innerHTML и CSP: защита в глубину против XSS

Даже при аккуратном выводе стоит иметь второй рубеж — на случай ошибки.

CSP (Content Security Policy) — заголовок ответа, ограничивающий, какие скрипты и ресурсы браузер вправе исполнять и загружать на странице.

Почему innerHTML опасен

Присваивание строки в innerHTML заставляет браузер распарсить её как HTML и построить узлы — включая активные. Если в строке есть пользовательский ввод, это прямой путь к DOM-XSS.

// Уязвимо: ввод парсится как HTML
el.innerHTML = "Привет, " + userName;
// userName = <img src=x onerror=steal()>  -> сработает обработчик onerror

Заметьте: даже без тега <script> XSS возможен через атрибуты-обработчики (onerror, onload). Поэтому «вырезать script» бесполезно.

Эта ловушка особенно коварна в одностраничных приложениях. На сервере шаблонизатор экранирует вывод по умолчанию, и разработчик привыкает, что «всё под защитой». Но как только данные приходят в браузер и попадают в innerHTML вручную, серверная защита уже не действует — экранировать обязан клиентский код. Типичный сценарий: приложение получает JSON от API, берёт оттуда поле name и пишет el.innerHTML = user.name, чтобы «быстро» отрисовать приветствие. Если имя пользователя когда-то задавалось без проверки, в нём может оказаться <img src=x onerror=...>, и это превратится в DOM-XSS уже на стороне браузера, без участия сервера.

Стоит запомнить правило: грань между «данными» и «кодом» в HTML очень тонкая, и любая функция, которая принимает строку и трактует её как разметку, эту грань стирает. Поэтому опасны не только присваивания в innerHTML, но и целое семейство похожих API. Прежде чем передать строку в такую функцию, спросите себя: может ли в этой строке оказаться хоть один символ, пришедший от пользователя? Если да — нужна либо текстовая вставка, либо санитайзер.

Безопасная альтернатива: работать с текстом и узлами

Если нужно вставить текст, используйте textContent — он не парсит HTML, а кладёт строку как обычный текст. Для построения структуры создавайте узлы программно и задавайте им текст.

// Безопасно: текст не интерпретируется как разметка
el.textContent = "Привет, " + userName;   // <img ...> покажется как текст

// Безопасно: строим DOM из узлов, текст — через свойства
const span = document.createElement("span");
span.textContent = userName;
el.appendChild(span);

Тот же принцип — для аналогов: избегайте document.write, outerHTML, insertAdjacentHTML с недоверенными данными; не передавайте ввод в eval, setTimeout("строка") или конструктор Function.

Отдельно стоит сказать про атрибуты, задаваемые из кода. Если значение идёт в href или src, недостаточно вставить его как текст — нужно ещё проверить схему. Ссылка вида javascript:steal() не содержит ни одного «опасного» символа разметки, но при клике выполнит код. Поэтому для URL-атрибутов из недоверенного источника разумно разрешать только ожидаемые схемы (http, https, mailto) и отвергать остальные. Современные фреймворки делают подобную проверку за вас, но при ручной работе с DOM о ней легко забыть.

Content Security Policy: второй рубеж

Экранирование может где-то дать сбой — человеческий фактор. CSP ограничивает ущерб: даже если скрипт внедрился, политика может запретить его исполнение. Базовая защита — запрет инлайновых скриптов и разрешение только своих источников.

Content-Security-Policy:
  default-src 'self';
  script-src 'self';        # только скрипты со своего домена, без inline
  object-src 'none';
  base-uri 'self'

Без 'unsafe-inline' внедрённый <script>alert(1)</script> или onerror= просто не выполнится — браузер их заблокирует. Это и есть defense in depth: первый рубеж — экранирование, второй — CSP.

Почему второй рубеж так важен? Потому что экранирование — это код, который пишут люди, а люди ошибаются. Достаточно одного забытого места вывода, одного нового компонента, который собрали в спешке, одной сторонней библиотеки с неаккуратной работой с DOM — и в защите появляется щель. CSP не зависит от безошибочности всего этого кода: даже если дыра существует и злоумышленник сумел внедрить скрипт, политика на уровне браузера может просто не дать ему выполниться. Получается, что одна ошибка перестаёт автоматически означать пробитую защиту — нужно, чтобы совпали сразу две независимые неудачи.

У CSP есть и диагностическая ценность. Режим Content-Security-Policy-Report-Only не блокирует ничего, но шлёт отчёты о том, что было бы заблокировано. Так политику можно сначала обкатать на боевом трафике, увидеть все легитимные источники скриптов и стилей, а уже потом включить блокирующий режим, ничего не сломав. А постоянный поток таких отчётов в продакшене нередко становится ранним сигналом: всплеск нарушений может означать, что кто-то прямо сейчас пытается эксплуатировать XSS.

Как работает под капотом: nonce и строгая политика

Чтобы разрешить только свои инлайн-скрипты, им присваивают одноразовый nonce, совпадающий со значением в заголовке CSP. Браузер выполнит лишь скрипты с верным nonce; внедрённый злоумышленником его не знает. Современный подход — strict-dynamic + nonce вместо перечисления доменов в allowlist.

Ключевое слово здесь — одноразовый: nonce должен заново генерироваться сервером на каждый ответ и быть непредсказуемым. Если зашить один и тот же nonce в шаблон навсегда, защита рушится — злоумышленник просто подсмотрит его в исходном коде страницы и подставит в свой скрипт. Альтернатива nonce — хеш: в политике перечисляют SHA-хеши разрешённых инлайн-скриптов, и браузер выполняет только те, чьё содержимое совпадает по хешу. Оба механизма решают одну задачу — отличить «свой» инлайн-код от внедрённого, не открывая дверь всему инлайну сразу через 'unsafe-inline'.

Content-Security-Policy: script-src 'nonce-r4Nd0m' 'strict-dynamic'

<script nonce="r4Nd0m">...</script>   // выполнится: nonce совпал
<script>evil()</script>                // заблокируется: nonce'а нет

Частые ошибки

  • Считать CSP заменой экранированию. Это второй рубеж, а не первый; нужны оба.
  • Оставить 'unsafe-inline'. Сводит защиту CSP от XSS почти на нет.
  • Использовать innerHTML «для скорости». Для текста есть textContent.
  • Зашитый постоянный nonce. Он обязан быть случайным на каждый ответ, иначе атакующий просто подставит его в свой скрипт.
  • Слишком широкий allowlist источников. Разрешение целых CDN с произвольным содержимым размывает пользу CSP; точнее работают nonce и хеши.

Итоги

  • innerHTML парсит строку как HTML — недоверенные данные дают DOM-XSS даже без тега script.
  • Для текста используйте textContent и построение DOM из узлов.
  • CSP — второй рубеж: запрет inline-скриптов и nonce ограничивают ущерб от XSS.
Проверьте себя
1. Почему присваивание пользовательского ввода в innerHTML опасно?
AОно медленно работает
BБраузер парсит строку как HTML и создаёт активные узлы, включая обработчики событий, что даёт DOM-XSS
CinnerHTML не поддерживается в новых браузерах
DОно стирает остальную страницу
2. Что безопасно использовать для вставки пользовательского текста в элемент?
AinnerHTML
BtextContent — он кладёт строку как текст и не парсит её как HTML
Cdocument.write
Deval
3. Какую роль играет CSP в защите от XSS?
AПолностью заменяет экранирование вывода
BСлужит вторым рубежом: ограничивает, какие скрипты браузер вправе выполнять, снижая ущерб от внедрённого кода
CШифрует HTML-страницу
DУскоряет загрузку скриптов