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.