руИНН
руИНН

Убрать 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, чего с подключением по ссылке не было в принципе.

Регрессию стерегут тесты. Они простые и проверяют не поведение, а факт:

Без такого сторожа директива вернётся сама: кто-нибудь добавит удобный onclick, всё будет работать локально, и никто не заметит, что политика снова стала декоративной.

Почему style-src осталась

В style-src 'unsafe-inline' пока стоит, и это осознанно, а не «руки не дошли». Её держат инлайновые style="..." в разметке — наследие быстрой вёрстки. Вычистить их и перевести на классы — отдельная работа на несколько вечеров, она в бэклоге дизайн-системы.

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

Честно называть такие компромиссы полезнее, чем закрывать вопрос формулировкой «CSP настроен». Настроен наполовину — тоже результат, если знаешь, какая половина осталась.

Что забирать с собой