Что такое межсайтовый скриптинг
Межсайтовый скриптинг (XSS) — уязвимость, при которой атакующий добивается того, чтобы страница вашего сайта включала написанный им JavaScript. Браузер не может отличить этот скрипт от вашего собственного: он приходит с вашего origin, так что выполняется со всеми правами, которые разрешены вашему origin, включая сессии ваших пользователей.
Корневая причина всегда одна. Текст, который контролирует кто-то другой, например комментарий, поисковый запрос, отображаемое имя или фрагмент URL, вставляется в страницу так, что браузер читает его как markup или код вместо простого текста. Комментарий, который должен появиться как буквальные символы <script>, вместо этого становится элементом script.
Имя историческое и слегка сбивает с толку: классическая атака подразумевала второй сайт, но сегодня большинство XSS — это просто ненадёжные данные, выполняемые внутри вашей собственной страницы. OWASP относит её к категории Injection в OWASP Top 10, рядом с её серверным родственником — SQL-инъекцией. Разница в интерпретаторе. SQL-инъекция обманывает вашу базу данных; XSS обманывает браузеры ваших пользователей.
XSS — ошибка в приложении, не в браузере и не в сети. HTTPS её не предотвращает, файрвол не предотвращает её полностью, и только код, строящий страницу, может её устранить.
Три вида: stored, reflected и DOM-based
Stored XSS (также постоянная) — самый разрушительный вид. Вредоносный ввод сохраняется сервером — в комментарии, поле профиля, отзыве о товаре или тикете поддержки — а затем отдаётся каждому посетителю, открывшему эту страницу, часто включая администраторов, читающих бэк-офис. Никому не нужно кликать по особой ссылке.
Reflected XSS не хранится. Сервер берёт что-то из запроса, обычно параметр query, и записывает это прямо в ответ: страница поиска, печатающая «Результаты для ...», или страница ошибки, повторяющая плохое значение. Атакующему нужно заставить жертву открыть специально подготовленную ссылку — через письмо, чат или другой сайт.
DOM-based XSS происходит целиком в браузере. Ваш собственный JavaScript читает значение, контролируемое атакующим, например location.hash, location.search, данные postMessage или document.referrer, и записывает его в опасный sink: innerHTML, document.write, eval, setTimeout со строкой или URL javascript: в href. Сервер может вообще никогда не увидеть payload, поскольку всё после # не отправляется с запросом.
Три учебных примера ниже показывают форму каждой ошибки. В каждом случае решение — одна и та же идея: обращаться со значением как с текстом.
Минимальные уязвимые паттерны (не используйте их в продакшене)
<!-- Stored: a saved comment printed without escaping (PHP) -->
<p><?= $comment->body ?></p>
<!-- body saved as: <script>alert(document.domain)</script> -->
<!-- Reflected: the search term echoed from the URL -->
<h1>Results for <?= $_GET['q'] ?></h1>
<!-- link: /search?q=<script>alert(document.domain)</script> -->
// DOM-based: client code writes the URL fragment as HTML
document.getElementById('greeting').innerHTML =
decodeURIComponent(location.hash.slice(1));
// link: /welcome#<img src=x onerror=alert(document.domain)>
Что атакующий может с этим сделать
alert(1) — это то, чем тестировщики доказывают наличие ошибки; не то, что запускают атакующие. Как только их скрипт исполняется в вашей странице, он действует как залогиненный пользователь, внутри вашего origin.
Кража сессии. Если cookie сессии читаем из JavaScript, скрипт отправляет его атакующему, и тот логинится как жертва. Токены, хранящиеся в localStorage или sessionStorage, всегда читаемы скриптом, что является главным аргументом против хранения долгоживущих токенов там.
Действия от имени пользователя. Даже когда cookie помечен HttpOnly, скрипт может вызвать ваш API через fetch; браузер сам прикрепит cookie. Он может прочитать CSRF-токены со страницы, изменить email на аккаунте, создать администратора, отправить сообщения или оформить заказы. Правила same-origin, CORS и cookie SameSite этого не останавливают, потому что запрос приходит с вашего собственного сайта.
Чтение того, что видит пользователь. Персональные данные, сообщения, счета — всё, что на странице или доступно через ваш API.
Фишинг и кейлоггинг на вашем домене. Скрипт может перерисовать страницу как форму логина или записывать, что пользователь вводит в настоящие формы. Адресная строка показывает ваш домен и валидный сертификат, так что у пользователя нет причин сомневаться.
Дефейс и распространение. Stored XSS может изменить то, что видит каждый посетитель, или скопировать себя в профиль каждой жертвы. Червь Samy на MySpace в 2005 году распространился так на более чем миллион профилей менее чем за день.
Насколько серьёзен конкретный XSS, зависит от того, кто видит страницу. Ошибка на экране, доступном только администраторам, достигнутая через хранимый ввод, например тему тикета поддержки, часто хуже, чем ошибка на публичной главной странице.
Решение: контекстно-зависимое кодирование вывода
Основная защита — кодировать ненадёжные данные в момент записи их в страницу так, как требует окружающий контекст. Валидация ввода помогает (почтовый индекс должен выглядеть как почтовый индекс), но не может быть основным контролем, потому что одно и то же значение может быть безопасным в одном контексте и опасным в другом.
Тело HTML: экранируйте &, <, >, " и ' в сущности, чтобы <script> отображался, а не разбирался.
Атрибут HTML: всегда берите значения атрибутов в кавычки и экранируйте те же символы. Атрибут без кавычек можно разорвать одним пробелом.
URL в href или src: одного кодирования недостаточно, потому что в javascript:alert(1) нет ничего, что можно было бы экранировать. Разбирайте URL и разрешайте только https: и http: (и mailto:, если нужно); кодируйте значения query через encodeURIComponent.
Внутри блока <script>: не конкатенируйте строки в JavaScript. Сериализуйте данные как JSON функцией, которая также экранирует <, либо поместите их в атрибут data- и читайте через element.dataset.
Стили: вообще избегайте помещать данные пользователя в CSS.
На клиенте: предпочитайте безопасные API. textContent, setAttribute для безвредных атрибутов и createElement никогда не разбирают HTML; innerHTML, outerHTML, insertAdjacentHTML и document.write — разбирают.
Безопасная запись данных пользователя в браузере
// Text, never markup
el.textContent = userName;
// Links: allow only http(s)
function safeUrl(value) {
try {
const url = new URL(value, location.origin);
return ['https:', 'http:'].includes(url.protocol) ? url.href : '#';
} catch {
return '#';
}
}
link.href = safeUrl(profile.website);
// Data for scripts: a data attribute, not string concatenation
// <div id="app" data-user="{{ user_json }}"> (escaped by the template)
const user = JSON.parse(document.getElementById('app').dataset.user);
Автоэкранирование фреймворков и его лазейки
Современные фреймворки уже кодируют для контекста HTML по умолчанию. {{ }} в Blade, Twig, Jinja, шаблонах Django, Vue и Angular, <%= %> в Rails ERB и {value} в React JSX — все экранируют то, что печатают. Это главная причина, почему XSS встречается реже, чем в самодельных PHP-страницах, и причина продолжать использовать способ печати значений фреймворка по умолчанию.
Почти каждый реальный XSS в современной кодовой базе живёт в лазейке — функции, намеренно отключающей экранирование:
- React dangerouslySetInnerHTML, Vue v-html, Svelte {@html}, Angular bypassSecurityTrustHtml.
- Laravel Blade {!! $value !!}, Jinja |safe и Markup(), Django mark_safe и {% autoescape off %}, Rails raw и html_safe.
- Прямая запись в DOM из кода компонента: ref.current.innerHTML = ..., jQuery .html(), $(userInput).
Остальные пробелы — это контексты, которые автоэкранирование не понимает. Шаблон, экранирующий HTML, всё равно пропускает javascript: в href={url}, всё равно позволяет значению пользователя внутри встроенного <script> или обработчика события разорвать код, и никогда не защищает строку, передаваемую в eval.
Практическое правило: сделайте лазейки редкими, легко находимыми при поиске и проверенными. Поиск по коду по списку выше находит большую часть вашей уязвимости, а правило линтера (например, ESLint react/no-danger, или правило Semgrep для {!!) не даёт новым проскальзывать незамеченными.
Когда вы обязаны принимать HTML: используйте настоящий санитайзер
Некоторым функциям нужен HTML пользователя: редактор rich-text, тело CMS, пост на форуме с форматированием, предпросмотр письма. Экранирование уничтожило бы форматирование, поэтому ответ — санитизация: разбор HTML и сохранение только allowlist безопасных элементов и атрибутов.
Используйте поддерживаемую библиотеку, созданную именно для этого, никогда регулярное выражение или blocklist «плохих» тегов. Браузеры разбирают HTML неожиданным образом, и каждый самодельный фильтр, удаляющий <script>, обходили через атрибут обработчика события, элемент SVG, странную вложенность или трюк с кодировкой. Признанные варианты — DOMPurify в браузере и в Node.js с jsdom, HTML Purifier для PHP, nh3 (библиотека ammonia на Rust) для Python и OWASP Java HTML Sanitizer для Java.
Три детали делают разницу. Санитизируйте с allowlist настолько маленьким, насколько нужно функции. Санитизируйте на выводе или повторно на выводе, чтобы более позднее изменение в библиотеке или в вашем allowlist защищало старые данные. И не изменяйте HTML после санитизации: повторный разбор, замена строк или вставка в другой контекст может превратить безопасный результат в небезопасный.
Для Markdown итоговый HTML нуждается в той же обработке: большинство рендереров Markdown по умолчанию разрешают сырой HTML.
Ограничение ущерба: HttpOnly, SameSite и Content-Security-Policy
Считайте, что один XSS в итоге проскочит, и сделайте его менее ценным.
Cookie. Помечайте cookie сессии как HttpOnly, чтобы document.cookie не мог их прочитать, плюс Secure и SameSite=Lax или Strict. Это останавливает сценарий с кражей cookie. Это не останавливает скрипт от действий от имени пользователя, пока страница открыта, так что это контроль ущерба, а не решение. По той же причине держите долгоживущие токены вне localStorage.
Content-Security-Policy. CSP сообщает браузеру, какие скрипты могут выполняться. Политика, останавливающая XSS, — это строгая политика: случайный nonce, сгенерированный заново для каждого ответа, помещённый и в заголовок, и на каждый легитимный тег <script>, плюс 'strict-dynamic', object-src 'none' и base-uri 'none'. У внедрённого <script> нет валидного nonce, а встроенные обработчики событий, такие как onerror=, блокируются, так что большинство ошибок XSS превращается в ошибку в консоли и отчёт. Политика, которая только перечисляет домены и сохраняет 'unsafe-inline', даёт мало защиты от XSS.
Выкатывайте её сначала как Content-Security-Policy-Report-Only, исправьте то, что показывают отчёты, затем включайте принудительно. Руководство по HTTP-заголовкам безопасности разбирает выкатывание в режиме только отчётов, другие заголовки рядом с ней, и почему X-XSS-Protection больше не стоит отправлять.
Одна ловушка с кэшированием: nonce должен быть непредсказуемым. Если CDN или кэш страницы отдаёт один и тот же HTML всем, каждый посетитель получает один и тот же nonce, и атакующий может прочитать его со страницы. Для кэшируемых страниц используйте хеши скриптов ('sha256-...') вместо nonce, либо держите HTML, несущий nonce, вне кэша.
Где браузеры это поддерживают, require-trusted-types-for 'script' идёт дальше: DOM-sinks, такие как innerHTML, отказываются принимать простые строки, так что DOM-based XSS должен пройти через код, который вы написали.
Строгая политика на основе nonce (новый nonce на каждый ответ)
Content-Security-Policy: script-src 'nonce-R4nd0mPerResponse' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script nonce="R4nd0mPerResponse" src="/js/app.js"></script>
Set-Cookie: session=...; Path=/; Secure; HttpOnly; SameSite=Lax
Тестирование собственного приложения на XSS
Тестируйте только системы, которыми вы владеете или на тестирование которых у вас есть разрешение. Для вашего собственного приложения безвредный маркер находит большинство ошибок без какого-либо кода атаки.
Проследите каждый вход до каждого выхода. Поместите уникальный маркер с HTML-значимыми символами, например xss7"'<b>bold</b>, в каждое поле, параметр query, заголовок, который ваше приложение отображает, и имя файла, которое вы принимаете. Затем посетите каждую страницу, где это значение появляется, включая админские экраны, письма, экспорты и уведомления. Если слово выглядит жирным, или исходный код страницы показывает необработанный <b> или кавычку, закрывающую атрибут, — вывод не кодируется.
Проверяйте DOM, а не только исходник. Для ошибок DOM-based поместите маркер в location.hash и строку запроса и инспектируйте живой DOM в инструментах разработчика: view-source показывает только то, что отправил сервер.
Ищите по коду. Грепайте лазейки, перечисленные выше, и innerHTML, insertAdjacentHTML, document.write, eval и new Function. Каждое совпадение должно либо исчезнуть, либо иметь короткий комментарий, объясняющий, почему ввод безопасен.
Используйте инструменты. OWASP ZAP и Burp Suite обходят сайт и тестируют входы автоматически, находя очевидные reflected случаи; статический анализ вроде Semgrep или CodeQL следит за потоками данных, которые краулер не видит. Ни один из них не заменяет ручную трассировку для stored XSS на экранах бэк-офиса.
Следите за отчётами CSP. Когда политика только для отчётов уже на месте, нарушения от неожиданных встроенных скриптов — бесплатное раннее предупреждение.
grep -rnE 'dangerouslySetInnerHTML|v-html|\{@html|bypassSecurityTrust|innerHTML|insertAdjacentHTML|document\.write' src/
grep -rnE '\{!!|\|safe|mark_safe|html_safe|raw\(' resources/ templates/ app/
Место WAF и что делает WAF CDN.com.tr
Веб-файрвол приложений проверяет запросы и блокирует те, что похожи на атаки. Для XSS это полезно: теги script и обработчики событий в строке query или теле формы распознаваемы, так что reflected попытки и автоматические сканеры останавливаются до того, как достигнут вашего приложения, а хранимый payload, отправленный через обычную форму, часто отвергается на входе.
Это слой, а не решение. WAF видит запросы, а не ваши страницы, так что не может знать, как значение будет использовано позже. DOM-based XSS, живущий во фрагменте URL, никогда до него не доходит, ввод, приходящий через импорт, API, который вы вызываете, или канал, который WAF не инспектирует, проскальзывает мимо, а атакующие подгоняют payload, чтобы избежать паттернов. Руководство OWASP Top 10 детально сопоставляет, что файрвол может и не может перехватить. Кодируйте вывод, санитизируйте HTML и задавайте CSP независимо от того, стоит ли перед вами WAF.
На CDN.com.tr WAF — это ModSecurity с OWASP Core Rule Set, работающий на edge и включаемый для аккаунта со страницы правил доставки. Семейство 941 Core Rule Set — это правила против межсайтового скриптинга. Заблокированный посетитель получает брендированную страницу 403 с ID запроса, а страница Логи WAF перечисляет заблокированные события с категорией атаки, страной, IP и правилом, с поиском по этому ID за последние 30 дней и экспортом в CSV или XLSX; cdnctl waf logs возвращает тот же список из командной строки.
Обычное ложное срабатывание для правил XSS — легитимный HTML-пост, например редактор, сохраняющий отформатированный контент, или пример кода. Исключения для отдельных путей пока недоступны в панели: если WAF блокирует легитимный запрос, отправьте его ID запроса в поддержку, а не отключайте WAF.
Заголовки тоже можно добавить на edge. Однозначные заголовки идут в Пользовательские заголовки правила доставки, по одному в строке, и применяются к ответам 2xx и 3xx, включая попадания в кэш. Полный Content-Security-Policy туда не поместить, потому что панель и edge отвергают ; в строке заголовка; одна директива вроде frame-ancestors 'self' работает, а nonce всё равно нужно генерировать на каждый ответ, так что политика должна жить в вашем приложении.
cdnctl waf logs --account $ACCOUNT_UUID --range 1d
cdnctl waf show $REFERENCE_ID --account $ACCOUNT_UUID
Частые вопросы про XSS
В чём разница между stored, reflected и DOM-based XSS?
В том, где живёт payload. Stored XSS сохраняется на сервере и отдаётся каждому, кто открывает страницу. Reflected XSS возвращается из того же запроса, так что жертве нужно открыть специально подготовленную ссылку. DOM-based XSS создаётся вашим собственным клиентским JavaScript, записывающим контролируемое атакующим значение, часто из URL, в страницу; сервер может его вообще не увидеть.
Защищает ли HTTPS или валидный сертификат от XSS?
Нет. TLS защищает данные при передаче. Payload XSS доставляется вашим собственным сервером или вашим собственным скриптом по тому же зашифрованному соединению, а замочек делает фальшивую форму логина на вашем домене более, а не менее убедительной.
Достаточно ли HttpOnly, чтобы остановить XSS?
Нет. Он останавливает чтение cookie сессии скриптом, что убирает один из исходов. Скрипт всё ещё может отправлять запросы от имени пользователя, потому что браузер сам прикрепляет cookie, и может читать и менять страницу. HttpOnly — это контроль ущерба; кодирование вывода — решение.
Может ли WAF остановить межсайтовый скриптинг?
Он останавливает многие reflected и автоматические попытки, потому что payload скриптов в запросах распознаваемы. Он не может увидеть DOM-based XSS в фрагменте URL, не знает, как ваше приложение использует хранимое значение, и его можно обойти подогнанным payload. Относитесь к нему как к слою перед корректным кодом.
Стоит ли мне всё ещё отправлять X-XSS-Protection?
Нет. Фильтр, который он контролировал, удалён из современных браузеров, а в старых его можно было использовать во вред. Не отправляйте ничего или отправляйте X-XSS-Protection: 0 и используйте вместо него Content-Security-Policy.
Делают ли React или Vue моё приложение иммунным к XSS?
Они делают безопасным общий случай, экранируя то, что вы печатаете. Вы всё равно уязвимы через dangerouslySetInnerHTML и v-html, через URL пользователя в href, начинающиеся с javascript:, через прямую запись в DOM и через HTML, отрендеренный на сервере, который фреймворк не контролирует.
Что такое self-XSS?
Скрипт, который жертву обманом заставляют вставить в собственную консоль разработчика браузера. Для него не нужно никакой ошибки в вашем сайте, поэтому браузеры предупреждают при вставке в инструменты разработчика. Это социальная инженерия, а не уязвимость в вашем коде.