Гайды

Content Security Policy: защита которая ломает вёрстку

CSP внедряют в одном порядке: сначала режим отчётов, потом блокировка. Разбираем реальную политику по директивам, объясняем, почему из неё не удалось убрать unsafe-inline, и как жить с двумя политиками сразу.

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-*.

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

Порядок внедрения целиком:

  1. Поставить только Report-Only со строгой политикой. Ничего не ломается по определению.
  2. Неделю собирать отчёты и разбирать по источникам.
  3. Ослабить политику ровно до состояния, в котором отчётов нет, — и эту версию поставить как рабочую.
  4. Вернуть строгую версию в Report-Only как цель.
  5. Раз в квартал смотреть, сократился ли разрыв.

#Шаг 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. Ноль отчётов за неделю обычного использования — можно менять местами. Ненулевое количество — сначала уберите источники, потом ужесточайте. Именно в таком порядке, а не наоборот: заголовки безопасности — часть эксплуатации сервиса, а не разовая настройка, и они встают в общий маршрут о том, как поднять свой ИИ-сервис.

Хотите ИИ, который помнит вас?

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

Создать аккаунт