Content Security Policy (CSP) — один из самых эффективных и при этом недооценённых способов снизить риск XSS и внедрения вредоносного кода в браузере пользователя. Правильно настроенная политика позволяет строго ограничить, откуда страница может загружать скрипты, стили, шрифты, картинки и какие конструкции JavaScript ей разрешены. В статье разберём два ключевых подхода к разрешению inline-скриптов — nonce и hash, а также безопасный путь внедрения через режим Report-Only. Приведу практические примеры для Nginx и Apache, типичные ошибки и чеклист финальной политики.
Зачем вам CSP прямо сейчас
Даже если у вас чистый код и строгий код-ревью, фронтенд нередко обрастает интеграциями: аналитика, A/B, чаты, виджеты, сторонние CDN. Статическая отдача через CDN, SPA и SSR-рендеринг — всё это повышает поверхность атаки. CSP позволяет:
- ограничить источники ресурсов (например, только ваш домен и явно перечисленные поддомены),
- запретить inline-скрипты по умолчанию, разрешив их только через
nonceилиhash, - запретить потенциально опасные конструкции вроде
eval, - получать отчёты о нарушениях и постепенно ужесточать правила, не ломая прод.
Цель CSP — не «разрешить всё, что и так работает», а определить минимально необходимую политику, без которой приложение перестаёт быть функциональным.
Nonce и hash: два способа разрешить inline-код
По умолчанию политика с script-src 'self' запрещает inline-скрипты. Но иногда они неизбежны: bootstrap-конфиг, критические инлайновые стили, инлайн-данные. Разрешить их можно двумя способами.
Подход 1: nonce
Nonce — одноразовый криптографически стойкий токен, сгенерированный на сервере для каждого HTTP-ответа. Его нужно вставить:
- в заголовок CSP:
script-src 'nonce-...'и при необходимостиstyle-src 'nonce-...', - в каждый разрешаемый inline-блок:
<script nonce="...">...</script>или<style nonce="...">...</style>.
Если nonce совпадает, браузер запустит код. Важные свойства:
- Nonce должен быть разным для каждого ответа (перезапрос — новый nonce),
- минимум 128 бит энтропии,
- никогда не кэшируйте HTML с зафиксированным nonce без учёта пользователя/сессии.
Плюсы nonce:
- проще для динамического контента и шаблонов,
- работает с директивой
'strict-dynamic'— удобно, когда bootstrap-скрипт динамически подгружает другие скрипты, - не нужно вычислять хеши при каждом изменении инлайна.
Минусы:
- требует генерации и прокидывания одного значения в заголовок и в разметку,
- ухудшает эффективность кэша HTML, если кэшировать страницу на прокси,
- надо аккуратно реализовать в шаблонизаторе/фреймворке.
Подход 2: hash
Hash — это хеш содержимого inline-блока. В script-src и/или style-src перечисляют хеши разрешённых инлайнов: 'sha256-...' или 'sha384-...', 'sha512-...' (Base64 от бинарного дайджеста). Важно: хеш считается строго от точного содержимого — включая пробелы, переносы и комментарии.
Плюсы hash:
- статичен и независим от запроса — идеально для неизменного инлайна,
- не ломает кэш HTML на прокси/CDN,
- прост в статической сборке (генерируйте в CI/CD).
Минусы:
- любой косметический патч в инлайне потребует обновить хеш,
- не работает с
'strict-dynamic'как доверенный корень, - менее удобен для динамических шаблонов.
Когда что выбирать
- Nonce: если есть динамический инлайн-бустрап, если скрипт подгружает другие скрипты, если вы хотите применять
'strict-dynamic', или если у вас SSR и вы легко прокидываете переменные в шаблон. - Hash: если inline стабилен (например, небольшой конфиг), если у вас статический сайт, или если используете статический генератор и удобно считать хеш на этапе сборки.
- Стили: попробуйте
style-src 'nonce-...'или список хешей. Избегайте'unsafe-inline'для стилей, но иногда он остаётся единственным компромиссом — тогда ограничьте егоstyle-src-elem/style-src-attrи источниками шрифтов.
Report-Only: безопасный путь в прод
Включать жёсткую политику сразу — рецепт ночного инцидента. Правильный путь — режим Content-Security-Policy-Report-Only: браузер не блокирует, а только присылает отчёты нарушений на ваш эндпоинт. План внедрения:
- Запустите широкую политику в Report-Only, включите
report-uri /csp-reportи/илиreport-to, добавьтеreport-sample. - Собирайте отчёты, агрегируйте источники, разбирайте по типам ресурсов: скрипты, стили, шрифты, изображения, XHR/fetch.
- Убирайте лишнее: запрещайте всё ненужное, для нужного — выбирайте
nonceилиhash, используйте поддомены и CDN с жёстким списком. - Включите
'strict-dynamic'приnonce, удалите «широкие» хосты вscript-src(иначе лишите смыслаstrict-dynamic). - Как только шум отчётов утихнет и основные сценарии тестами покрыты — переведите политику в боевой заголовок
Content-Security-Policy, но оставьте дублирование в Report-Only для экспериментов.
Работайте по HTTPS, добавляйте HSTS и следите за сроком действия сертификатов. При необходимости оформите надёжные SSL-сертификаты.

