Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Strict Content Security Policy (CSP) и Nonce в React SSR и Next.js: руководство по настройке и защите от XSS

Тимофей Ищенко

Коротко: Практическое руководство по внедрению Strict CSP и nonce в React SSR и Next.js: настройка Middleware, App/Pages Router, CSS-in-JS и обход частых ошибок.

Межсайтовый скриптинг (XSS) остается одной из ключевых угроз для клиентских и изоморфных приложений. Даже при тщательной санитизации данных ошибки в сторонних пакетах, некорректная вставка HTML или уязвимости в клиентском роутинге могут привести к выполнению произвольного JavaScript-кода в контексте пользователя.

Наиболее надежный рубеж обороны — современная Content Security Policy (CSP) третьего поколения, построенная на концепции Strict CSP с использованием динамических одноразовых токенов (nonce).

Внедрение Strict CSP в приложениях с серверным рендерингом (React SSR, Next.js) сопряжено с практическими вызовами: генерация per-request токенов, разметка скриптов гидратации, работа со сторонней аналитикой и интеграция с CSS-in-JS библиотеками. Разберем архитектуру и пошаговую настройку этого механизма.


Почему allowlist-подход устарел и что такое Strict CSP

Долгое время стандартом настройки CSP было явное перечисление доверенных доменов (Allowlists) в директиве script-src:

Content-Security-Policy: script-src 'self' https://apis.google.com https://cdn.example.com;

Проблемы традиционных CSP на основе доменов

  1. JSONP-эндпоинты и открытые редиректы. Если разрешенный домен содержит уязвимый JSONP-эндпоинт или открытый редирект, злоумышленник может обойти ограничения и выполнить вредоносный код.
  2. Сложность поддержки. С развитием проекта список доменов разрастается до десятков позиций. Поддерживать его в актуальном состоянии без риска сломать рабочие интеграции становится сложно.
  3. Неэффективность против инъекций в инлайн-скрипты. Добавление директивы 'unsafe-inline' сводит на нет базовую защиту от XSS.

Преимущества Strict CSP на базе Nonce и 'strict-dynamic'

Современная модель безопасности опирается не на происхождение домена, а на криптографическое подтверждение подлинности конкретного исполняемого блока:

  • Сервер генерирует случайный криптографический токен (nonce) на каждый входящий HTTP-запрос.
  • Браузер выполняет только те скрипты, чей атрибут nonce="..." совпадает со значением в HTTP-заголовке Content-Security-Policy.
  • Директива 'strict-dynamic' указывает браузеру: скрипт, которому уже разрешено выполнение благодаря валидному nonce, имеет право динамически создавать и внедрять новые теги <script>. Браузер автоматически доверяет этим дочерним скриптам, избавляя разработчиков от необходимости вручную поддерживать список CDN и поддоменов загрузчиков.

Анатомия Nonce: требования безопасности и генерация

Nonce (number used once) — это одноразовый криптографический маркер. По спецификации W3C к генерации nonce предъявляются строгие требования:

  1. Криптостойкость: Генератор случайных чисел обязан быть криптографически стойким (CSPRNG). Использование псевдослучайных методов вроде Math.random() недопустимо, так как их вывод предсказуем.
  2. Энтропия и длина: Значение должно содержать не менее 128 бит энтропии (16 байт) и передаваться в кодировке Base64.
  3. Уникальность: Токен обязан генерироваться заново для каждого HTTP-ответа. Повторное использование токена нивелирует защиту.
Входящий запрос ──► [Сервер / Middleware] ──► Генерация Nonce (16+ байт CSPRNG)
                                             ├─► HTTP-заголовок: script-src 'nonce-XYZ'
                                             └─► HTML-документ:  <script nonce="XYZ">

Валидация браузером

