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.