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

Экранирование на сервере не видит половину векторов

Классическое экранирование живёт на сервере: шаблонизатор заменяет опасные символы в ответе, и браузер показывает их как текст. Эта стена безотказна ровно до того места, где разметку собирает сам браузер. Приложение получает от API сведения в формате JSON и строит интерфейс на клиенте, а сервер о каждой вставке в дерево документа не знает ничего.

У векторов этого класса есть имя, уязвимости уровня DOM. Опасное место выглядит обычно скромно:

// приветствие строится из фрагмента адреса
const name = decodeURIComponent(location.hash.slice(1));
document.getElementById("greeting").innerHTML = "Добро пожаловать, " + name;

Фрагмент после решётки браузер на сервер не отправляет в принципе, так что этой строке физически неоткуда экранироваться: она рождается и исполняется целиком в браузере, а пользователю достаточно открыть адрес с вставкой вроде img с обработчиком onerror, чтобы скрипт заработал от имени жертвы. Сюда же относятся document.write, ссылки с протоколом javascript в атрибуте href и строковые таймеры.

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

Отдельный анти-приём из той же серии, универсальный перехватчик на сервере, который якобы экранирует всё подряд. Справочник по предотвращению межсайтовых сценариев прямо перечисляет его провалы: разметка ломается от двойного экранирования, контексты вставки требуют разной обработки, а против DOM-уязвимостей перехватчик бессилен, потому что вставка происходит без единого запроса к серверу.

Механика атаки в одностраничном приложении бьёт по долгим сессиям

В классическом сайте у вора было одно окно: кража cookie при переходе по ссылке. В одностраничном приложении пожива жирнее. Токен входа лежит в localStorage и не исчезает неделями, а внедрённый скрипт исполняется в том же источнике, что и всё приложение: ему доступны и хранилище, и cookie, и все вызовы к API от имени пользователя. Одна удачная вставка означает не разовый разбор одной страницы, а постоянный канал поверх действующей сессии.

Экосистема добавляет риск извне. Осенью 2018 года в популярном пакете event-stream из реестра npm обнаружили вредоносную вставку, ориентированную на конкретное приложение, а успела она разъехаться с миллионами загрузок в неделю. Политика, ограничивающая источники исполняемого кода, как раз про такие случаи: чужой код попадает в сборку по решению целой цепочки людей, а страница обязана уметь его не запускать.

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

Заголовок X-XSS-Protection, на который десять лет возлагали надежды, из практики ушёл: встроенный фильтр браузера Chrome убрали к версии 78, осень 2019 года, и опираться на него бессмысленно.

Заголовок CSP работает как список разрешений для браузера

Заголовок Content-Security-Policy переворачивает логику защиты. Вместо попыток обезвредить каждую строку браузер получает список разрешений: откуда загружать скрипты, стили и изображения, к каким адресам ходить сетевыми запросами, что запрещено в принципе. Всё, что в список не попало, блокируется ещё до исполнения, и это единственный рубеж, который срабатывает даже тогда, когда уязвимая вставка уже произошла.

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self'; img-src 'self' data:; connect-src 'self' https://api.example.com; object-src 'none'; frame-ancestors 'none'; base-uri 'none'

Директива default-src задаёт общий фон, script-src расшифровывает источники кода, connect-src ограничивает сетевые вызовы, object-src и base-uri закрывают старые и редкие пути вставки, frame-ancestors запрещает встраивать страницу в чужие рамки. Политика второго поколения стала общедоступной в браузерах к августу 2016 года, так что опора на неё сегодня прагматична, а не экзотична.

Механику проще всего увидеть на вставке из первого раздела. Тег img с обработчиком onerror, попавший в дерево через innerHTML, без политики исполняется, а при строгой script-src браузер отказывается запускать атрибутный обработчик: он считается встроенным кодом, на который у страницы нет разрешения. Уязвимость никуда не делась, но её последствие оказалось обрезанным на самом первом шаге.

Есть и щадящий режим запуска: заголовок с суффиксом Report-Only ничего не блокирует, только шлёт отчёты о нарушениях, и на нём политику обкатывают до включения. Метатег с той же политикой тоже существует, но часть директив в нём игнорируется, включая проверку фреймов и адреса отчётов, поэтому настоящим носителем политики остаётся именно заголовок ответа.

Нарушения приходят в форме структурированного отчёта, и по нему сразу видно, какая директива сработала и что пытались исполнить:

{
  "csp-report": {
    "violated-directive": "script-src",
    "blocked-uri": "inline",
    "source-file": "https://app.example.com/assets/app.js"
  }
}

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