Когда браузер получает HTML-документ:

  1. Считывает заголовок Content-Security-Policy: script-src 'nonce-8IBTHwOdqNKAWeKl7plt8g==' 'strict-dynamic'.
  2. Находит в DOM тег <script nonce="8IBTHwOdqNKAWeKl7plt8g==">.
  3. Сверяет токены. При полном совпадении скрипт выполняется.
  4. В целях безопасности браузер скрывает значение атрибута nonce из DOM API (element.getAttribute('nonce') возвращает пустую строку в современных браузерах), защищая токен от утечки через простые скриптовые инъекции.

Реализация Strict CSP в Next.js (App Router)

В Next.js App Router генерация CSP и nonce происходит на уровне Middleware. Nonce создается на каждый запрос и пробрасывается в заголовки для использования в Server Components.

Шаг 1. Генерация Nonce и установка заголовков в Middleware

Создайте файл middleware.ts в корне проекта:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  // Генерация 16 криптографически случайных байт через Web Crypto API
  const nonceBuffer = new Uint8Array(16);
  crypto.getRandomValues(nonceBuffer);
  const nonce = btoa(String.fromCharCode(...nonceBuffer));

  // Формирование Strict CSP
  const cspHeader = `
    default-src 'self';
    script-src 'nonce-${nonce}' 'strict-dynamic' 'self' ${
      process.env.NODE_ENV === 'production' ? '' : "'unsafe-eval'"
    };
    style-src 'self' 'nonce-${nonce}';
    img-src 'self' blob: data:;
    font-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
  `.replace(/\s{2,}/g, ' ').trim();

  // 1. Пробрасываем nonce и CSP во входящие заголовки запроса (для чтения в Layout)
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-nonce', nonce);
  requestHeaders.set('Content-Security-Policy', cspHeader);

  // 2. Устанавливаем CSP в заголовки ответа
  const response = NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });
  response.headers.set('Content-Security-Policy', cspHeader);

  return response;
}

export const config = {
  matcher: [
    /*
     * Применяем CSP ко всем маршрутам, исключая статические файлы и служебные пути:
     * - _next/static (статические бандлы)
     * - _next/image (оптимизация изображений)
     * - favicon.ico, sitemap.xml, robots.txt
     */
    {
      source: '/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
      missing: [
        { type: 'header', key: 'next-router-prefetch' },
        { type: 'header', key: 'purpose', value: 'prefetch' },
      ],
    },
  ],
};

Шаг 2. Чтение Nonce в Root Layout

Next.js App Router автоматически считывает заголовок x-nonce или CSP из входящих заголовков запроса и внедряет nonce во все внутренние скрипты сборки и гидратации.

В app/layout.tsx можно явно получить nonce для кастомных инлайн-скриптов:

// app/layout.tsx
import { headers } from 'next/headers';
import Script from 'next/script';

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const headersList = await headers();
  const nonce = headersList.get('x-nonce') ?? undefined;

  return (
    <html lang="ru">
      <head />
      <body>
        {children}
        
        {/* Сторонний скрипт с явной передачей nonce */}
        <Script
          id="custom-inline-script"
          strategy="afterInteractive"
          nonce={nonce}
          dangerouslySetInnerHTML={{
            __html: `console.log("Безопасный инлайн-скрипт выполнен");`,
          }}
        />
      </body>
    </html>
  );
}

Особенности реализации в Next.js Pages Router и кастомном React SSR

Next.js Pages Router (_document.tsx)

В Pages Router генерация CSP также выполняется в middleware.ts, а доступ к nonce в документе осуществляется через DocumentContext:

// pages/_document.tsx
import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document';

interface CustomDocumentProps {
  nonce?: string;
}

class MyDocument extends Document<CustomDocumentProps> {
  static async getInitialProps(ctx: DocumentContext) {
    const initialProps = await Document.getInitialProps(ctx);
    const nonce = ctx.req?.headers['x-nonce'] as string | undefined;
    return { ...initialProps, nonce };
  }

