Безопасность веба и API
Чтение JavaScript с ИИ для поиска endpoints
Фронтенд на JavaScript перечисляет API, с которым говорит приложение. Модель ИИ читает бандлы и выносит endpoints и параметры, а вы проверяете каждый.
Отражённый, хранимый и DOM-based XSS, почему кодирование вывода зависит от контекста и как CSP добавляет ещё один эшелон защиты, в локальной лабораторной работе.
Межсайтовый скриптинг (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-сущностями &, <, >, ", ' |
Двойное кодирование, из-за которого на странице виден &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 (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 на сайты, которые вам не принадлежат и на тестирование которых у вас нет письменного разрешения.
Убедитесь, что Docker отвечает:
docker --version
docker info --format '{{.ServerVersion}}'На машине, использованной для этой статьи, первая команда напечатала Docker version 29.8.2, build 7fc2dff. Ваша версия может отличаться.
Запустите контейнер с пробросом порта только на loopback:
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 ниже соответствующим образом.
Проверьте, что приложение отвечает, и прочитайте его версию:
curl -s http://127.0.0.1:3000/rest/admin/application-versionНа машине, использованной для этой статьи, ответ был {"version":"20.3.0"}. Первый запуск может занять некоторое время, поэтому, если ответа нет, повторите запрос.
В официальном руководстве по Juice Shop есть раздел «Perform a DOM XSS attack». В его подсказке предлагается искать поле ввода, содержимое которого появляется в HTML при отправке формы. Руководство приводит такой payload:
<iframe src="javascript:alert(`xss`)">Следуйте руководству в своём браузере. Откройте строку поиска Juice Shop, вставьте payload и отправьте. Подойдёт и URL поиска с запросом во фрагменте. На машине, использованной для этой статьи, этот URL был загружен в headless Chrome, после чего скорборд показал задание как решённое:
http://127.0.0.1:3000/#/search?q=%3Ciframe%20src%3D%22javascript%3Aalert(%60xss%60)%22%3EЗапросите состояние задания через API приложения:
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 и скриптов приложения.
Создайте папку и перейдите в неё:
mkdir xsslab && cd xsslabСоздайте index.html. Этот файл читает текст после #, записывает его один раз через innerHTML и один раз через textContent и добавляет meta-элемент CSP из задания к лабораторной работе:
<!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> тоже был бы заблокирован политикой, и это помешало бы нужному сравнению.
// 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:
python -m http.server 8000 --bind 127.0.0.1Оставьте этот терминал открытым на время работы. Откройте http://127.0.0.1:8000/index.html в браузере. Когда закончите, остановите сервер клавишами Ctrl+C.
Откройте инструменты разработчика браузера и вкладку Console. Сначала загрузите страницу с безобидным значением:
http://127.0.0.1:8000/index.html#helloЗатем выполните в консоли эти две строки:
document.getElementById('html-sink').innerHTML
document.getElementById('text-sink').textContentОбе показывают hello. Теперь загрузите фрагмент, в котором смешаны разметка и обработчик события:
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. Нужная часть вывода выглядела так:
<div id="html-sink">hello <img src="x" onerror="alert(1)"></div>
<div id="text-sink">hello <img src=x onerror=alert(1)></div>Синк innerHTML создал элемент img. Синк textContent сохранил те же символы как обычный текст. Выполните две строки в консоли ещё раз на этом URL и проверьте, что первая возвращает разметку с элементом, а вторая экранированную строку.
На странице выше уже есть default-src 'self'. Тот же запуск в headless-режиме записал такое сообщение:
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, а когда закончите, остановите контейнер:
docker ps --filter ancestor=bkimminich/juice-shop
docker stop <container-id>Следующая статья серии посвящена межсайтовой подделке запросов и тому, как cookie с SameSite ограничивают один из её путей. XSS и CSRF часто путают, и разницу стоит понять до проектирования модели сессий.
Безопасность веба и API
Фронтенд на JavaScript перечисляет API, с которым говорит приложение. Модель ИИ читает бандлы и выносит endpoints и параметры, а вы проверяете каждый.
Безопасность веба и API
Методика для IDOR, XSS, SSRF и сломанной аутентификации: модель ИИ как читатель и второе мнение, плюс как проверить находку и избежать ложных срабатываний.
Безопасность веба и API
API описывают свою форму через OpenAPI и introspection в GraphQL. Модель ИИ читает схему и планирует тесты авторизации, которые вы проверяете.