Нонсы и хэши спасают, когда встроенные скрипты неизбежны

Первая честная попытка включить политику обычно разбивается об одну мелочь: сборщик требует встроенный скрипт начальной загрузки, и в политику просят разрешения unsafe-inline вместе с unsafe-eval, которые обнуляют большую часть защиты. Правильный путь лежит через нонсы. Сервер генерирует случайную метку на каждый ответ, метка попадает и в заголовок, и в атрибут скрипта:

Content-Security-Policy: script-src 'nonce-4AEemGb0xJptoIGFP3Nd' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script nonce="4AEemGb0xJptoIGFP3Nd" src="/assets/app.js"></script>

Добавка strict-dynamic из третьего поколения политики передаёт доверие дальше: скрипт, легально загруженный из разрешённого источника, вправе подключать собственные зависимости, а посторонние адреса по-прежнему отсекаются. Для неизменных встроенных фрагментов вместо нонса подойдёт хэш содержимого, который считают заранее и вписывают в политику. К списку источников есть и вторая сторона: один широкий источник обнуляет всю работу. Разрешение с звёздочкой на поддомены делает легальным поставщиком кода любой компрометированный сосед, а открытый редирект на своём же домене превращает доверенный адрес в трамплин к постороннему скрипту. Поэтому список ведут скупо, и каждая его строка имеет владельца и обоснование.

Оба механизма лишают атакующего главного ресурса, возможности исполнять что угодно на чужом источнике.

React экранирует текст сам, но оставляет пару лазеек

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

// безопасно: экранирование встроено, тег увидят как текст
<p>{comment.text}</p>

// отверстие: вставка сырой разметки мимо экранирования
<p dangerouslySetInnerHTML={{ __html: comment.text }} />

Имя второго варианта произносится с предостережением не случайно. Он нужен для честной разметки из редактора статей, и тогда сырьё прогоняют через санитайзер DOMPurify, вырезающий обработчики событий и опасные атрибуты:

import DOMPurify from "dompurify";

<p dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(comment.text) }} />

Вторая лазейка тоньше: ссылка с протоколом javascript внутри атрибута href. Экранирование тут бессильно, опасна сама схема адреса, и защита на стороне интерфейса одна, белый список протоколов перед записью в ссылку. В той же логике работает Angular со встроенной очисткой значений и Vue с экранированием по умолчанию: фреймворки закрывают типовые пути, но каждый обходной манёвр в сторону сырой разметки остаётся на совести разработчика.

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

Внедрение политики проходит поэтапно и без героизма

Внедрение политики в живом проекте с многолетней историей похоже на ремонт в квартире без выезда жильцов. Рабочий порядок, который себя оправдал:

  1. включают заголовок в режиме только отчётов и неделю собирают нарушения из реальной работы;
  2. по отчётам составляют опись источников скриптов, стилей и сетевых адресов;
  3. встроенные скрипты переносят в файлы либо снабжают нонсами, заодно вычищая обработчики из атрибутов;
  4. запрещают устаревшие пути вставки кода: объекты, смену базового адреса, чужие рамки;
  5. ужесточают политику до строгой и оставляют отчёты о нарушениях на постоянной основе;
  6. новое расширение или библиотеку проверяют против политики до выхода в прод, а не после.

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

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

Экранирование и заголовок работают парой, а не сменяют друг друга

Оговорка нужна и в обратную сторону. Тот же справочник называет анти-приёмом и полную ставку на заголовок: старые браузеры исполняют политику с оговорками, наследованные приложения роняют её ценность, а чуть ослабишь разрешение ради очередного виджета, и стена снова в дырах. Читается это однозначно: заголовок не заменяет экранирование, он его страхует, и любой аргумент вида выбери что-то одно проигрывает обоим сторонам спора.

Вывод из практики прост. Серверное экранирование остаётся фундаментом, без него не выживет ни одна классическая форма. Но одностраничное приложение живёт наполовину в браузере, и там, где сборкой разметки занят клиент, нужен второй рубеж, не зависящий от аккуратности отдельных функций: строгая политика со списком разрешений, нонсы вместо широких разрешений, отчёты о нарушениях в постоянной работе. Первый рубеж не видит вставок, которые никогда не доходили до сервера, второй не доверяет никому вообще. Поодиночке каждый пропускает свою категорию угроз, вместе они закрывают и её, и человеческие ошибки, которые случаются в любом достаточно большом проекте. Направление развития подсказывает и сама платформа: экспериментальный механизм Trusted Types в браузерах на движке Chromium принуждает пропускать вставки через санитайзер, продолжая ту же идею явных разрешений, только уже на уровне интерфейса языка.