  render() {
    const { nonce } = this.props;
    return (
      <Html lang="ru">
        <Head nonce={nonce} />
        <body>
          <Main />
          <NextScript nonce={nonce} />
        </body>
      </Html>
    );
  }
}

export default MyDocument;

Кастомный React SSR (Express / renderToPipeableStream)

При создании собственного SSR-сервера на базе Express или Fastify токен генерируется средствами модуля node:crypto:

// server.ts (Express + React 18 SSR)
import express from 'express';
import crypto from 'node:crypto';
import React from 'react';
import { renderToPipeableStream } from 'react-dom/server';
import App from './src/App';

const app = express();

app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString('base64');
  res.locals.nonce = nonce;

  res.setHeader(
    'Content-Security-Policy',
    `default-src 'self'; script-src 'nonce-${nonce}' 'strict-dynamic'; style-src 'self' 'nonce-${nonce}';`
  );
  next();
});

app.get('*', (req, res) => {
  const nonce = res.locals.nonce;

  const { pipe } = renderToPipeableStream(
    <App nonce={nonce} />,
    {
      nonce,
      bootstrapScripts: ['/static/bundle.js'],
      onShellReady() {
        res.setHeader('content-type', 'text/html');
        pipe(res);
      },
    }
  );
});

Сложные кейсы: стили, CSS-in-JS и сторонние скрипты

Ограничения директивы style-src

Для директивы style-src спецификация CSP не поддерживает механизм 'strict-dynamic'. Это накладывает определенные ограничения:

  • Если библиотека создает стили через document.createElement('style'), каждому такому тегу необходим атрибут nonce.
  • Если стили изменяются через инлайновые атрибуты (<div style="...">), nonce на них не распространяется. Для поддержки таких конструкций применяют директиву 'unsafe-hashes' либо оставляют 'unsafe-inline' исключительно для стилей (риск XSS при этом несоизмеримо ниже, чем при использовании 'unsafe-inline' для скриптов).

Поддержка Nonce в Emotion и Styled Components

CSS-in-JS библиотеки поддерживают внедрение nonce через создание кастомного кэша и React Context:

// StyleProvider.tsx (Emotion CacheProvider с Nonce)
import { CacheProvider } from '@emotion/react';
import createCache from '@emotion/cache';

export function StyleProvider({ nonce, children }: { nonce: string; children: React.ReactNode }) {
  const cache = createCache({
    key: 'custom-css',
    nonce: nonce,
  });

  return <CacheProvider value={cache}>{children}</CacheProvider>;
}

Google Tag Manager (GTM) и аналитика

При включенной директиве 'strict-dynamic' основной загрузчик GTM должен содержать валидный nonce:

<!-- Инициализация GTM -->
<script nonce="ВАШ_NONCE">
  (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
  new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
  j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
  'https://www.googletagmanager.com/gtm.js?id='+i+dl;var n=d.querySelector('[nonce]');
  n&&j.setAttribute('nonce',n.nonce||n.getAttribute('nonce'));f.parentNode.insertBefore(j,f);
  })(window,document,'script','dataLayer','GTM-XXXXXX');
</script>

Благодаря 'strict-dynamic' все скрипты, которые GTM создает через document.createElement('script'), автоматически считаются доверенными.


Nonce и кэширование: SSR vs SSG vs Edge

Использование динамического токена определяет стратегию кэширования HTML-страниц:

Архитектура рендеринга Совместимость с Nonce Решение для CSP
Node.js SSR / React Server Components Полная Генерация nonce на каждый входящий запрос в Middleware
Edge SSR (Cloudflare Workers, Vercel Edge) Полная Генерация токена через Web Crypto API на Edge-узле
Static Site Generation (SSG / output: 'export') Несовместимо с per-request nonce Использование Hash-based CSP (SHA-256) или трансформация HTML на Edge-прокси

