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

Подстановка чужого кода остаётся самой частой уязвимостью веба

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

Сам термин появился в 2000 году. Изначально он описывал частный случай, но постепенно расширился и теперь охватывает любые способы внедрения кода.

Различают две основные разновидности. Первая это отражённая подстановка: данные из адреса страницы сразу попадают в разметку без обработки. Классический пример это страница поиска, которая повторяет введённый запрос. Если запрос содержит разметку, она выполнится.

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

Масштаб проблемы известен. Исследователи ещё в 2007 году оценивали, что до двух третей сайтов имели подобные уязвимости. С тех пор положение улучшилось, но подстановка чужого кода остаётся среди самых частых находок при проверках.

Политика описывает, откуда разрешено загружать ресурсы

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

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

Первая версия стандарта появилась в 2012 году, вторая в 2014 году, третья разрабатывается. Поддержка браузерами стала широкой с августа 2016 года.

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

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

Директивы задают разрешения по видам ресурсов

Политика состоит из директив, каждая из которых управляет своим видом ресурсов.

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

Базовый вариант политики выглядит так:

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:

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

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

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

Третья тонкость касается нескольких заголовков. Сервер может передать несколько политик, и браузер применит их все. Это позволяет постепенно добавлять ограничения, не переписывая существующий заголовок.

Встроенные фрагменты разрешаются одноразовым значком

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

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

Content-Security-Policy: script-src 'nonce-8f2a91c4' 'strict-dynamic'
<script nonce="8f2a91c4">
  window.__INITIAL_STATE__ = {"user": "guest"};
</script>

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

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

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

Проверка перед включением позволяет избежать поломок

Включать строгую политику сразу на рабочем сайте рискованно. Есть безопасный порядок.

Первый инструмент это режим отчёта без блокировки. Заголовок с соответствующим именем заставляет браузер сообщать о нарушениях, но не мешает загрузке.

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

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

// приём отчётов о нарушениях на сервере
app.post("/csp-report", (req, res) => {
  const report = req.body["csp-report"] || {};
  logger.warn("нарушение политики", {
    document: report["document-uri"],
    directive: report["violated-directive"],
    blocked: report["blocked-uri"],
  });
  res.status(204).end();
});

Третий шаг это устранение нарушений. Каждое сообщение разбирается: либо ресурс добавляется в список разрешённых, либо код переписывается.

Четвёртый шаг это постепенное включение. Сначала политика применяется к одной странице, затем к разделу, затем ко всему сайту.

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

Типичные ошибки ослабляют защиту

Разберём промахи, которые сводят пользу политики к нулю.

Первая ошибка это использование широкого разрешения на любые источники. Формально политика есть, фактически она ничего не запрещает. Смысл теряется полностью.

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

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

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

Пятая ошибка это игнорирование отчётов. Нарушения копятся, политика постепенно ослабляется правками наугад, и в итоге от неё остаётся набор разрешений на всё.

Шестая ошибка это отказ от защиты при первом же сбое. Правильная реакция состоит в разборе причины, а не в отключении политики целиком.

Сочетание с другими мерами даёт лучший результат

Политика не заменяет другие средства защиты, а дополняет их.

Первая мера это экранирование вывода. Любые данные, попавшие в разметку, должны быть экранированы на стороне сервера или клиента. Политика это страховка, а не основная защита.

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

Третья мера это защита от встраивания в рамку. Директива запрещает открывать страницу внутри чужого окна, что мешает подмене интерфейса.

Четвёртая мера это требование защищённого протокола для всех ресурсов. Смешанная загрузка ослабляет всю схему.

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

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default

Шестая мера это регулярная проверка. Новые внешние сервисы появляются постоянно, и политика должна обновляться вместе с ними, а не отставать.

Числовая оценка показывает, сколько нарушений возникает при включении

Полезно понимать масштаб работы. По опыту внедрения на среднем сайте с подключёнными счётчиками и внешними виджетами режим отчёта без блокировки за первые сутки даёт сотни сообщений.

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

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

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

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

Порядок внедрения занимает недели и проходит без остановки работы

Первый этап это инвентаризация. Выписываются все внешние источники: счётчики, виджеты, шрифты, системы поддержки, платёжные формы.

Второй этап это черновая политика. Источники вносятся в списки, встроенные фрагменты пока разрешаются.

Третий этап это режим отчёта. Политика передаётся без блокировки, отчёты собираются в журнал.

Четвёртый этап это разбор. Каждое нарушение относится к одной из трёх групп: добавить источник, переписать код, отказаться от сервиса.

Пятый этап это переход к блокировке на одном разделе сайта.

Шестой этап это расширение на весь сайт и удаление временных разрешений.

Седьмой этап это наблюдение и периодический пересмотр.

Такой порядок занимает от двух недель до двух месяцев в зависимости от размера сайта. Останавливать работу на это время не требуется.

Короткая проверка результата

Первое: политика передаётся заголовком на всех страницах.

Второе: в списках источников нет разрешения на всё подряд.

Третье: встроенные сценарии разрешены только через одноразовый значок.

Четвёртое: настроен приём отчётов и кто-то их читает.

Пятое: проверено, что основные сценарии работают после включения.

Шестое: описан порядок добавления нового внешнего сервиса.

Седьмое: политика пересматривается не реже раза в квартал.

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

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