Убрать unsafe-inline из CSP на живом проекте: что пришлось переписать
Content-Security-Policy с 'unsafe-inline' в script-src — это как замок, который открывается любым ключом. Он есть, он в заголовках, аудит его видит. Только браузер не может отличить ваш инлайновый скрипт от внедрённого чужого, поэтому не блокирует ни один.
Зачем вообще вторая линия
Первая линия обороны от XSS — экранирование. Любые данные, попадающие в разметку, проходят через escHtml(), и в теории этого достаточно.
На практике одного экранирования мало, потому что «в теории» и «во всех 40 местах сразу» — разные утверждения. У нас, например, аудит нашёл отражённый XSS через параметр ?city= в фильтрах каталога: значение города склеивалось в JavaScript-строку, и экранирование HTML там не спасало.
CSP — вторая линия: даже если данные где-то попали в разметку неэкранированными, браузер откажется исполнять внедрённый скрипт. Но ровно до тех пор, пока в политике не стоит
'unsafe-inline'. С ней браузер разрешает исполнение любого инлайнового скрипта на странице, а отличить «наш» от «чужого» он не может: и тот и другой — просто текст внутри тега.
То есть найденный XSS через ?city= при 'unsafe-inline' исполнился бы полноценно. Директива была, защиты не было.
Что держало unsafe-inline
Убрать директиву — это не правка заголовка, а инвентаризация всего исполняемого инлайна. Нашлось четыре класса.
1. Пять onclick на карточке компании. Кнопки «скопировать реквизиты», «скачать выписку», «в избранное», пагинация арбитражных дел. Атрибут onclick="..." — это инлайновый скрипт, даже если внутри один вызов функции.
Заменено делегированием: обработчик один, на контейнере, а кнопки помечены data-action:
// было: <button onclick="copyRequisites('7707083893')">
// стало:
document.addEventListener('click', (e) => {
const el = e.target.closest('[data-action]');
if (!el) return;
const { action, inn } = el.dataset;
if (action === 'copy') copyRequisites(inn);
// …
});Побочная польза: обработчик вешается один раз и работает для элементов, которых на момент загрузки ещё не было, — а карточка как раз перерисовывается динамически.
2. Фильтры каталога. Селекты города, статуса и типа перезагружали страницу через
onchange="location.href='...'" — то самое место, где город попадал в JS-строку. Переехали на data-filter с параметрами в data-params и общий делегированный обработчик change. Вектор ?city= закрылся при этом конструктивно: значение больше не оказывается внутри кода, оно уходит в HTML-атрибут через экранирование. Статус и тип и так проверялись по белому списку.
3. Инициализация Яндекс.Метрики. Стандартный сниппет счётчика — инлайновый скрипт в
<head>. Вынесен в отдельный файл /metrika.js, подключается обычным <script src>.
4. Данные карточки. Сервер клал их на страницу как window.__PRELOAD_DATA__ = {...} — опять инлайновый скрипт, да ещё и с данными внутри. Переехало в
<script type="application/json" id="preload">: такой тег браузер не исполняет, клиент читает его содержимое и разбирает сам.
Здесь важна деталь безопасности, которая к CSP не относится, но появляется вместе с ней: внутри JSON нужно экранировать <. Иначе строка </script>, попавшая в данные, закроет тег — и это уже не косметика, а полноценный XSS. Заодно экранируются U+2028 и U+2029: в JavaScript это переводы строки, а JSON их не экранирует.
Что получилось
Политика для скриптов теперь: script-src 'self' mc.yandex.ru. Никакого unsafe-inline, никаких CDN — Chart.js в тот же заход переехал из cdnjs к себе в public/, так что внешних доменов, кроме счётчика, на сайте не осталось вовсе. Заодно библиотека попала в
devDependencies и стала видна для npm audit и npm outdated, чего с подключением по ссылке не было в принципе.
Регрессию стерегут тесты. Они простые и проверяют не поведение, а факт:
- в разметке ключевых страниц нет ни одного атрибута
on*=; - нет исполняемого инлайнового
<script>(JSON-тег сtype="application/json"разрешён); - в заголовке
Content-Security-Policyне встречаетсяunsafe-inlineдля скриптов.
Без такого сторожа директива вернётся сама: кто-нибудь добавит удобный onclick, всё будет работать локально, и никто не заметит, что политика снова стала декоративной.
Почему style-src осталась
В style-src 'unsafe-inline' пока стоит, и это осознанно, а не «руки не дошли». Её держат инлайновые style="..." в разметке — наследие быстрой вёрстки. Вычистить их и перевести на классы — отдельная работа на несколько вечеров, она в бэклоге дизайн-системы.
Важно понимать разницу в риске. Инлайновый скрипт исполняет произвольный код: крадёт сессии, шлёт запросы от имени пользователя, подменяет содержимое. Инлайновый стиль в худшем случае испортит внешний вид или используется в экзотических атаках на утечку данных через селекторы — несопоставимо. Поэтому порядок именно такой: сначала скрипты, стили потом.
Честно называть такие компромиссы полезнее, чем закрывать вопрос формулировкой «CSP настроен». Настроен наполовину — тоже результат, если знаешь, какая половина осталась.
Что забирать с собой
'unsafe-inline'вscript-srcобнуляет смысл CSP для скриптов. Не «ослабляет», а обнуляет: браузер физически не отличает ваш инлайн от внедрённого.- Снятие директивы — это инвентаризация, а не правка конфига. Ищите
on=-атрибуты, сниппеты аналитики, серверную вставку данных вwindow.. - Данные на страницу — только через
type="application/json"и с экранированием<внутри. Это и CSP-совместимо, и безопаснее по сути. - Поставьте тест-сторож. Директиву, которую ничто не проверяет, вернут обратно первым же «быстрым фиксом» — и об этом никто не узнает.