Раздел 12 · Security

XSS, CSP и секреты: чистим вывод и прячем ключи

middle~30 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

XSS, CSP и секреты: чистим вывод и прячем ключи

В прошлом уроке атакующий стучался снаружи. Теперь представь, что он уже смог вставить свой кусок HTML или JavaScript в твою страницу. Это XSS, и это до сих пор одна из самых частых уязвимостей в вебе. Второй сюжет урока про другую дисциплину: где живут секреты и почему .env в репозитории это инцидент, а не мелочь.

Идея: не доверяй ни входу, ни выводу

В прошлом уроке мы чистили вход: данные, которые приходят в систему. XSS заставляет думать про вывод: данные, которые система отдаёт обратно в браузер. Любая строка, которая попадает в HTML или DOM, должна быть либо экранирована под контекст, либо явно помечена доверенной.

Ключевое слово это контекст. Одна и та же строка безопасна как HTML-текст, но взрывается как значение атрибута или как кусок URL. Поэтому “просто заэкранировать” недостаточно: экранировать надо под то место, куда строка вставляется.

XSS: stored, reflected, DOM-based

По способу доставки XSS делят на три вида, и у каждого своя точка отказа.

  • Stored XSS: скрипт сохранён на сервере. Кто-то оставил <script>...</script> в комментарии, и теперь он выполняется у каждого читателя. Самый опасный вид, потому что не требует никаких действий от жертвы.
  • Reflected XSS: скрипт приезжает в URL и сразу отражается в ответе (например, в строке “ничего не найдено по запросу X”). Доставляется фишинговой ссылкой.
  • DOM-based XSS: всё происходит в браузере. Клиентский код берёт данные из location.hash и кладёт в innerHTML. Сервер чистый, дыра в клиентском JS.

Где обычно ломается санитизация. Корень в опасных sink: innerHTML, outerHTML, document.write, eval, new Function, присвоение src у скрипта. Передал туда недоверенную строку, получил выполнение кода.

// опасно: пользовательский ввод попадает в HTML как разметка
el.innerHTML = userComment; // <img src=x onerror=alert(document.cookie)>

// безопасно: textContent вставляет строку как текст, не как разметку
el.textContent = userComment;

Базовая защита это экранирование под контекст вывода. В шаблонизаторах оно включено по умолчанию: {{ value }} в большинстве движков и {value} в JSX экранируют автоматически. Опасны ровно те конструкции, которыми ты этот авто-эскейп выключаешь:

// React: dangerouslySetInnerHTML это явный выход из защиты
<div dangerouslySetInnerHTML={{ __html: userHtml }} />
// само имя кричит: ты берёшь ответственность на себя

Если HTML от пользователя действительно нужен (редактор, разметка в комментариях), не пиши свой фильтр. Используй DOMPurify:

import DOMPurify from 'dompurify';
el.innerHTML = DOMPurify.sanitize(userHtml);

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

Content Security Policy

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

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m';
  style-src 'self';
  img-src 'self' data:;
  frame-ancestors 'none';
  report-to csp-endpoint

Разберём директивы:

  • default-src 'self' это базовое правило для всего: грузим только со своего origin.
  • script-src отдельно ограничивает скрипты, это самое важное против XSS.
  • frame-ancestors задаёт, кто может встроить тебя в iframe, это защита от clickjacking.

Главная боль это inline-скрипты. Строгая CSP запрещает unsafe-inline, иначе любой <script> на странице выполнится, включая внедрённый. Чтобы свои inline-скрипты при этом работали, их помечают:

  • nonce: случайное число на каждый ответ, проставленное и в заголовок, и в свой <script nonce="r4nd0m">. Внедрённый скрипт nonce не угадает.
  • hash: sha256 содержимого скрипта прямо в заголовке, для статичных inline.
  • strict-dynamic: доверие транзитивно. Скрипт с валидным nonce может грузить другие, и им доверяют. Это упрощает CSP с бандлерами.

И, наконец, отчёты: report-to заставляет браузер слать JSON о каждом нарушении на твой endpoint. Практический путь внедрения: сначала Content-Security-Policy-Report-Only, собираешь нарушения неделю, чинишь ложные срабатывания, и только потом включаешь блокирующий режим.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.