Важно: Не кэшируйте HTML-страницы целиком в CDN, Varnish или Nginx, если в разметку зашит nonce. Кэширование ответа с фиксированным токеном нарушает принцип однократности использования и открывает вектор для replay-атак.

Для статически сгенерированных страниц (SSG) вместо nonce вычисляются хэши инлайн-скриптов на этапе сборки:

Content-Security-Policy: script-src 'sha256-RFW6UpviWKmGyEl3QWDbRCWMrPoxDL39eqNTBQoX+Eg=' https://static.domain.com;

Тестирование, отладка и режим Content-Security-Policy-Report-Only

Внедрение строгой CSP в рабочий продакшен без предварительного сбора отчетов может нарушить работу клиентских скриптов. Для безопасного раскатывания используется режим сбора нарушений:

Content-Security-Policy-Report-Only: 
  default-src 'self';
  script-src 'nonce-8IBTHwOdqNKAWeKl7plt8g==' 'strict-dynamic';
  report-uri /api/csp-violations;

В этом режиме браузер не блокирует выполнение ресурсов, но отправляет POST-запрос с JSON-описанием каждого зафиксированного нарушения на указанный эндпоинт.

Чек-лист поэтапного внедрения:

  1. Аудит зависимостей: Проверьте все внешние скрипты и виджеты на совместимость с динамической загрузкой.
  2. Включение Report-Only: Разверните политику в режиме Content-Security-Policy-Report-Only на 1–2 недели.
  3. Анализ логов: Устраните зафиксированные ошибки (пропущенные атрибуты nonce в динамических вставках, устаревшие инлайн-обработчики onclick).
  4. Переключение на Enforcement: Переведите заголовок в активный режим Content-Security-Policy.

Часто задаваемые вопросы (FAQ)

1. Можно ли использовать один и тот же nonce для script-src и style-src?

Да. В рамках обработки одного HTTP-запроса допускается использовать одинаковый токен как для script-src, так и для style-src. Главное условие — генерация нового значения для следующего входящего запроса.

2. Почему нельзя использовать Math.random() для генерации nonce?

Math.random() не является криптографически стойким генератором (CSPRNG). Его внутреннее состояние можно восстановить по предыдущим значениям, что позволяет злоумышленнику вычислить следующий nonce. Используйте crypto.randomBytes (Node.js) или crypto.getRandomValues (Web Crypto API).

3. Что делать, если проект собирается через статический экспорт (output: 'export')?

При статическом экспорте сервер отсутствует, поэтому сформировать per-request nonce в рантайме невозможно. В этом случае используют:

  • Вычисление SHA-256 хэшей для всех инлайн-скриптов во время сборки.
  • Подстановку CSP и замену nonce «на лету» через Edge-прокси (Cloudflare Workers или Lambda@Edge).

4. Как работают сторонние счетчики при включенном 'strict-dynamic'?

Если основной скрипт инициализации загружается с валидным nonce, директива 'strict-dynamic' разрешает ему создавать новые теги <script> и загружать вспомогательные бандлы без явного перечисления их доменов в CSP.

5. В чем разница между передачей CSP через HTTP-заголовок и тег <meta>?

HTTP-заголовок поддерживает полный набор возможностей спецификации. Мета-тег <meta http-equiv="Content-Security-Policy"> имеет жесткие ограничения: он не поддерживает директивы frame-ancestors, report-uri, sandbox, а также не может корректно обрабатывать per-request nonce при кэшировании HTML-разметки.


Заключение

Переход на Strict CSP с механизмом per-request nonce и директивой 'strict-dynamic' избавляет от необходимости поддерживать громоздкие списки доменов и обеспечивает устойчивую защиту от XSS на уровне современных веб-стандартов.

В Next.js и React SSR процесс внедрения строится на правильной организации Middleware, связывании Web Crypto API с жизненным циклом рендеринга и корректной настройке стилей. Такой подход существенно повышает безопасность веб-приложения без усложнения кодовой базы.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также