Перейти к содержимому
New

DeepSeek V4.1 Flash free for 7 days: 10M tokens a day after a top-up of $10 or more. Until Nov 30.

How it works

Межсайтовый скриптинг (XSS): типы и защита

Отражённый, хранимый и DOM-based XSS, почему кодирование вывода зависит от контекста и как CSP добавляет ещё один эшелон защиты, в локальной лабораторной работе.

Средний9 мин чтенияАвтор: NotHoly

Что такое межсайтовый скриптинг

Межсайтовый скриптинг (XSS) возникает, когда веб-приложение берёт данные, на которые может влиять атакующий, и вставляет их в страницу так, что браузер читает их как код. Браузер выполняет этот код с привилегиями origin страницы. Код может читать страницу, отправлять запросы от имени пользователя и менять то, что пользователь видит.

Суть проблемы не в том, что скрипт приходит с другого сайта. Приложение смешивает данные и код, и браузер не может определить, какая часть пришла от разработчика, а какая от пользователя. MITRE классифицирует это как CWE-79, «Improper Neutralization of Input During Web Page Generation». Формулировка точная: слабость состоит в том, что ввод не обезвредили там, где собирается страница.

Почему это важно

XSS относится к самым распространённым веб-уязвимостям, и распространённой остаётся потому, что прячется в обычных функциях: в поле поиска, которое повторяет запрос, в комментарии, в имени профиля, во фрагменте URL, который одностраничное приложение читает при загрузке. Успешная атака выполняется внутри сессии вошедшей в систему жертвы. Она может прочитать всё, что может прочитать страница, запустить действия, разрешённые пользователю, или показать поддельную форму входа. Браузеры отчасти ограничивают это политикой same-origin, но XSS работает как раз потому, что выполняется внутри origin, которому пользователь уже доверяет.

Практический вывод для разработчика узкий и полезный. Отфильтровывать каждую плохую строку незачем. Нужно сделать так, чтобы браузер никогда не разбирал недоверенный текст как разметку или код.

Три типа

OWASP описывает три семейства. Они различаются тем, какой путь проходят недоверенные данные до браузера.

Отражённый XSS. Сервер берёт значение из текущего запроса, обычно параметр запроса или поле формы, и возвращает его в ответе без безопасного кодирования. Атакующий собирает ссылку, в которой лежит payload. Для атаки жертва должна открыть эту ссылку. На сервере payload нигде не сохраняется.

Хранимый XSS. Приложение сохраняет ввод под контролем атакующего, например комментарий, имя пользователя или тикет поддержки, и позже показывает его другим пользователям. Особая ссылка жертве не нужна. Достаточно открыть обычную страницу, поэтому у хранимого XSS обычно самый большой ущерб.

DOM-based XSS. Сервер может вообще не увидеть payload. Клиентский JavaScript читает источник, который контролирует атакующий, например location.hash, location.search, document.referrer или сообщение из postMessage, и записывает его в опасный синк (sink), например innerHTML, document.write или eval. Баг находится в коде, который работает в браузере. Серверные фильтры атаку не видят, поэтому такой тип застаёт врасплох команды, которые проверяют только бэкенд.

Корневая причина у всех трёх типов одна: недоверенные данные попадают в контекст, который интерпретирует их как код.

Почему защиту определяет контекст

Кодирование вывода — главная защита, и единой операции здесь нет. Правильное кодирование зависит от места, куда попадает значение. В OWASP XSS Prevention Cheat Sheet правила перечислены для каждого контекста. Коротко:

Контекст, куда попадает значение Что делать Частая ошибка
Тело HTML Кодировать HTML-сущностями &, <, >, ", ' Двойное кодирование, из-за которого на странице виден &amp;lt;
Атрибут HTML Кодировать и всегда брать значение атрибута в кавычки Оставлять значение без кавычек или считать, что это защищает href и обработчики событий
Строка JavaScript Использовать библиотечный кодировщик именно для этого контекста Экранировать для HTML, а затем вставлять значение в блок <script>
Параметр URL Кодировать значение процентным кодированием через encodeURIComponent(), а для целых URL разрешать только схемы http и https Считать кодирование достаточным для полного URL от пользователя: схема javascript: всё равно выполнится, попав в href
CSS Не помещать недоверенные данные в CSS, задавать фиксированные свойства через CSSOM Собирать строку атрибута style из пользовательского ввода

