OSEN-НИЙ SAAALEСкидка 50% на виртуальный хостинг и VDS
до 30.11.2025 Подробнее
Выберите продукт

CSP на практике: nonce и hash, безопасный старт с Report-Only

Пошаговое внедрение Content Security Policy для защиты от XSS: чем отличаются nonce и hash, как безопасно запустить режим Report-Only, какие директивы добавить и как анализировать отчёты. Примеры для Nginx и Apache, советы команде.
CSP на практике: nonce и hash, безопасный старт с Report-Only

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 и источниками шрифтов.
FastFox VDS
Облачный VDS-сервер в России
Аренда виртуальных серверов с моментальным развертыванием инфраструктуры от 195₽ / мес

Report-Only: безопасный путь в прод

Включать жёсткую политику сразу — рецепт ночного инцидента. Правильный путь — режим Content-Security-Policy-Report-Only: браузер не блокирует, а только присылает отчёты нарушений на ваш эндпоинт. План внедрения:

  1. Запустите широкую политику в Report-Only, включите report-uri /csp-report и/или report-to, добавьте report-sample.
  2. Собирайте отчёты, агрегируйте источники, разбирайте по типам ресурсов: скрипты, стили, шрифты, изображения, XHR/fetch.
  3. Убирайте лишнее: запрещайте всё ненужное, для нужного — выбирайте nonce или hash, используйте поддомены и CDN с жёстким списком.
  4. Включите 'strict-dynamic' при nonce, удалите «широкие» хосты в script-src (иначе лишите смысла strict-dynamic).
  5. Как только шум отчётов утихнет и основные сценарии тестами покрыты — переведите политику в боевой заголовок Content-Security-Policy, но оставьте дублирование в Report-Only для экспериментов.

Работайте по HTTPS, добавляйте HSTS и следите за сроком действия сертификатов. При необходимости оформите надёжные SSL-сертификаты.

Пример логов нарушений CSP в режиме Report-Only в инструментах разработчика

Примеры конфигураций 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.

FastFox SSL
Надежные SSL-сертификаты
Мы предлагаем широкий спектр SSL-сертификатов от GlobalSign по самым низким ценам. Поможем с покупкой и установкой SSL бесплатно!

Генерация 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 для понимания браузерной поддержки.

Конфигурации Nginx и Apache для заголовков CSP с nonce и hash

Миграция: пошаговый рецепт

  1. Инвентаризация. Список всех источников: домены для скриптов, стилей, шрифтов, картинок, XHR, WebSocket. Отдельно — инлайны, eval, динамические вставки.
  2. Первый Report-Only. Очень мягкая политика, но уже с object-src 'none' и frame-ancestors. Опционально upgrade-insecure-requests, если нет смешанного контента.
  3. Чистим. Убираем лишние домены, заменяем inline-обработчики событий на делегирование из отдельных файлов, избавляемся от eval и библиотек, которые его требуют, или включаем только там, где критично.
  4. Выбираем стратегию. Для динамических инлайнов — nonce + 'strict-dynamic'. Для стабильных — хеши.
  5. Второй Report-Only. Уже почти финальная политика, но с отчётами. Под нагрузочным трафиком проверяем, что шум утих.
  6. Включаем 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 сразу; при необходимости можно подобрать подходящий хостинг.

Поделиться статьей

Вам будет интересно

Apache mod_cache: CacheQuickHandler, cache_disk vs cache_socache OpenAI Статья написана AI (GPT 5)

Apache mod_cache: CacheQuickHandler, cache_disk vs cache_socache

Если Apache — фронтенд или бэкенд для PHP/FCGI/прокси, mod_cache заметно разгружает приложение. Разбираем CacheQuickHandler, выбор ...
Docker BuildKit registry cache в CI: быстрые сборки на любом раннере OpenAI Статья написана AI (GPT 5)

Docker BuildKit registry cache в CI: быстрые сборки на любом раннере

Долгие Docker‑сборки в CI — классическая боль: свежие раннеры, мало диска и кэш каждый раз с нуля. Registry‑backed cache в BuildKi ...
MySQL 8: caching_sha2_password и TLS — безопасная аутентификация без сюрпризов OpenAI Статья написана AI (GPT 5)

MySQL 8: caching_sha2_password и TLS — безопасная аутентификация без сюрпризов

В MySQL 8 по умолчанию используется caching_sha2_password. Это повысило безопасность, но ломает старые клиенты без TLS. В статье — ...