Заголовки безопасности: CSP, HSTS и всё остальное
Практическое руководство по CSP с nonce и strict-dynamic, HSTS и preload, frame-ancestors, Referrer-Policy, Permissions-Policy и COOP с конфигурацией Next.js.
· Чтение: 7 мин · Lina Source LLC
Заголовки безопасности — это инструкции, которые ваш сервер даёт браузеру: выполняй только эти скрипты, общайся со мной только по HTTPS, не позволяй другим сайтам встраивать эту страницу во фрейм. Они не исправляют ошибки в вашем коде, но ограничивают то, что атакующий сможет с их помощью сделать. Дыра с межсайтовым скриптингом за строгой Content Security Policy — куда меньшая проблема, чем та же дыра без неё.
Большинство этих заголовков — одна строка конфигурации. Исключение — CSP, которая требует планирования. Это руководство объясняет, что делает каждый заголовок, какие значения разумны для типичного веб-приложения и как их выкатить, не сломав продакшен.
Content-Security-Policy
CSP сообщает браузеру, какие источники скриптов, стилей, изображений, фреймов и соединений разрешены. Её главная задача — не дать выполниться внедрённым скриптам. Список разрешённых доменов кажется очевидным подходом, но у него длинная история обходов: любой разрешённый CDN, где хранится пользовательский контент или старые версии библиотек, можно использовать для загрузки подконтрольного атакующему скрипта.
Nonce и strict-dynamic
Подход, который выдерживает проверку, — политика на основе nonce. Сервер генерирует случайное значение для каждого ответа, помещает его в заголовок CSP и добавляет атрибутом nonce к каждому тегу script, который отрисовывает. Внедрённые теги script не знают nonce и блокируются. Директива 'strict-dynamic' позволяет скриптам, загруженным доверенным скриптом, загружать следующие скрипты, благодаря чему сборщики и менеджеры тегов продолжают работать без перечисления всех доменов.
Content-Security-Policy:
default-src 'self';
script-src 'nonce-4AEemGb0xJptoIGFP3Nd' 'strict-dynamic';
style-src 'self' 'nonce-4AEemGb0xJptoIGFP3Nd';
img-src 'self' data: https:;
connect-src 'self';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
form-action 'self';
upgrade-insecure-requestsЗаголовок отправляется одной строкой; здесь он перенесён для удобства чтения. Несколько директив в этой политике делают больше, чем кажется. object-src 'none' блокирует устаревший плагинный контент. base-uri 'none' не даёт внедрённому тегу base перенаправить относительные URL скриптов. form-action 'self' не позволяет внедрённым формам отправлять учётные данные на чужой адрес. Nonce должен быть непредсказуемым и новым в каждом ответе, а значит, страницы с ним нельзя отдавать из статического кеша.
Выкатывайте через Report-Only
Строгая CSP, развёрнутая вслепую, что-нибудь да сломает: сниппет аналитики, встроенный обработчик события, сторонний виджет. Сначала выкатите политику как Content-Security-Policy-Report-Only. Браузер ничего не применяет, но сообщает о каждом нарушении на эндпоинт, указанный в директиве report-to или report-uri. Собирайте отчёты какое-то время, исправьте или разрешите то, что легитимно, а затем поменяйте имя заголовка на применяющее. Оставьте отчётность включённой и после этого: новые нарушения — это либо регрессии, либо атаки. Ожидайте шума в отчётах от браузерных расширений, которые внедряют в страницы свои скрипты; отфильтруйте их по источнику, прежде чем решать, что разрешать.
Strict-Transport-Security
HSTS говорит браузеру использовать HTTPS для вашего домена в течение заданного срока, даже если пользователь наберёт http:// или перейдёт по старой ссылке. Это закрывает окно, в котором сетевой атакующий мог бы перехватить первый обычный HTTP-запрос и убрать редирект на HTTPS. Браузеры учитывают этот заголовок, только если он пришёл по HTTPS, и они также не дают пользователям проигнорировать ошибки сертификата на HSTS-домене — в этом и смысл, но в этом же и риск, если срок действия сертификата истечёт.
Начните с короткого max-age, например в сутки, убедитесь, что ничего не сломалось, а затем поднимите его до года или двух. includeSubDomains распространяет правило на все поддомены, поэтому сначала проверьте, что все они отдают корректный HTTPS, включая старые маркетинговые сайты и внутренние инструменты на том же домене.
Директива preload вместе с заявкой в браузерный список предзагрузки зашивает ваш домен в браузеры, так что даже самый первый визит идёт по HTTPS. Она требует max-age не меньше года и includeSubDomains. Относитесь к ней как к двери в одну сторону: удаление из списка возможно, но до пользователей доходит долго, а любой поддомен, который не умеет HTTPS, станет тем временем недоступен.
Заголовки в одну строку
- X-Content-Type-Options: nosniff. Запрещает браузерам угадывать тип содержимого, отличный от объявленного вами, что не даёт исполнить как скрипт загруженный файл, отданный как текст.
- frame-ancestors (в CSP) и X-Frame-Options: DENY. Определяют, кто может встраивать ваши страницы во фрейм; это защита от кликджекинга. Современные браузеры используют frame-ancestors и игнорируют X-Frame-Options, когда заданы оба; сохраните X-Frame-Options для старых клиентов. Используйте 'self' или SAMEORIGIN, если встраиваете собственные страницы.
- Referrer-Policy: strict-origin-when-cross-origin. Отправляет другим сайтам только ваш origin, без полного пути и строки запроса. Это не даёт токенам и идентификаторам из URL утекать к третьим сторонам. Для особенно чувствительных страниц используйте no-referrer.
- Permissions-Policy. Отключает возможности браузера, которыми вы не пользуетесь, — camera=(), microphone=(), geolocation=() и payment=(), — чтобы внедрённый или встроенный код не мог их запросить.
- Cross-Origin-Opener-Policy: same-origin. Помещает вашу страницу в собственную группу контекстов просмотра, так что окно, открытое другим сайтом, не сможет сохранить ссылку на неё. Используйте same-origin-allow-popups, если вы полагаетесь на всплывающие окна OAuth или оплаты.
- Cross-Origin-Resource-Policy: same-origin или same-site. Запрещает браузерам загружать ваши ответы из других источников как изображения, скрипты и прочие подресурсы. Используйте same-site, если отдаёте ассеты с соседнего поддомена, и cross-origin — для ассетов, предназначенных для встраивания на других сайтах.
От X-XSS-Protection можно отказаться. Фильтр, которым он управлял, удалён из современных браузеров, а замена ему — CSP. Если сканер настаивает, установите его в 0. Точно так же избегайте заголовков, которые лишь добавляют информации атакующему: X-Powered-By и подробные баннеры Server бесплатно раскрывают ваш фреймворк и его версию. В Next.js установите poweredByHeader: false в конфигурации, чтобы убрать первый из них.
Конфигурация для Next.js
Статические заголовки живут в next.config.ts, где функция headers() применяет их ко всем маршрутам. Значения ниже — разумный вариант по умолчанию для приложения, которое не встраивает себя во фреймы, не использует камеру и геолокацию и отдаёт собственные ассеты. Подстройте каждое значение под то, что ваше приложение действительно делает, а не копируйте их вслепую.
// next.config.ts
import type { NextConfig } from "next";
const securityHeaders = [
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains",
},
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), payment=()",
},
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Resource-Policy", value: "same-origin" },
];
const nextConfig: NextConfig = {
async headers() {
return [{ source: "/(.*)", headers: securityHeaders }];
},
};
export default nextConfig;CSP нужен свежий nonce на каждый запрос, поэтому она задаётся в middleware. Next.js читает nonce из заголовка Content-Security-Policy запроса и применяет его к собственным скриптам фреймворка при рендеринге. В серверном компоненте вы можете получить его через headers().get(«x-nonce») для своих тегов script.
// middleware.ts (в Next.js 16 файл называется proxy.ts, а функция — proxy)
import { NextResponse, type NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const csp = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'nonce-${nonce}'`,
"img-src 'self' blob: data:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"upgrade-insecure-requests",
].join("; ");
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", csp);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy", csp);
return response;
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};Страницы, отрисованные с nonce, должны рендериться динамически: статическая страница использовала бы один и тот же nonce для всех посетителей. На время выката поменяйте имя заголовка в обоих местах на Content-Security-Policy-Report-Only и добавьте эндпоинт для отчётов. В разработке React может потребовать 'unsafe-eval' в script-src для своих отладочных возможностей; добавляйте его только когда NODE_ENV равен development.
Как проверить
- Выполните curl -sI https://your-domain.example/ для главной страницы, динамической страницы, маршрута API и статического ассета и прочитайте заголовки в каждом случае. Заголовки, установленные только на HTML-страницах, — частый пробел.
- Откройте инструменты разработчика в браузере. Нарушения CSP появляются в консоли с указанием директивы и заблокированного ресурса, а вкладка Network показывает фактически отданные заголовки.
- Проверяйте и за CDN или обратным прокси, и на самом origin. Прокси иногда вырезают, дублируют или переопределяют заголовки, а два конфликтующих заголовка CSP применяются оба, поэтому итоговая политика строже каждого из них.
- Воспользуйтесь публичным чекером вроде Mozilla HTTP Observatory для быстрого второго мнения и CSP Evaluator от Google, чтобы найти слабые директивы CSP.
- Добавьте в CI тест, который запрашивает ключевые маршруты и проверяет наличие заголовков, чтобы рефакторинг конфигурации не смог молча их убрать.
Заголовки — это конфигурация, и именно поэтому они расползаются: новый обработчик маршрута, который сам собирает ответ, правило прокси, переопределяющее умолчания, CSP, ослабленная 'unsafe-inline' ради выпуска релиза. Рецензируйте их как код. CodeAuditAgent читает конфигурацию в публичном репозитории или вставленном фрагменте наравне с остальным кодом и сообщает об отсутствующих или ослабленных заголовках с указанием критичности, цитатой конфигурации и предлагаемым исправлением.
Разумный порядок работ: заголовки в одну строку — сегодня, HSTS с коротким max-age — на этой неделе, а CSP на основе nonce в режиме Report-Only — как только сможете собирать отчёты.