Таблица показывает, почему правило «санитизируйте ввод» неполное. Строка, безопасная в абзаце, может быть опасной в атрибуте href, потому что javascript: там допустимая схема URL. Та же строка безвредна, если записана через textContent. Кодирование должно соответствовать синку, в который значение попадает в момент записи.

В шпаргалке OWASP также сказано, что шестнадцатеричные или Unicode-экранирования сами по себе не заменяют контекстно-зависимый кодировщик JavaScript и что eval(), setTimeout() и setInterval() вообще не должны получать недоверенные строки. Кодируйте на выводе и держите эти функции подальше от недоверенных путей.

Безопасные синки и экранирование во фреймворках

Решений станет меньше, если выбирать синки, которые считают ввод текстом. Присваивание element.textContent или element.value записывает символы, разметка при этом не создаётся. Присваивание element.innerHTML запускает HTML-парсер. Шпаргалка OWASP называет innerHTML синком, которого следует избегать для недоверенных данных, и рекомендует textContent или value.

Современные фреймворки, такие как React, Angular, Vue и Svelte, экранируют значения в стандартных привязках шаблонов. Значение, записанное как {userName} в JSX или как интерполяция в шаблоне, становится текстом. Риск возвращается, когда разработчики пользуются обходными механизмами: dangerouslySetInnerHTML в React, bypassSecurityTrustHtml в Angular, v-html во Vue или прямой записью в DOM из библиотеки. Через такие обходы XSS обычно и попадает в приложение на фреймворке. Считайте каждое их использование пунктом для ревью и ведите их список.

Content Security Policy как второй слой

Content Security Policy (CSP) — это заголовок ответа или элемент <meta http-equiv>, который сообщает браузеру, какие источники скриптов, стилей, картинок и других ресурсов может использовать страница. MDN описывает главное её назначение как контроль над тем, какие ресурсы может загружать документ, что защищает от XSS. Директива default-src задаёт значение по умолчанию для каждого типа ресурсов, у которого нет своей директивы.

При default-src 'self' браузер отказывается выполнять инлайновые скрипты и инлайновые обработчики событий вроде onerror="...", а также URL javascript: там, где они запустили бы код. Бага в вашем HTML CSP не исправляет, зато ограничивает возможности внедрённого payload. Поэтому её и называют эшелонированной защитой: кодирование останавливает внедрение, а CSP делает пропущенное внедрение менее полезным.

Есть два ограничения. Политика в <meta> поддерживает не все возможности CSP, и MDN отмечает, что политики report-only надо отправлять в HTTP-заголовках. Прежде чем полагаться на <meta>, проверьте на странице MDN, какие директивы ваши браузеры в нём учитывают. Политика с 'unsafe-inline' или широким списком хостов защищает намного слабее.

Лабораторная работа

Работа состоит из двух частей. Сначала вы решите задание DOM XSS в OWASP Juice Shop, намеренно уязвимом приложении, запущенном на вашей машине. Затем соберёте небольшую страницу, которая записывает один и тот же недоверенный текст в два разных синка, и посмотрите, что сделает браузер.

Выполняйте все шаги на машине, которой распоряжаетесь сами. Привязывайте сервисы к 127.0.0.1. Не направляйте эти payload на сайты, которые вам не принадлежат и на тестирование которых у вас нет письменного разрешения.

Часть 1: запустите Juice Shop локально

Убедитесь, что Docker отвечает:

bash
docker --version
docker info --format '{{.ServerVersion}}'

На машине, использованной для этой статьи, первая команда напечатала Docker version 29.8.2, build 7fc2dff. Ваша версия может отличаться.

Запустите контейнер с пробросом порта только на loopback:

bash
docker run -d -p 127.0.0.1:3000:3000 bkimminich/juice-shop

Префикс 127.0.0.1: публикует порт только на loopback-интерфейсе. Если порт 3000 уже занят, выберите другой порт хоста, например 127.0.0.1:3001:3000, и измените URL ниже соответствующим образом.

