Заголовок Content-Security-Policy относится к вещам, которые настраивают один раз и больше не открывают. Он отдаётся, он синтаксически валиден, автоматический эвалуатор ставит приемлемую оценку. Ровно поэтому политика способна долго резать рабочий трафик так, что об этом никто не узнает: привычные способы проверки устроены так, что этот класс дефектов им недоступен.
Ниже — разбор конкретного случая на собственном сайте. Заявка была узкой: прогнать заголовок через CSP Evaluator и починить две ошибки. Обе оказались пустыми. А настоящий дефект нашёлся тогда, когда проверку сделали не по заголовку, а по поведению браузера.
Мёртвая запись в allowlist — не безобидный остаток
Эвалуатор показал две находки уровня error в script-src: хост, известный своими JSONP-эндпоинтами, и хост, раздающий сборки Angular. Механика обхода в обоих случаях одна. Если атакующий может внедрить <script src>, указывающий на разрешённый хост, политика такой скрипт пропустит — а дальше JSONP-колбэк или шаблонизатор Angular исполнят то, что нужно атакующему. Формально 'self' и хеши на месте; фактически в политике дыра.
Проверка кода дала скучный ответ: сервис, ради которого эти хосты стояли, был выключен конфигом — идентификатор счётчика пустой, режим выключен. Один из двух хостов вообще не встречался в кодовой базе нигде, кроме самого шаблона политики. Он не подключался никогда.
Выключение интеграции и удаление её хостов — одна правка, а не две, из которых вторая откладывается навсегда. Обратное тоже верно: возвращая интеграцию, хосты нужно вернуть в обе директивы, и в script-src, и в connect-src. Иначе тег блокируется молча, а интерфейс продолжает предлагать переключатель, который ничего не включает. Это стоит записать прямо в комментарий рядом с директивой — там, где правку будут делать, а не в тикете.
Почему привычная проверка не могла увидеть главного
Сайт работает по модели «отказ по умолчанию»: инлайн-бутстрап ставит все категории хранения в denied, а сторонние теги грузит отдельный скрипт со своего же домена — и только после того, как посетитель нажал кнопку согласия.
Из этого следует вещь, которую легко упустить: до согласия со страницы не уходит ни одного стороннего запроса. Значит, любая проверка, которая заканчивается на загрузке страницы, физически не может увидеть, покрывает ли allowlist то, что сайт реально запрашивает. Она увидит ноль нарушений — и это будет правдой, не имеющей отношения к делу.
| Способ проверки | Что видит | Чего не видит |
|---|---|---|
curl -I по заголовку | синтаксис, набор хостов | обслуживает ли этот набор фактические запросы |
| CSP Evaluator | обходимые хосты в script-src | connect-src не оценивает вообще |
| Lighthouse, открытие страницы | нарушения до согласия — их нет | всё, что происходит после согласия |
nginx -t | что файл парсится | ничего о поведении |
Обобщение шире аналитики: любой allowlist за гейтом согласия или за фичефлагом проверяем только во включённом состоянии. Отрицательный результат в состоянии по умолчанию не доказывает ничего — ни того, что политика достаточно строгая, ни того, что она достаточно широкая.
Что нашлось при замере с согласием
Замер в состоянии «согласие выдано» дал на статье нарушение, которого никто не заказывал:
Refused to connect because it violates the
document's Content Security Policy.
https://region1.google-analytics.com/g/collect
?v=2&...&en=page_view
Механизм такой. Аналитика отправляет событие не на один фиксированный хост: с европейского адреса это региональный эндпоинт region1.google-analytics.com. В connect-src же стоял точный хост www.google-analytics.com, который региональный не покрывает.
Здесь же прячется ловушка с похожими именами, на которую легко купиться:
analytics.google.com != google-analytics.com
В политике присутствовала маска https://*.analytics.google.com — и создавала полное впечатление, что региональные эндпоинты покрыты. Это разные домены, и она не покрывает ничего из нужного. Документация предписывает для измерения маску https://*.google-analytics.com: она покрывает и www., и все regionN.. Хосты рекламных функций перечислены там же, но отдельной сноской — если рекламные категории выключены, добавлять их не нужно.
Правка вышла в один токен:
- connect-src 'self' https://www.google-analytics.com ...
+ connect-src 'self' https://*.google-analytics.com ...
Два уточнения, важных для честности вывода. Первое: img-src в этой политике разрешает любые https-картинки, то есть пиксельный путь отправки был открыт — но в дофиксовом замере единственным сторонним запросом оставалась загрузка самого тега, ни одного успешного обращения к аналитике. Событие терялось целиком, а не уходило деградировавшим путём. Второе: узкий хост стоял в конфигурации с первого её коммита, 73 дня. Но история конфигурации датирует конфигурацию, а не объём потери: сколько трафика было европейским, из этих данных не следует, и утверждать «потеряно столько-то» было бы выдумкой.
Метод: браузер, настоящий клик, журнал нарушений
Проверка сводится к тому, чтобы выполнить ту же цепочку, что и посетитель:
- Поднять headless-браузер с отладочным портом.
- Открыть страницу и дождаться загрузки.
- Нажать настоящую кнопку согласия, а не выдать согласие программно: дальше работает код сайта, и проверяется именно та цепочка, которую обязан покрывать allowlist.
- Собрать ответы на сторонние запросы со статусами, заблокированные запросы с причиной блокировки и записи журнала о нарушениях политики.
- Чистить хранилище перед каждым URL — иначе баннер не появится повторно, и клик тихо превратится в no-op.
Брать нужно разные шаблоны страниц — статью, раздел, страницу 404: за ними один и тот же конфиг веб-сервера, но разная разметка. Зелёный результат выглядит как «тег → 200, отправка события → 204, нарушений 0».
У метода есть собственная ловушка, и она едва не стоила ложного вывода. Событие уходит не мгновенно: при ожидании в пять секунд его не видно на большинстве страниц. Это артефакт измерения, а не дефект — рабочее окно около пятнадцати секунд. Тот же артефакт маскирует и настоящие нарушения: в дофиксовом прогоне отказ зафиксировался только на одной странице из трёх ровно потому, что на остальных запрос за окно ещё не ушёл.
Отдельный вывод, не про CSP: требование, под которое нет инструмента, не исполняется. «Проверять в состоянии с согласием» было записано во внутренней документации и раньше — но скрипта не существовало, поэтому и проверки не было. Разрыв закрывает не правило, а исполняемый скрипт с прогнанными ветками отказа. Иначе обещанный код возврата висит на коде, который никто не запускал. Это тот же принцип, по которому гейты в пайплайне имеют смысл только тогда, когда их падение проверено.
Два предела, о которых стоит знать заранее
nginx падает по длине токена, а не заголовка. Если хешировать каждый инлайн-блок, список хешей растёт линейно с числом страниц и упирается в неочевидный предел: парсер конфигурации ограничивает любой одиночный токен, включая строку в кавычках, буфером около 4 КБ — не 32 КБ. На практике nginx -t падает с too long parameter. Рефлекс лезть в large_client_header_buffers не помогает: та настройка относится к заголовкам запроса, а отказ происходит на этапе разбора конфигурации, до всякого трафика.
Лечится это тем, что хешировать нужно только исполняемые инлайн-скрипты. Блоки <script type="application/ld+json"> — неисполняемые данные, script-src к ним не применяется (проверено на Chrome 150). Когда хешируется один общий бутстрап, набор хешей равен единице и не растёт с контентом.
У strict-dynamic есть цена, которую советы обычно не называют. Эвалуатор рекомендует его вместо перечисления хостов, и на динамической загрузке тегов он ложится хорошо. Но под ним игнорируется 'self' — а это не только ужесточение. Исчезает страховка на окне между выкатом разметки и обновлением хешей: устаревший хеш бутстрапа означает, что не выполнится вообще ничего, включая сам баннер согласия. Плюс переход меняет шаблоны, то есть требует передеплоя всего корпуса страниц. Решение зависит от того, что осталось в политике после чистки: если на уровне error не осталось ничего, цена не оправдана.
Что забрать с собой
CSP — политика, и к ней применимо то же, что к любой политике как коду: она описывает состояние, которое обязано соответствовать реальности, и расходится с ней молча. Три вещи, которые дешевле сделать сразу:
- снять живой конфиг до правки — генератор может не делать бэкапа сам, а
nginx -tдоказывает только то, что файл парсится; - мерить в целевом состоянии — с выданным согласием, с включённым флагом, — и на разных шаблонах страниц;
- сверять набор хостов с документацией поставщика, а не с памятью: региональные эндпоинты и похожие домены — самая частая причина того, что политика выглядит правильной и не работает.