Книга: «Все об уязвимости XSS». Глава 8. DOM XSS - Глубокое погружение 🌊 • Sources и sinks: откуда приходит зло и куда утекает

DOM XSS начинается не там, где ты видишь <script>, а там, где браузер берёт контролируемые данные и вставляет их в опасную точку DOM. Сервер может вообще не увидеть payload: HTTP-ответ останется полностью безопасным, WAF ничего не заметит, логов на бэкенде не будет. Вся атака происходит уже после загрузки страницы - внутри JavaScript-кода клиента. Именно поэтому DOM XSS часто переживает поверхностные сканеры и наивную серверную фильтрацию.

Для понимания DOM XSS нужен один фундаментальный концепт: source → transformation → sink. Source - откуда приложение получает данные, sink - куда оно их записывает. Если атакующий контролирует source, данные проходят через небезопасную обработку и доходят до опасного sink, появляется XSS. Это не магия и не “особый вид payload’а”. Это обычный поток данных, который разработчик по ошибке направил в исполняемый контекст.

Модель DOM XSS

Представим такой клиентский код:

const message = location.hash.substring(1);
document.getElementById('notice').innerHTML = message;

На вид всё просто: приложение читает текст после # в URL и показывает его на странице. Но location.hash контролируется пользователем. Если значение без экранирования отправляется в innerHTML, браузер интерпретирует его как HTML-разметку, а не как текст.

Вот поток данных:

URL fragment (#...) → location.hash → message → innerHTML → DOM

В этой цепочке:

  • location.hash - source.
  • message - промежуточная переменная.
  • innerHTML - sink.
  • notice - место, где появляется эффект.

Важный нюанс: фрагмент URL после # обычно не отправляется на сервер при HTTP-запросе. Поэтому серверный WAF, backend-валидация и access-логи могут вообще не увидеть вредоносное значение. Браузер получает нормальную страницу, затем сам JavaScript читает fragment и создаёт опасный DOM. Это и есть классический DOM-based XSS.

Sources: точки поступления данных

Source - любой источник, из которого фронтенд получает данные, потенциально контролируемые пользователем, другим сайтом или внешней системой. Не каждый source автоматически опасен. Опасность возникает только тогда, когда его значение доходит до sink без корректной обработки.

URL как главный источник

Самый частый source - объект location. Он содержит почти всё, что пользователь видит в адресной строке.

location.href
location.protocol
location.host
location.hostname
location.port
location.pathname
location.search
location.hash

Наиболее интересные части:

// https://example.test/search?q=hello#tab=reviews
location.pathname // "/search"
location.search // "?q=hello"
location.hash // "#tab=reviews"
location.href // полный URL

Разработчик часто пишет что-то вроде:

const tabName = location.hash.slice(1);
document.querySelector('#active-tab').innerHTML = tabName;

Или:

const params = new URLSearchParams(location.search);
const query = params.get('q');
results.innerHTML = `Результаты поиска для: ${query}`;

В первом случае контролируется hash; во втором - GET-параметр q. Если данные идут в innerHTML, это потенциальный DOM XSS. Для безопасного вывода текста нужно использовать textContent:

results.textContent = `Результаты поиска для: ${query}`;

Разница принципиальна:

element.innerHTML = userInput;    // HTML-парсинг: риск
element.textContent = userInput; // обычный текст: безопаснее

document.referrer

document.referrer хранит адрес страницы, с которой пользователь пришёл. Его часто используют в аналитике, виджетах “вернуться назад”, баннерах и реферальных программах.

const referrer = document.referrer;
document.querySelector('#came-from').innerHTML =
`Вы перешли с: ${referrer}`;

Если приложение вставляет значение referrer как HTML, оно доверяет внешнему URL. В реальности источник может быть атакующим сайтом, контролирующим путь, query-параметры и иногда фрагмент перехода.

Безопаснее:

document.querySelector('#came-from').textContent =
`Вы перешли с: ${referrer}`;

Ещё лучше - не отображать referrer целиком, а разбирать URL через URL, валидировать протокол, разрешать только нужные домены и выводить нормализованное значение.

window.name

window.name - старый, но живой механизм браузера. Значение переживает навигацию внутри той же вкладки и доступно JavaScript-коду страницы.

Проблема в том, что window.name может быть установлен на другой странице до перехода пользователя на целевой сайт. Если приложение считает его “внутренним” состоянием и сразу отдаёт в innerHTML, получается DOM XSS.

Для разработчика правило простое: window.name - внешний ввод. Обрабатывай его так же недоверчиво, как URL-параметр или содержимое формы.

postMessage

window.postMessage() нужен для обмена данными между окнами, iframe и вкладками. Это полезный API для OAuth, встроенных виджетов, платёжных страниц и микрофронтендов. Но он же создаёт мощный source.

Уязвимый код:

window.addEventListener('message', (event) => {
document.querySelector('#widget').innerHTML = event.data;
});

Любой сайт может открыть страницу и отправить ей сообщение:

targetWindow.postMessage('данные', '*');

Главная ошибка - принимать данные без проверки event.origin. Надёжный вариант должен проверять конкретный origin, тип данных и ожидаемную структуру:

const trustedOrigin = 'https://trusted.example';
window.addEventListener('message', (event) => {
if (event.origin !== trustedOrigin) return;
if (typeof event.data !== 'object') return;
if (typeof event.data.message !== 'string') return;
document.querySelector('#widget').textContent = event.data.message;
});

Проверка только на event.source или использование '*' как доверенного источника не заменяют строгую проверку origin.

localStorage и sessionStorage

Web Storage часто используют для сохранения темы оформления, черновиков, интерфейсных настроек, токенов и данных виджетов.

const welcome = localStorage.getItem('welcomeMessage');
document.querySelector('#welcome').innerHTML = welcome;

На первый взгляд данные в localStorage “наши”. Но это опасная иллюзия. Их могут записать:

  • другой скрипт того же origin;
  • сторонний виджет;
  • ранее найденная XSS;
  • небезопасный механизм импорта/восстановления настроек;
  • расширение браузера с доступом к странице.

Storage - это не граница доверия. Это просто долговременное клиентское хранилище. Данные из него всё равно нужно валидировать и выводить безопасными API.

Cookies

Cookie обычно ассоциируются с сессиями, но фронтенд нередко хранит там UI-настройки:

const themeLabel = getCookie('themeLabel');
settingsPreview.innerHTML = themeLabel;

Если cookie не имеет HttpOnly, JavaScript может читать его. А значит, его значение становится source. В некоторых архитектурах cookie можно подменить через поддомен, некорректный Domain, старые HTTP-каналы или другие логические ошибки. Вывод: не вставляй данные из cookie в HTML через опасные sinks.

Данные API, WebSocket и сторонних сервисов

Source - не только то, что пользователь передаёт напрямую. Любой внешний API - потенциально недоверенный источник.

fetch('/api/comments')
.then(response => response.json())
.then(comments => {
comments.forEach(comment => {
list.innerHTML += `<li>${comment.text}</li>`;
});
});

Даже если API находится на том же домене, комментарий мог быть создан пользователем. Это уже stored XSS, реализованный через DOM sink.

Другие примеры:

  • WebSocket-сообщения;
  • Server-Sent Events;
  • ответы GraphQL;
  • данные CRM, CMS или helpdesk;
  • markdown, импортированный из внешней системы;
  • OAuth-параметры;
  • данные из QR-кода, deep link или mobile bridge.

Главный принцип: источник считается доверенным не потому, что его выдал JSON или внутренний endpoint, а только если приложение обеспечивает корректную модель доверия и безопасную обработку до вывода.

Sinks: опасные места назначения

Sink - это API, операция или свойство, которое интерпретирует строку как HTML, JavaScript, URL, CSS или иной исполняемый контекст. Именно sink превращает обычные данные в уязвимость.

HTML sinks

Самые известные и частые:

element.innerHTML = data;
element.outerHTML = data;
element.insertAdjacentHTML('beforeend', data);
document.write(data);
document.writeln(data);

Эти API запускают HTML-парсер. Если в data оказывается разметка, браузер создаёт узлы DOM. В зависимости от контекста опасными могут стать ссылки, iframe, SVG, обработчики событий и другие HTML-конструкции.

Уязвимый пример:

const user = new URLSearchParams(location.search).get('user');
profile.innerHTML = `<strong>Пользователь:</strong> ${user}`;

Безопасный вариант:

const user = new URLSearchParams(location.search).get('user');
const label = document.createElement('strong');
label.textContent = 'Пользователь: ';
const value = document.createTextNode(user ?? '');
profile.replaceChildren(label, value);

Если нужен шаблонный рендеринг, используй фреймворк, который экранирует значения по умолчанию, или библиотеку санитизации для случаев, когда HTML действительно разрешён.

JavaScript sinks

Это функции, которые интерпретируют строку как JavaScript.

eval(data);
Function(data)();
setTimeout(data, 1000);
setInterval(data, 1000);

Особенно опасен eval():

const action = location.hash.slice(1);
eval(action);

Если строка приходит из URL, это прямой путь к DOM XSS. Хорошее правило для кода: если вы видите eval, Function, строковый setTimeout или строковый setInterval, остановитесь и спросите: “почему здесь вообще нужна интерпретация строки как программы?”

Безопасная альтернатива - таблица разрешённых действий:

const actions = {
refresh: () => location.reload(),
close: () => modal.close()
};
const action = new URLSearchParams(location.search).get('action');
actions[action]?.();

Здесь пользователь выбирает только ключ из заранее определённого набора, а не выполняет произвольный код.

URL sinks

Строка становится опасной не только в innerHTML. Иногда приложение использует её как URL:

location.assign(data);
location.replace(data);
window.open(data);
element.href = data;
element.src = data;
iframe.src = data;

Пример риска:

const next = new URLSearchParams(location.search).get('next');
location.assign(next);

Это прежде всего open redirect, но при определённых контекстах и разрешённых схемах может стать частью XSS-цепочки. Надёжная схема - разрешать только относительные пути или URL из чёткого allowlist:

function safeInternalPath(value) {
if (!value || !value.startsWith('/')) return '/';
if (value.startsWith('//')) return '/';
return value;
}
location.assign(safeInternalPath(next));

Для внешних URL проверяй протокол и origin через new URL():

function safeUrl(value) {
const url = new URL(value, location.origin);
if (url.protocol !== 'https:') return null;
if (url.origin !== 'https://trusted.example') return null;
return url.href;
}

DOM sinks, создающие структуру

Опасность может быть менее очевидной:

element.setAttribute('href', data);
element.setAttribute('src', data);
element.setAttribute('onclick', data);

Особенно опасны события:

button.setAttribute('onclick', userInput);

Нельзя превращать строку в обработчик событий. Вместо этого обработчик должен быть статическим кодом:

button.addEventListener('click', () => {
performKnownAction();
});

А входные данные должны использоваться только как данные:

button.dataset.action = validatedAction;

Framework-specific sinks

Современные фреймворки снижают риск, но не отменяют его. У каждого есть “аварийная кнопка”, отключающая экранирование:

Технология

Опасный механизм

React

dangerouslySetInnerHTML

Vue

v-html

Angular

bypassSecurityTrustHtml()

Svelte

{@html ...}

jQuery

.html(), .append() со строкой

Lit

небезопасные директивы HTML

Пример React:

<div dangerouslySetInnerHTML={{ __html: comment }} />

Если comment приходит от пользователя, это потенциальный XSS. Безопаснее:

<div>{comment}</div>

Если HTML необходим для бизнес-логики — пропускай его через поддерживаемый санитайзер с строгой политикой и не расширяй allowlist “для удобства”.

Путь данных: где искать настоящую проблему

Source сам по себе не баг. Sink сам по себе не баг, если туда попадают только константы или безопасно сформированные данные. Уязвимость появляется на всём пути:

Source → decode / transform → store → render → sink

Например:

const raw = location.hash.slice(1);
const decoded = decodeURIComponent(raw);
const message = JSON.parse(decoded).text;
panel.innerHTML = message;

Проверять нужно не только последнюю строку с innerHTML, но и всю цепочку:

  • кто контролирует location.hash;
  • сколько раз вызывается decode;
  • проходит ли значение через JSON.parse;
  • есть ли валидация структуры;
  • может ли message содержать HTML;
  • где ещё используется тот же объект.

Промежуточные преобразования часто скрывают уязвимость от простого поиска по проекту.

DOM Clobbering: когда source появляется из HTML

DOM clobbering - отдельный интересный класс DOM-рисков. Браузер может создать глобальные свойства или свойства формы на основе id и name HTML-элементов. Если код ожидает, что глобальная переменная - это безопасный объект, а пользователь может повлиять на DOM, логика ломается.

Небезопасный паттерн:

const config = window.config || {};
const destination = config.url;
link.href = destination;

Если страница допускает пользовательскую HTML-разметку, элемент с определённым именем может изменить поведение кода и подставить неожиданные значения. Это не всегда прямой XSS, но нередко становится частью цепочки, которая ведёт к опасному URL sink или HTML sink.

Защита:

  • не опирайся на неявные глобальные свойства DOM;
  • используй явные модули и const;
  • проверяй типы;
  • создавай словари через Object.create(null);
  • не смешивай пользовательскую HTML-разметку с логикой приложения.

Как искать sources и sinks

Поиск по коду

Начни с grep или глобального поиска по проекту:

innerHTML
outerHTML
insertAdjacentHTML
document.write
eval
Function(
setTimeout(
setInterval(
location.assign
location.replace
window.open
srcdoc
dangerouslySetInnerHTML
v-html
bypassSecurityTrust

Затем от каждого sink иди назад по переменным: откуда пришло значение? Есть ли location, postMessage, storage, API, WebSocket или пользовательский контент?

Для sources ищи:

location.
document.URL
document.documentURI
document.referrer
window.name
postMessage
addEventListener('message'
localStorage
sessionStorage
document.cookie
URLSearchParams
WebSocket
EventSource

DevTools и DOM Invader

В браузерных DevTools удобно ставить breakpoint на DOM mutations и следить, какой скрипт меняет конкретный элемент. В Burp Suite DOM Invader автоматически помогает отслеживать поток данных из источников к опасным sinks: он маркирует данные, внедряет canary-значения и показывает потенциально уязвимые цепочки. Инструмент ускоряет поиск, но не заменяет ручной анализ контекста: он может показать источник и sink, а тебе нужно подтвердить, что данные действительно достижимы и приводят к исполняемой интерпретации.

Безопасный тестовый маркер

В авторизованной лаборатории или согласованном пентесте не начинай с исполняемого кода. Используй уникальный маркер:

DOM_TEST_9f31c

Передай его через URL, postMessage или другой предполагаемый source и проследи:

  • появился ли он в DOM;
  • где именно;
  • как браузер его закодировал;
  • в каком sink он оказался;
  • сохраняется ли он после навигации;
  • меняется ли при декодировании.

Только после установления цепочки оценивай риск в соответствии с правилами scope и методологией тестирования.

Защита: разрываем цепочку

Надёжная защита DOM XSS - это не blacklist слов вроде script, onerror или javascript:. Такие фильтры всегда обходятся вариациями синтаксиса и разными контекстами. Нужно разорвать поток данных до того, как он попадёт в sink.

Правило №1: текст выводится как текст

// Плохо
card.innerHTML = userValue;
// Хорошо
card.textContent = userValue;

Правило №2: DOM создаётся через API

const item = document.createElement('li');
item.textContent = userValue;
list.appendChild(item);

Так браузер не интерпретирует userValue как HTML.

Правило №3: HTML допускается только по строгой политике

Если бизнес-функция требует rich text или Markdown, сначала преобразуй его, затем очисти проверенным санитайзером. Не пиши санитайзер на регулярных выражениях и не доверяй произвольной разметке из API “потому что она внутренняя”.

Правило №4: Trusted Types

Trusted Types помогают запретить опасные DOM sinks для обычных строк. Политика заставляет разработчика явно пройти через контролируемую функцию перед присваиванием в innerHTML и похожие API.

Пример заголовка:

Content-Security-Policy: require-trusted-types-for 'script'

Это не замена исправлению, но сильный технический барьер против случайного появления новых DOM XSS.

Правило №5: CSP ограничивает последствия

Content Security Policy не должна быть единственной защитой, но она усложняет эксплуатацию, если ошибка всё же есть:

Content-Security-Policy:
default-src 'self';
script-src 'self';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';

Политика должна подбираться под архитектуру приложения; добавление 'unsafe-inline' ради удобства резко ослабляет её смысл.

Итог

DOM XSS - это история про движение недоверенных данных. location.hash, postMessage, document.referrer, storage, cookie, API-ответы и WebSocket-сообщения - всё это возможные sources. innerHTML, document.write, eval, URL-переходы, строковые обработчики событий и “небезопасные” механизмы фреймворков - sinks. Между ними может быть десять переменных, несколько декодирований и целая цепочка функций, но суть не меняется: недоверенное значение не должно попасть в исполняемый или HTML-интерпретируемый контекст.

Запомни формулу:

контролируемый source+опасный sink+отсутствие корректной обработки=DOM XSS\text{контролируемый source} + \text{опасный sink} + \text{отсутствие корректной обработки} = \text{DOM XSS}контролируемый source+опасный sink+отсутствие корректной обработки=DOM XSS

Найди source. Проследи поток. Проверь sink. Именно так начинается глубокая, а не поверхностная охота на DOM XSS.

5 views·1 share