Проверьте, что приложение отвечает, и прочитайте его версию:

bash
curl -s http://127.0.0.1:3000/rest/admin/application-version

На машине, использованной для этой статьи, ответ был {"version":"20.3.0"}. Первый запуск может занять некоторое время, поэтому, если ответа нет, повторите запрос.

Часть 2: решите задание DOM XSS

В официальном руководстве по Juice Shop есть раздел «Perform a DOM XSS attack». В его подсказке предлагается искать поле ввода, содержимое которого появляется в HTML при отправке формы. Руководство приводит такой payload:

html
<iframe src="javascript:alert(`xss`)">

Следуйте руководству в своём браузере. Откройте строку поиска Juice Shop, вставьте payload и отправьте. Подойдёт и URL поиска с запросом во фрагменте. На машине, использованной для этой статьи, этот URL был загружен в headless Chrome, после чего скорборд показал задание как решённое:

text
http://127.0.0.1:3000/#/search?q=%3Ciframe%20src%3D%22javascript%3Aalert(%60xss%60)%22%3E

Запросите состояние задания через API приложения:

bash
curl -s "http://127.0.0.1:3000/api/Challenges/?name=DOM%20XSS" | grep -o '"solved":[a-z]*'

До загрузки URL поиска вывод был "solved":false. После загрузки он стал "solved":true. Когда вы запустите задание в браузере, результат у вас должен совпасть.

Обратите внимание, что сделало этот баг DOM-based. Payload лежит во фрагменте, который браузер не отправляет на сервер, а записывает его в страницу клиентский код. В логе сервера будут только запросы HTML и скриптов приложения.

Часть 3: соберите стенд с синками

Создайте папку и перейдите в неё:

bash
mkdir xsslab && cd xsslab

Создайте index.html. Этот файл читает текст после #, записывает его один раз через innerHTML и один раз через textContent и добавляет meta-элемент CSP из задания к лабораторной работе:

html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
  <title>XSS sink lab</title>
</head>
<body>
  <h1>Sink comparison</h1>
  <p>Put text after the # in the URL, for example <code>#hello</code>.</p>
  <h2>innerHTML</h2>
  <div id="html-sink"></div>
  <h2>textContent</h2>
  <div id="text-sink"></div>
  <script src="app.js"></script>
</body>
</html>

Создайте app.js в той же папке. Скрипт вынесен в отдельный файл намеренно. Инлайновый блок <script> тоже был бы заблокирован политикой, и это помешало бы нужному сравнению.

javascript
// Read the fragment once, then write it to two different sinks.
const raw = decodeURIComponent(location.hash.slice(1));
document.getElementById('html-sink').innerHTML = raw;
document.getElementById('text-sink').textContent = raw;

Запустите сервер для этой папки только на loopback. В Windows Python обычно запускается командой python, в Linux и macOS часто python3:

bash
python -m http.server 8000 --bind 127.0.0.1

Оставьте этот терминал открытым на время работы. Откройте http://127.0.0.1:8000/index.html в браузере. Когда закончите, остановите сервер клавишами Ctrl+C.

Часть 4: сравните два синка

Откройте инструменты разработчика браузера и вкладку Console. Сначала загрузите страницу с безобидным значением:

text
http://127.0.0.1:8000/index.html#hello

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

javascript
document.getElementById('html-sink').innerHTML
document.getElementById('text-sink').textContent

Обе показывают hello. Теперь загрузите фрагмент, в котором смешаны разметка и обработчик события:

text
http://127.0.0.1:8000/index.html#hello%20%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E

Я загрузил именно этот URL в headless Chrome 156.0.8078.80 и выгрузил получившийся DOM. Нужная часть вывода выглядела так:

html
<div id="html-sink">hello <img src="x" onerror="alert(1)"></div>
<div id="text-sink">hello &lt;img src=x onerror=alert(1)&gt;</div>

Синк innerHTML создал элемент img. Синк textContent сохранил те же символы как обычный текст. Выполните две строки в консоли ещё раз на этом URL и проверьте, что первая возвращает разметку с элементом, а вторая экранированную строку.