Примеры конфигураций Nginx
Минимальная подстановка заголовка в прод-режиме. Имейте в виду: без параметра always в Nginx заголовок не попадёт в некоторые ответы с ошибками.
server {
listen 443 ssl;
server_name example.com;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
location / {
proxy_pass http://app;
}
}
Запуск через Report-Only и сбор отчётов на эндпоинт /csp-report. На старых браузерах работает report-uri, в новых — Report-To и report-to. Можно указывать оба.
server {
listen 443 ssl;
server_name example.com;
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; report-uri /csp-report; report-to csp-endpoint; report-sample" always;
add_header Report-To '{"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"/csp-report"}]}' always;
location = /csp-report {
# На старте можно просто принимать и игнорировать
# потом проксировать в сборщик логов
return 204;
}
location / {
proxy_pass http://app;
}
}
Если используете nonce, сам nonce должен сгенерировать бэкенд и подставить и в заголовок, и в инлайн. В Nginx типично прокидывают его от приложения через заголовок upstream-а и отражают в ответе:
location / {
proxy_set_header X-Request-Nonce "";
proxy_pass http://app;
# Приложение вернёт X-Request-Nonce: abcd...; мы используем его ниже
}
# map нужен заранее в http {}, чтобы собрать строку CSP с nonce из заголовка
# map $upstream_http_x_request_nonce $csp_nonce { default $upstream_http_x_request_nonce; }
add_header Content-Security-Policy "default-src 'self'; script-src 'strict-dynamic' 'nonce-$csp_nonce'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
В реальности удобнее формировать полный заголовок CSP на стороне приложения, чтобы избежать склейки строк в Nginx и держать политику рядом с шаблонами. Полезно также посмотреть общую шпаргалку по заголовкам безопасности для веб-серверов: HTTP security headers для Nginx и Apache.
Примеры конфигураций Apache
В виртуальном хосте:
<VirtualHost *:443>
ServerName example.com
Header always set Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
# Report-Only для безопасного старта
Header always set Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self'; report-uri /csp-report; report-to csp-endpoint; report-sample"
Header always set Report-To '{"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"/csp-report"}]}'
<Location "/csp-report">
# Можно отдать 204, либо проксировать в приложение
Require all granted
</Location>
</VirtualHost>
Реализация с nonce: Apache сам по себе не генерирует криптографически стойкий nonce для каждого ответа. На практике это делает приложение и отдаёт, например, заголовок X-Request-Nonce. Дальше mod_headers может подставить его в CSP:
Header always set Content-Security-Policy "default-src 'self'; script-src 'strict-dynamic' 'nonce-%{X-Request-Nonce}e'; object-src 'none'" env=X-Request-Nonce
Альтернатива — полностью формировать заголовок на бэкенде и просто пробрасывать его через веб-сервер. Настроить такие заголовки можно и на виртуальном хостинге, и на управляемом VDS.
Генерация nonce в приложении
PHP (FPM)
<?php
// Генерация 16 байт криптографически стойкой случайности
$nonce = base64_encode(random_bytes(16));
// Заголовок CSP с nonce
header("Content-Security-Policy: default-src 'self'; script-src 'strict-dynamic' 'nonce-{$nonce}'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'");
// Пример инлайна с тем же nonce
echo '<script nonce="' . htmlspecialchars($nonce, ENT_QUOTES) . '">window.appConfig = { env: "prod" };</script>';
?>
Node.js (Express)
app.use((req, res, next) => {
const nonce = require('crypto').randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.set('Content-Security-Policy', "default-src 'self'; script-src 'strict-dynamic' 'nonce-" + nonce + "'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'");
next();
});
// В шаблоне EJS/Pug подставляйте nonce в инлайн-скрипты
Python (Django)
import os, base64
from django.utils.deprecation import MiddlewareMixin
class CSPNonceMiddleware(MiddlewareMixin):
def process_request(self, request):
request.csp_nonce = base64.b64encode(os.urandom(16)).decode('ascii')
def process_response(self, request, response):
nonce = getattr(request, 'csp_nonce', None)
if nonce:
policy = "default-src 'self'; script-src 'strict-dynamic' 'nonce-{}'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'".format(nonce)
response['Content-Security-Policy'] = policy
return response
Правило: один и тот же nonce должен использоваться в заголовке и во всех разрешаемых инлайн-блоках внутри этого же ответа. На следующий запрос генерируйте новый.
Вычисление hash для инлайна
Для небольшого стабильного блока удобны хеши. Пример: получим SHA-256 хеш строки через OpenSSL и Base64:
printf "console.log('hello')" | openssl dgst -sha256 -binary | openssl base64 -A
Вставляем результат как 'sha256-...' в script-src или style-src. Важно пересчитывать хеш при любом изменении содержимого вплоть до пробела.
Базовая политика с nonce и strict-dynamic
Полезный шаблон для современных браузеров:
Content-Security-Policy: default-src 'self';
script-src 'strict-dynamic' 'nonce-RANDOM'
; object-src 'none'
; base-uri 'self'
; frame-ancestors 'none'
; img-src 'self' data:
; style-src 'self' 'nonce-RANDOM'
; font-src 'self'
; connect-src 'self'
; report-uri /csp-report
; report-to csp-endpoint
; report-sample
Объяснение ключевых флагов:
'strict-dynamic'говорит браузеру доверять скриптам, которые были загружены из доверенного инлайна сnonce, даже если они с другого домена. Тогда нет смысла перечислять кучу источников вscript-src; наоборот, их лучше убрать, чтобы не расширять поверхность для XSS-гаджетов.object-src 'none'выключает устаревшие плагины.frame-ancestors 'none'защищает от clickjacking (часто используется вместе сX-Frame-Optionsкак совместимость).report-sampleдобавляет фрагмент нарушившего кода в отчёт — очень помогает в отладке.
Метрики и отчёты о нарушениях
Браузер отправляет JSON. В зависимости от механизма это либо старый формат с ключом csp-report, либо новый application/reports+json. Пример классического отчёта:
{
"csp-report": {
"document-uri": "https://example.com/",
"referrer": "",
"violated-directive": "script-src-elem",
"effective-directive": "script-src-elem",
"original-policy": "default-src 'self'; script-src 'self'; report-uri /csp-report",
"blocked-uri": "https://bad.example.net/evil.js",
"line-number": 12,
"column-number": 34,
"source-file": "https://example.com/app.js",
"sample": "(function(){eval(location.hash.slice(1))})()"
}
}
На старте полезно:
- агрегировать по
violated-directiveиblocked-uri, - отдельно смотреть нарушения от расширений браузера — часто шум,
- добавить корреляцию с release/commit версии клиента,
- логировать user agent для понимания браузерной поддержки.

