XSS: экранирование вывода в нужном контексте

XSS — это инъекция, только интерпретатор здесь браузер, а команда — HTML/JavaScript.

XSS (Cross-Site Scripting) — внедрение скрипта на страницу через недоверенные данные, который исполнится в браузере другого пользователя в контексте вашего сайта.

Почему XSS опасен

Скрипт, исполненный на вашей странице, действует от имени жертвы: читает её куки и токены (если они доступны JS), отправляет запросы от её имени, подменяет содержимое страницы, ворует вводимые данные. По сути это захват сессии в браузере пользователя.

Чтобы прочувствовать масштаб, разберём конкретный сценарий. Представьте форум, где в комментарии можно вставить произвольный текст. Злоумышленник оставляет комментарий, внутри которого спрятан скрипт. Дальше ему ничего не нужно делать вручную: каждый, кто откроет страницу с этим комментарием, невольно запустит чужой код в своём браузере — в том числе администратор форума. Скрипт может тихо отправить сессионную куку администратора на сторонний сервер, и атакующий войдёт в панель управления, не зная ни логина, ни пароля. Так одна-единственная небезопасная вставка превращается в захват самого привилегированного аккаунта.

Важно понимать, что браузер по природе доверяет коду, пришедшему с вашего домена: для него скрипт жертвы и скрипт злоумышленника неразличимы, раз оба загрузились со страницы example.com. Поэтому внедрённый код наследует все права вашего сайта — может ходить к вашему API с куками пользователя, читать данные на странице, незаметно менять то, что видит человек. Антивирус и файрвол тут бесполезны: технически ничего «вредоносного» не скачивается, исполняется обычный JavaScript на доверенной странице.

Три типа XSS

ТипГде живёт payloadКогда срабатывает
Stored (хранимый)в БД (комментарий, профиль)у каждого, кто открыл страницу
Reflected (отражённый)в параметре запроса/URLу того, кто перешёл по ссылке
DOM-basedв клиентском JSпри обработке данных на клиенте

Различается путь данных, но корень один: недоверенный ввод попал в HTML/JS-контекст без обезвреживания.

Как возникает

// Уязвимо: ввод вставлен в HTML как есть
// comment = <script>steal(document.cookie)</script>
page = "<div>" + comment + "</div>";
// браузер увидит тег <script> и исполнит его

Защита: экранирование вывода под контекст

Главная защита — экранировать данные при выводе, переводя «активные» символы в их текстовые сущности. В HTML это <&lt;, >&gt;, &&amp;, кавычки — в &quot;/&#39;. Тогда <script> отобразится как текст, а не выполнится.

// Безопасно: экранируем перед вставкой в HTML
// comment = <script>...</script>  ->  &lt;script&gt;...&lt;/script&gt;
page = "<div>" + htmlEscape(comment) + "</div>";
// браузер покажет текст «<script>...», ничего не исполнит

Контекст решает. Данные внутри HTML-тела, в атрибуте, в URL и внутри <script> требуют разного экранирования. Никогда не вставляйте пользовательский ввод в исполняемый JS-контекст или в обработчик события — там HTML-экранирования недостаточно.

Поясним, почему одного набора правил не хватает. Если значение попадает в значение атрибута, то опасны уже не угловые скобки, а кавычки: закрыв кавычку, можно «вырваться» из атрибута и дописать свой обработчик события. Если значение идёт в href или src, то отдельную угрозу несёт схема javascript:, которую обычное HTML-экранирование вообще не трогает. А если данные оказываются внутри JS-строки, то их нужно экранировать по правилам JavaScript, иначе достаточно закрыть строку и кавычку, чтобы начать писать произвольный код. Один универсальный «фильтр спецсимволов» в одном контексте лишний, а в другом — дырявый; именно поэтому правильнее доверять экранирование инструменту, который знает, в какой контекст он пишет.

Как работает под капотом: шаблонизаторы экранируют сами

Современные шаблонизаторы (Jinja, ERB, React JSX) экранируют выводимые значения по умолчанию и в правильном контексте. Пока вы выводите данные штатно — {{ value }} или {value} в JSX — XSS закрыт автоматически. Опасность появляется, когда вы явно просите «сырой» вывод (| safe, dangerouslySetInnerHTML, v-html): защита отключается, и ответственность переходит к вам.

Под капотом шаблонизатор на этапе компиляции шаблона помечает каждую точку подстановки её контекстом и подставляет соответствующую функцию экранирования. Поэтому в большинстве случаев самое надёжное решение — вообще не выводить данные «руками» через конкатенацию строк, а пользоваться штатной подстановкой шаблонизатора и не отключать её. Беда начинается там, где разработчик собирает HTML вручную из кусочков строк: тогда никакой автоматики нет, и каждую вставку нужно экранировать самому, не забыв про контекст. Это ровно тот код, который стоит подсвечивать на ревью.

Когда нужен HTML от пользователя

Если по задаче пользователь должен вводить форматированный текст (редактор статей), не экранируйте его «насовсем» — пропустите через проверенный HTML-санитайзер со строгим allowlist тегов и атрибутов. Сами правила санитизации писать не стоит: обходов слишком много.

Разница между экранированием и санитизацией принципиальна, и её стоит держать в голове. Экранирование говорит браузеру: «считай это целиком текстом, не разметкой» — оно уместно почти везде, где данные не должны нести форматирование. Санитизация же сохраняет часть разметки (жирный шрифт, списки, ссылки), но вырезает всё потенциально опасное по строгому белому списку. Выбор зависит от задачи: для имени пользователя, текста комментария или поля поиска нужно экранирование, а санитизация — лишь там, где форматированный HTML действительно является осмысленной частью продукта. Применять санитайзер «на всякий случай» к данным, которым форматирование не нужно, — значит без причины расширять поверхность атаки.

Частые ошибки

  • Экранировать на входе вместо вывода. Контекст вывода ещё неизвестен; портятся данные.
  • Одно экранирование на все контексты. HTML-тело, атрибут, URL, JS — разные правила.
  • Чёрный список тегов. «Вырезать <script>» обходится через атрибуты-обработчики и другие теги.
  • Экранировать «один раз и навсегда». Одни и те же данные в разных местах страницы требуют разного экранирования; нельзя подготовить «универсально безопасную» строку заранее.
  • Доверять клиентской валидации. Проверка ввода в браузере — это удобство для пользователя, а не защита: запрос легко отправить в обход формы.

Итоги

  • XSS — инъекция в браузер: недоверенные данные исполняются как скрипт на вашей странице.
  • Защита — контекстное экранирование вывода; шаблонизаторы делают это по умолчанию.
  • Для богатого пользовательского HTML используйте проверенный санитайзер с allowlist.
Проверьте себя
1. Чем хранимый (stored) XSS отличается от отражённого (reflected)?
AХранимый работает только в одном браузере
BХранимый payload лежит в БД и срабатывает у каждого открывшего страницу; отражённый приходит в параметре запроса и бьёт по перешедшему по ссылке
CОтражённый опаснее, потому что шифрует данные
DМежду ними нет разницы
2. Какая основная защита от XSS?
AШифрование куки
BКонтекстное экранирование данных при выводе, переводящее активные символы в текстовые сущности
CЗапрет JavaScript на сайте
DИспользование длинных паролей
3. Почему конструкции вроде dangerouslySetInnerHTML или v-html требуют осторожности?
AОни медленные
BОни отключают автоматическое экранирование шаблонизатора и вставляют сырой HTML, открывая XSS
CОни не работают на мобильных
DОни запрещены стандартом HTML