Часть 5: добавьте политику и посмотрите, как она блокирует обработчик

На странице выше уже есть default-src 'self'. Тот же запуск в headless-режиме записал такое сообщение:

text
Executing inline event handler violates the following Content Security Policy directive 'default-src 'self''.

Элемент img создан, но его обработчик onerror не выполнил alert(1). Инлайновый обработчик остановила политика. В вашей консоли должно появиться похожее сообщение. Точная формулировка в разных версиях браузера может отличаться.

Чтобы увидеть разницу, удалите meta-элемент, снова загрузите тот же URL и дождитесь alert. Затем верните meta-элемент на место. Это единственный шаг лабораторной работы, где payload должен выполниться.

Учтите, что политика не сделала синк innerHTML безопасным. Она заблокировала один вид выполнения. Элемент img по-прежнему есть в DOM, и атакующий, нашедший разрешённый политикой payload, всё ещё сможет этим воспользоваться. Правильное исправление — записывать недоверенный текст через textContent.

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

Однократное экранирование для неверного контекста. HTML-кодирование защищает текст в абзаце. URL, строку JavaScript или значение CSS оно не защищает. Подбирайте кодировщик под место, куда попадает значение.

Фильтрация известных плохих строк. Чёрные списки, удаляющие <script>, пропускают <img onerror>, <svg onload> и URL javascript:. Кодирование и безопасные синки работают потому, что им не нужно угадывать следующую строку атакующего.

Слепое доверие фреймворку. Стандартные привязки экранируют значения, но dangerouslySetInnerHTML, v-html и bypassSecurityTrustHtml эту защиту обходят. Найдите их в своём коде.

Чтение только серверного кода. DOM XSS до сервера не доходит. Проверяйте клиентский код, который читает данные location, document.referrer и postMessage.

CSP как единственный контроль. Строгая политика помогает, но кодирование не заменяет, а слабая политика с 'unsafe-inline' защищает мало.

Уверенность, что в meta-политике есть всё. Политика в <meta> не умеет работать в режиме report-only и поддерживает не все директивы. Если нужны отчёты, отправляйте политику в заголовке ответа.

Что дальше

Прочитайте OWASP Cross Site Scripting Prevention Cheat Sheet от начала до конца. Для справочника по безопасности он короткий и охватывает все контексты из таблицы выше. Затем прочитайте руководство MDN по CSP и попробуйте на своей лабораторной странице более строгую политику с nonce или хешами.

Пройдите другие задания Juice Shop, связанные с XSS, и сравните, чем хранимый и отражённый варианты отличаются от только что решённого DOM-случая. Оставляйте лабораторию на loopback, а когда закончите, остановите контейнер:

bash
docker ps --filter ancestor=bkimminich/juice-shop
docker stop <container-id>

Следующая статья серии посвящена межсайтовой подделке запросов и тому, как cookie с SameSite ограничивают один из её путей. XSS и CSRF часто путают, и разницу стоит понять до проектирования модели сессий.

Источники

Источники

  1. OWASP Cross Site Scripting Prevention Cheat Sheet
  2. MITRE CWE-79: Improper Neutralization of Input During Web Page Generation
  3. MDN Web Docs: Content Security Policy (CSP)
  4. Pwning OWASP Juice Shop (companion guide)

Ещё по теме: Безопасность веба и API

Безопасность веба и API

Чтение JavaScript с ИИ для поиска endpoints

Фронтенд на JavaScript перечисляет API, с которым говорит приложение. Модель ИИ читает бандлы и выносит endpoints и параметры, а вы проверяете каждый.

Начальный3 мин чтения

Безопасность веба и API

Тестирование частых веб-багов с помощью ИИ

Методика для IDOR, XSS, SSRF и сломанной аутентификации: модель ИИ как читатель и второе мнение, плюс как проверить находку и избежать ложных срабатываний.

Начальный3 мин чтения

Безопасность веба и API

Тестирование API с ИИ: OpenAPI и GraphQL

API описывают свою форму через OpenAPI и introspection в GraphQL. Модель ИИ читает схему и планирует тесты авторизации, которые вы проверяете.

Средний3 мин чтения