Content Security Policy — это белый список источников, из которых странице разрешено грузить и исполнять что-либо. Заголовок настраивается за пять минут, и ровно поэтому его чаще всего откатывают на следующий день: политика, написанная по шаблону из статьи, гарантированно ломает половину интерфейса, и вернуть всё как было проще, чем разбираться.
Порядок, при котором политику удаётся внедрить и не откатить, ровно один: сначала режим наблюдения, потом блокировка. Ниже — этот порядок и разбор настоящей политики, включая те её части, которые пришлось оставить хуже, чем хотелось.
#Что понадобится
- Место, где вы можете ставить заголовки ответа: код приложения или обратный прокси. Про второй вариант — в гайде про обратный прокси перед ИИ-сервисом.
- Инвентаризация того, что ваши страницы реально загружают. Не предположение, а список.
- HTTPS, если планируете добавлять
Strict-Transport-Security: по обычномуhttp://браузер этот заголовок игнорирует, а закреплять localhost за HTTPS — активный вред. Про выдачу сертификата — в гайде про HTTPS и автопродление.
#Шаг 1. Инвентаризация вместо шаблона
Первое действие — не писать политику, а посчитать. Для каждой директивы нужен ответ на вопрос «что у нас реально это делает».
Когда мы это посчитали, картина оказалась такой: около 128 исполняемых встроенных блоков скриптов и около 100 обработчиков в атрибутах вида onclick в 79 шаблонах; 448 атрибутов style в 94 шаблонах и 39 встроенных блоков стилей; все сторонние библиотеки лежат у себя, ни одного скриптового CDN.
Из этой инвентаризации политика выводится почти механически — и сразу становится видно, что можно ужать до 'self', а что нельзя.
#Шаг 2. Написать политику по директивам
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://mc.yandex.ru https://yastatic.net;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' data: https://fonts.gstatic.com;
img-src 'self' data: blob: https:;
connect-src 'self' https://mc.yandex.ru wss://mc.yandex.ru;
media-src 'self' blob:;
worker-src 'self';
frame-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'self'
Что каждая строка делает и почему выглядит именно так:
default-src 'self'— пол для всех директив, которые вы забыли перечислить. Забыть придётся: их больше, чем кажется.script-src— здесь и живёт вся драма, разбор ниже.style-src 'unsafe-inline'— неизбежен, пока в шаблонах есть атрибутыstyleи пока стили генерируются на лету в браузере. Библиотека, собирающая CSS в рантайме, вставляет блок стилей на каждой странице; без'unsafe-inline'страница остаётся без оформления целиком.img-src ... https:— нарочно широко. Картинки не исполняют код, а слишком узкийimg-src— классический способ незаметно сломать пользовательский контент. Канал утечки, который эта директива закрыла бы, всё равно шире открыт черезscript-src 'unsafe-inline', так что ужимать её здесь смысла нет.connect-src— сюда попадают всеfetch, XHR и потоки событий. Забыть проwss://легко:https://в CSP не покрывает веб-сокет, это отдельная схема.object-src 'none',base-uri 'self',form-action 'self'— три директивы, которые ставятся с высокой уверенностью и дают много. Первая убирает устаревшие встраиваемые объекты, вторая закрывает подмену базового адреса, третья — отправку формы на чужой хост.frame-ancestors 'self'— против кликджекинга. Осторожно: у нас есть один маршрут с публичной вставкой скриншота, иframe-ancestorsв современных браузерах переопределяет старый заголовокX-Frame-Options, то есть тихо убил бы эту вставку. Такому маршруту политика выдаётся отдельная.
Отдельно про яндексовские адреса в списке: на размещённой версии стоит Яндекс.Метрика с вебвизором, поэтому её домены пришлось внести в script-src и connect-src. При развёртывании у себя этого счётчика нет — он подключается одним шаблоном, и вместе с ним из политики уходят три строки. Что это означает для приватности, разобрано отдельно в статье про телеметрию в ИИ-приложениях.
#Шаг 3. Понять, почему 'unsafe-inline' не убрался
Это самая честная часть статьи, потому что все руководства требуют выкинуть 'unsafe-inline', а мы не смогли.
Стандартный совет — перейти на одноразовые метки (nonce). Он разбивается о две вещи.
Первая: метка не покрывает атрибуты-обработчики. onclick="..." в атрибуте нельзя пометить меткой в принципе. Сотня таких обработчиков означает сотню переписанных мест, а не настройку заголовка.
Вторая, менее известная: добавление метки отключает 'unsafe-inline'. Это не побочный эффект, а поведение по спецификации: если в script-src есть nonce или хеш, браузер игнорирует 'unsafe-inline'. То есть промежуточного состояния «пометим постепенно» не существует — в момент появления первой метки перестаёт работать всё непомеченное сразу.
Плюс 'unsafe-eval': стандартная сборка популярной библиотеки реактивности компилирует выражения из атрибутов через конструктор функции, а библиотека обмена фрагментами — то же самое для своих значений. Убрать это можно, но ценой отдельной сборки библиотеки и переписывания каждого выражения в 48 шаблонах.
Вывод, который стоит записать честно: политика с 'unsafe-inline' в script-src защищает существенно слабее, чем без него. Она всё ещё закрывает загрузку скриптов с чужих доменов, подмену базового адреса, отправку формы наружу и встраивание в чужой фрейм. Но от внедрения кода в саму страницу она не защищает. Называть такую политику «защитой от XSS» некорректно.
#Шаг 4. Две политики сразу: рабочая и целевая
Приём, который решает проблему «внедрили и застряли». Ставятся два заголовка:
Content-Security-Policy— то, что реально применяется сегодня, со всеми вынужденными послаблениями;Content-Security-Policy-Report-Only— то, к чему вы идёте:script-src 'self',style-src 'self', без единогоunsafe-*.
Второй ничего не блокирует. Он только сообщает о нарушениях — в консоль браузера, а при указанном адресе приёма отчётов и на сервер. Смысл в том, что целевая политика перестаёт быть намерением и становится измеримой величиной: вы видите, сколько нарушений осталось и какие именно шаблоны их порождают. Убрали десять встроенных блоков — количество отчётов уменьшилось. Когда оно дошло до нуля, целевую политику можно менять местами с рабочей.
Порядок внедрения целиком:
- Поставить только
Report-Onlyсо строгой политикой. Ничего не ломается по определению. - Неделю собирать отчёты и разбирать по источникам.
- Ослабить политику ровно до состояния, в котором отчётов нет, — и эту версию поставить как рабочую.
- Вернуть строгую версию в
Report-Onlyкак цель. - Раз в квартал смотреть, сократился ли разрыв.
#Шаг 5. Не вешать политику куда попало
Заголовки различаются по области применения, и это не мелочь.
| Заголовок | Куда ставить |
|---|---|
X-Content-Type-Options: nosniff |
на каждый ответ, включая API |
Content-Security-Policy |
только на HTML-ответы |
Strict-Transport-Security |
только когда запрос реально пришёл по HTTPS |
X-Frame-Options |
на HTML, в пару к frame-ancestors |
Строка про HTML — не стилистика. Политика, повешенная на файл сервис-воркера, начинает управлять его собственными запросами, а он ходит за ресурсами и на чужие домены; результат — тихо сломанные шрифты и счётчик под сервис-воркером, причём только у тех пользователей, у кого он уже установлен. Как сервис-воркер вообще устроен и чем ещё он умеет удивить, — в статье про PWA и версионирование кэша.
Строка про HSTS: за обратным прокси приложение видит обычный http:// и заголовок не поставит, пока прокси не передаст ему настоящую схему.
И ещё одно правило, спасающее от тихих поломок: заголовок, уже выставленный маршрутом, не перезаписывать. Именно так у нас выжила публичная вставка скриншота со своим X-Frame-Options.
#Проверка результата
# 1. что реально отдаётся
curl -sI https://example.com/ | grep -i -E 'content-security|x-frame|x-content-type|strict-transport'
# 2. HTML получает политику, а статика — нет
curl -sI https://example.com/static/sw.js | grep -ci content-security-policy # ждём 0
# 3. HSTS не приходит по http
curl -sI http://example.com/ | grep -ci strict-transport-security # ждём 0
Дальше — руками, и это обязательная часть: откройте консоль браузера и пройдите по всем ключевым экранам. Нарушения рабочей политики в консоли — это сломанный интерфейс прямо сейчас. Нарушения Report-Only — это ваш список задач. Различать их надо по префиксу сообщения, иначе легко испугаться отчётов, которые ничего не ломают.
#Что может пойти не так
Политика собрана из двух источников. Заголовок поставили и в приложении, и в прокси — браузер применяет оба, и результат строже каждого. Классический симптом: локально всё работает, на сервере половина не грузится. Ставьте в одном месте.
Забыли wss://. Потоки событий работают, веб-сокеты — нет. https:// их не покрывает.
frame-ancestors убил нужное встраивание. Он сильнее X-Frame-Options и переопределяет его. Маршрутам-исключениям нужна отдельная политика.
Отчёты не приходят. Отчёты о нарушениях — тема, где браузеры расходятся, а форматы менялись; на момент написания правильнее ориентироваться на консоль браузера как на основной канал, а серверный приём отчётов — как на дополнение. Проверяйте текущую поддержку в документации браузера.
CSP приняли за защиту от всего. Она ограничивает браузер. Она ничего не знает про то, что происходит на сервере, и не имеет отношения к тому, как в вашу модель попадают чужие инструкции, — это другой класс атак, разобранный в статье про промпт-инъекции.
#Частые вопросы
#С чего начать, если политики нет вообще?
С двух заголовков, которые почти ничего не ломают: X-Content-Type-Options: nosniff на все ответы и Content-Security-Policy-Report-Only со строгой политикой на HTML. Это ноль риска и сразу полный список того, что вам предстоит.
#Стоит ли ставить CSP, если он получится с 'unsafe-inline'?
Да, но не обманывайте себя относительно того, что вы получили. Даже ослабленная политика закрывает загрузку скриптов с чужих доменов, подмену базового адреса, отправку формы наружу и встраивание в чужой фрейм. Просто она не защищает от внедрения кода в саму страницу — а именно так формулируется цель, ради которой CSP обычно и ставят.
#Как быть с картинками из пользовательского контента?
Либо широкая img-src со схемой https:, либо проксирование картинок через свой домен. Второе строже и требует кода. Первое — компромисс, осознанно выбранный у нас: картинки не исполняются, а слишком узкое правило ломает контент незаметно.
#Нужен ли CSP на личном сервисе, которым пользуюсь только я?
Три директивы — object-src 'none', base-uri 'self', form-action 'self' — стоят почти ничего и полезны всегда. Остальное окупается, когда сервисом пользуется кто-то ещё или когда в интерфейсе появляется чужой контент: пересланное письмо, страница из интернета, ответ модели с разметкой.
#Как понять, что политику можно ужесточать?
По количеству отчётов от Report-Only. Ноль отчётов за неделю обычного использования — можно менять местами. Ненулевое количество — сначала уберите источники, потом ужесточайте. Именно в таком порядке, а не наоборот: заголовки безопасности — часть эксплуатации сервиса, а не разовая настройка, и они встают в общий маршрут о том, как поднять свой ИИ-сервис.