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 это < → <, > → >, & → &, кавычки — в "/'. Тогда <script> отобразится как текст, а не выполнится.
// Безопасно: экранируем перед вставкой в HTML
// comment = <script>...</script> -> <script>...</script>
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.