Экранируйте и раскрывайте HTML-сущности. Преобразуйте < > & " ' в именованные сущности, при необходимости кодируйте все не-ASCII символы и раскрывайте именованные или числовые ссылки обратно в текст.
Экранируйте текст, чтобы он был безопасен внутри HTML, или превращайте сущности обратно в символы. Вводите в любое поле, чтобы конвертировать в этом направлении. Минимальный режим экранирует только пять символов, важных для корректности; расширенный режим также кодирует каждый не-ASCII символ для максимальной совместимости.
HTML-парсер читает < как начало тега, а & как начало ссылки на сущность. Если ваш контент содержит любой из них, парсер перестаёт считать его текстом. Экранирование заменяет символ ссылкой, которую парсер разрешает обратно в исходный символ, не придавая ей структурного смысла.
Действительно нужно экранировать только пять символов:
& становится & — должен быть первым, иначе вы дважды экранируете всё остальное
< становится <
> становится >
" становится " — обязателен внутри значений атрибутов в двойных кавычках
' становится ' — обязателен внутри значений атрибутов в одинарных кавычках
В тексте элемента строго нужны только & и <. Остальные три важны, как только ваш текст может попасть внутрь атрибута, а поскольку вы редко контролируете это в момент экранирования, экранирование всех пяти — безопасная привычка.
Именованные, десятичные и шестнадцатеричные ссылки
Один и тот же символ можно записать тремя способами. Неразрывный пробел — это ,   или  . Именованные ссылки читаемее всего, но в списке HTML5 около 2200 записей, а старые парсеры знают куда меньше, поэтому неизвестные имена — риск переносимости. Числовые ссылки работают для любой точки кода Unicode и всегда безопасны. Каждая именованная ссылка в HTML5 требует завершающей точки с запятой; горстка устаревших имён вроде & всё ещё разбирается без неё для обратной совместимости, но полагаться на это — ошибка, которая рано или поздно проявится.
Экранирование — это не санитизация
Экранирование — это то, что останавливает межсайтовый скриптинг: если пользовательский ввод, содержащий <script>, экранирован в <script>, браузер выводит буквальный текст и ничего не выполняет. Но экранирование работает, только если применяется в правильном контексте. HTML-экранирования недостаточно внутри блока <script>, внутри атрибута style или в URL — каждый из них требует собственного кодирования. И экранирование должно применяться один раз, на выводе. Экранирование на вводе и снова на выводе даёт &lt; и видимый мусор.
Когда нужен расширенный режим
Экранирование не-ASCII символов не требуется ни одной современной спецификацией. Документ в UTF-8 может содержать é и 中 напрямую, и это обычно лучший выбор: короче, читаемее и проще для поиска. Расширенный режим существует для случаев, когда поток байтов должен пережить канал, искажающий не-ASCII — некоторые устаревшие почтовые шаблоны, старые поля CMS, XML-конвейеры с неясной объявленной кодировкой или этап сборки, застрявший в Latin-1. Если вы контролируете кодировку от начала до конца, оставайтесь в минимальном режиме.
Ловушка неразрывного пробела
— это U+00A0, символ, отличный от обычного пробела U+0020. Он предотвращает перенос строки и не даёт браузерам схлопывать последовательности пробелов, поэтому WYSIWYG-редакторы щедро выдают его. Поскольку он выглядит одинаково, он молча ломает сравнение строк, регулярные выражения, написанные с \s в некоторых движках, и импорт CSV. Если значение выглядит правильным, но отказывается совпадать, проверьте на случайный неразрывный пробел.
Часто задаваемые вопросы
Нужно ли экранировать знак больше?
Строго нет — одинокий > в тексте однозначен и разбирается нормально. Его экранируют по традиции, потому что это ничего не стоит и избавляет от путаницы в сгенерированной разметке, особенно когда вывод позже обрабатывается инструментами строже браузера.
Почему & нужно экранировать первым?
Потому что каждая другая escape-последовательность начинается с амперсанда. Если вы замените < на < до экранирования &, следующий проход превратит этот амперсанд в &, и в итоге на странице окажется &lt;.
Достаточно ли экранирования для защиты от XSS?
Только для текстового и атрибутного контекста HTML и только при применении на выводе. Контент, внедряемый в JavaScript, CSS или URL, требует правил экранирования этого языка. Экранирование — один слой, а не полная защита.
Именованные или числовые сущности — что выбрать?
Числовые ссылки поддерживаются повсеместно и работают для любой точки кода. Именованные читаемее для горстки общеизвестных. На практике используйте именованные для пяти основных символов и числовые для всего необычного.
Стоит ли кодировать буквы с диакритикой?
Не в документе UTF-8, которым по сути является весь современный веб-контент. Пишите их напрямую. Используйте расширенный режим, только если что-то в вашем конвейере не заслуживает доверия в переноске не-ASCII байтов без искажений.
В чём разница между и обычным пробелом?
Неразрывный пробел — это кодовая точка U+00A0. Он выглядит так же, но предотвращает перенос строки и не схлопывается с соседними пробелами. Это также отдельный символ для целей сравнения, что делает его частым источником тонких ошибок.