Миграция: пошаговый рецепт
- Инвентаризация. Список всех источников: домены для скриптов, стилей, шрифтов, картинок, XHR, WebSocket. Отдельно — инлайны, eval, динамические вставки.
- Первый Report-Only. Очень мягкая политика, но уже с
object-src 'none'иframe-ancestors. Опциональноupgrade-insecure-requests, если нет смешанного контента. - Чистим. Убираем лишние домены, заменяем inline-обработчики событий на делегирование из отдельных файлов, избавляемся от
evalи библиотек, которые его требуют, или включаем только там, где критично. - Выбираем стратегию. Для динамических инлайнов —
nonce + 'strict-dynamic'. Для стабильных — хеши. - Второй Report-Only. Уже почти финальная политика, но с отчётами. Под нагрузочным трафиком проверяем, что шум утих.
- Включаем enforcing. Переносим политику в
Content-Security-Policy. Оставляем параллельно ещё один Report-Only для A/B экспериментов.
Продвинутые приёмы
- script-src-elem vs script-src-attr. CSP3 разделяет контроль загрузок из элементов и inline-обработчиков атрибутов. Это помогает тонко закрывать инлайны.
- unsafe-hashes. Позволяет включить хеши для inline-обработчиков атрибутов (
onclickи т. п.), но лучше отказаться от таких обработчиков совсем. - wasm-unsafe-eval. Для WebAssembly может понадобиться, но включайте только при чётком понимании рисков.
- Trusted Types. Дополняет CSP, защищая от DOM XSS при работе с опасными sinks. Порог вхождения выше, но в больших SPA себя оправдывает.
Типичные грабли
- Оставили 'unsafe-inline' в
script-src. Это обнуляет ценность CSP, так как любой внедрённый инлайн выполнится. - Смешали 'strict-dynamic' с широкой вайтлистой. Если вы доверяете цепочке от nonce, не нужно разрешать десяток доменов — вы расширяете поверхность атаки.
- Кэш рушит nonce. Реверс-прокси закешировал страницу с конкретным nonce, а пользователь получил HTML с невалидным заголовком. Решение: не кэшируйте HTML на уровне, где nonce может «смешиваться», либо вставляйте nonce на самом последнем шаге рендера.
- Хеши не совпали из-за форматирования. Изменили пробелы, prettier, минификатор — хеш устарел. Решение: считать хеш на том же шаге пайплайна, что и минификация.
- Третьи стороны требуют eval. Ищите альтернативы или изолируйте такие интеграции на отдельной странице/поддомене с более мягкой политикой.
Практические шпаргалки
- Минимум для старта:
default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'. - Скрипты: при возможности используйте
nonce+'strict-dynamic', уберите'unsafe-inline'и доменные вайлисты. - Стили:
style-src 'self' 'nonce-...'или хеши. Если нужна инлайновая критическая CSS — ограничьте её хешом. - Картинки: часто
img-src 'self' data:достаточно. Base64-иконки потребуютdata:. - XHR/WebSocket: перечисляйте API-домены в
connect-src. Разделяйте прод/стейджинг. - Встраивание: регулируйте
frame-ancestors, а неX-Frame-Options; CSP точнее и гибче.
Ответы на частые вопросы
Можно ли задать CSP через мета-тег? Да, <meta http-equiv="Content-Security-Policy" content="...">, но заголовок предпочтительнее: применяется раньше, работает и при редиректах/ошибках через сервер.
Нужно ли дублировать в Report-Only после включения enforcing? Полезно держать второй заголовок Report-Only для тестирования новых источников, прежде чем добавлять их в основную политику.
Как быть с SPA и runtime-инлайном? Вынесите конфиг в тег <script type="application/json" nonce="...">...</script> и парсите его безопасно без eval. Старайтесь не порождать инлайн-код на лету.
Что с поддержкой браузеров? Большинство директив из статьи работают в актуальных версиях основных браузеров. Если нужна обратная совместимость, тестируйте конкретные флаги по целевой матрице. Параллельно обеспечьте корректную настройку TLS — подсказки в материале лучшие практики SSL/TLS.
Чеклист финальной политики
- Есть боевой заголовок
Content-Security-Policyи параллельныйReport-Onlyдля будущих изменений. object-src 'none',base-uri 'self',frame-ancestorsвыставлены.- Для скриптов — либо
nonce + 'strict-dynamic', либо полный отказ от инлайна;'unsafe-inline'отсутствует. - Для стилей — nonce или хеши; при невозможности — чётко зафиксированы источники и сценарии.
- Источники для
img-src,font-src,connect-srcсведены к минимуму. - Отчёты собираются, агрегируются и периодически ревьюятся.
- Тесты покрывают критические пользовательские сценарии под CSP.
Итог
CSP — это не «галочка соответствия», а мощный операционный контроль. Начните с режима Report-Only, инвентаризируйте ресурсы, выберите стратегию nonce или hash, постепенно ужесточайте. Для динамических приложений удобнее nonce с 'strict-dynamic'; для статических участков — хеши. Веб-серверы Nginx и Apache одинаково хорошо выставляют заголовки, но генерация nonce — ответственность приложения. В результате вы получите защищённый от XSS фронтенд без неожиданностей для пользователей и без бессонных ночей у дежурной команды. Если разворачиваете новый проект — начните с HTTPS и CSP сразу; при необходимости можно подобрать подходящий хостинг.


