{ "index": 3, "slug": "editorial-2027-12-practice-author-manifesto", "title": "Security headers: CSP и HSTS без иллюзии защиты", "excerpt": "Как CSP и HSTS ограничивают браузер, почему nonce и HTTPS не заменяют исправление XSS и как вводить политики с проверяемым откатом.", "contentHtml": "
Сбой обычно замечают в двух местах: DevTools показывает нарушение Content Security Policy (CSP), а переход на http:// сначала попадает на редирект вместо немедленной замены схемы. Если в этот момент просто добавить длинную строку заголовков, приложение может продолжить выполнять инъецированный код, а первый HTTP-запрос всё ещё может уйти без шифрования. Цена ошибки — украденная сессия, отправленная форма или недоступный поддомен, а не «потерянный зелёный чек».
Главный вопрос не в том, какой шаблон заголовка скопировать, а в том, какую границу он действительно проводит. CSP ограничивает ресурсы и выполнение внутри страницы. HSTS учит браузер обращаться к уже известному хосту только по HTTPS. Ни один из них не исправляет XSS, не выдаёт сертификат и не делает безопасным секрет, который уже попал в JavaScript. Значит, внедрение нужно вести как проверяемое изменение поведения браузера: симптом → причина → отрицательный тест → откат.
\nCSP приходит в ответе страницы и применяется к её документу или связанному worker-контексту. Браузер сопоставляет попытку загрузки или выполнения с директивой: script-src отвечает за JavaScript, connect-src — за fetch/XHR и другие соединения, img-src — за изображения, а default-src задаёт запасное правило там, где нет более узкой директивы. object-src 'none' закрывает plugin-контент, если он приложению не нужен; base-uri 'self' ограничивает адреса, допустимые в элементе base.
HSTS работает иначе. После корректного заголовка Strict-Transport-Security, полученного по TLS без ошибки, браузер сохраняет хост как известный HSTS-хост на срок max-age. При следующем обращении к нему по HTTP браузер сам меняет схему на HTTPS. Заголовок, пришедший по обычному HTTP, браузер обязан проигнорировать. Поэтому серверный redirect нужен для первого контакта и старых клиентов, но не заменяет HSTS для клиента, который ещё не знает домен.
| Слой | Действие браузера | Чего не делает | Проверка и владелец |
|---|---|---|---|
| CSP | Блокирует ресурс или выполнение, не совпавшее с политикой; может только отправить отчёт | Не санитизирует HTML и не исправляет небезопасный innerHTML | Негативные сценарии страницы; владелец приложения |
| HSTS | Для известного хоста заменяет HTTP на HTTPS и требует успешный TLS | Не защищает первый HTTP-переход и не чинит сертификаты | HTTPS-ответы и каждый поддомен; владелец платформы |
| Redirect | Получает ответ сервера с новой схемой | Не скрывает исходный HTTP-запрос от сетевого посредника | Цепочка 3xx и канонический URL; владелец edge |
| Исправление XSS | Убирает исполняемый ввод из приложения | Не заменяется заголовком | Контекстное экранирование и безопасные API; владелец кода |
Начните с одной страницы и составьте список фактических источников: скрипты, inline-блоки, стили, шрифты, изображения, API, iframe, service worker и plugin-контент. Затем выражайте этот список в директивах. Разрешение https: или широкого CDN может убрать сообщения в консоли, но одновременно разрешить больше серверов, чем нужно странице. Узкий список — не самоцель: каждую внешнюю зависимость нужно связать с конкретной загрузкой и владельцем.
Content-Security-Policy-Report-Only позволяет сначала наблюдать нарушения без блокировки. Это полезный этап инвентаризации, но не защита: сломанный или вредоносный inline-скрипт продолжит выполняться. После разбора отчётов ту же политику переводят в Content-Security-Policy. Проверка должна включать отрицательный путь — например, попытку загрузить https://unknown.example/evil.js — иначе успешная загрузка штатного bundle ничего не доказывает.
Nonce — исключение для конкретного inline-скрипта. Сервер создаёт новое случайное значение для каждого ответа, помещает его в script-src и в атрибут nonce нужного элемента. CSP Level 3 требует уникальное значение; спецификация рекомендует не менее 128 бит до кодирования и криптографически стойкий генератор. Статическая строка в конфигурации нарушает эту модель: её можно повторно использовать и предсказать.
Nonce разрешает inline-скрипт, но не inline-обработчик события вроде onclick и не произвольный HTML. Если пользовательская строка попала в DOM через небезопасный sink, атакующий может использовать разрешённый bootstrap или другой разрешённый путь. Поэтому исправление XSS, контекстное экранирование и безопасные DOM-API остаются первым слоем, а CSP — ограничителем последствий.
Ниже — учебный файл security-headers.mjs. Он запускается в Node.js без сторонних пакетов, создаёт nonce и проверяет связь между политикой и HTML. Значения img-src, connect-src, frame-ancestors и годовой max-age — проектный пример, а не готовая политика для любого сайта.
import { randomBytes } from 'node:crypto';\n\nfunction buildSecurityHeaders() {\n const nonce = randomBytes(16).toString('base64');\n const contentSecurityPolicy = [\n \"default-src 'self'\",\n \"base-uri 'self'\",\n \"object-src 'none'\",\n \"script-src 'self' 'nonce-\" + nonce + \"'\",\n \"style-src 'self'\",\n \"img-src 'self' data:\",\n \"connect-src 'self'\",\n \"frame-ancestors 'none'\",\n \"form-action 'self'\",\n ].join('; ');\n\n return {\n nonce,\n headers: {\n 'Content-Security-Policy': contentSecurityPolicy,\n 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',\n },\n };\n}\n\nconst { nonce, headers } = buildSecurityHeaders();\nconst bootstrap = \"<script nonce='\" + nonce + \"'>startApplication();</script>\";\nconst csp = headers['Content-Security-Policy'];\nconst nonceSource = \"'nonce-\" + nonce + \"'\";\n\nif (!csp.includes(nonceSource)) throw new Error('nonce is absent from CSP');\nif (!bootstrap.includes(\"nonce='\" + nonce + \"'\")) throw new Error('nonce is absent from HTML');\nif (csp.includes(\"'unsafe-inline'\")) throw new Error('broad inline permission detected');\n\nconsole.log({ headers, bootstrap });\nЗапуск node security-headers.mjs должен вывести два заголовка и один bootstrap с одинаковым nonce. Это проверяет только сборку значения. Серверный адаптер всё ещё обязан отправить заголовки именно в HTTPS-ответе HTML и передать тот же nonce шаблону. При кэшировании страницы нужно проверить, что HTML и CSP не разъезжаются: сохранённый HTML со старым nonce при новом заголовке будет заблокирован, а повторно использованный nonce ослабит модель.
Для страницы с внешним CDN добавьте конкретный origin только после проверки реального запроса, например https://cdn.example/asset.js, и повторите отрицательный тест. Не добавляйте 'unsafe-inline' или 'unsafe-eval' ради исчезновения одной ошибки сборки: сначала выясните, какой код создаёт inline или динамическое выполнение. Если библиотека требует это исключение, запишите риск, владельца и срок удаления рядом с конфигурацией.
У HSTS есть неудобная граница: политика появляется только после доверенного HTTPS-ответа, если браузер заранее не знает домен из своего preload-списка. Пользователь, впервые открывший http://example.test, сначала зависит от сети и server redirect. Посредник может изменить этот первый запрос или ответ до того, как браузер узнает о HSTS. Поэтому порядок такой: исправить TLS, включить редирект на HTTPS, проверить сертификат и только затем отдать HSTS в HTTPS-ответе.
includeSubDomains расширяет политику на все поддомены. Это не декоративный флаг: отдельный API, старый кабинет, health endpoint или внешний инструмент может перестать открываться, если он не готов к HTTPS или имеет другой сертификат. Сначала соберите список имён и владельцев, затем проверьте их вручную и автоматикой. В учебном примере выше включение флага — осознанное значение для домена, где такая инвентаризация уже пройдена.
max-age=0 позволяет сообщить браузеру по HTTPS, что политика хоста больше не должна считаться действующей. Это не мгновенный глобальный откат: клиент должен снова успешно соединиться по HTTPS, а другие клиенты могли ещё не получить новое значение. Чем дольше срок и шире область, тем дороже ошибка конфигурации. HSTS нельзя использовать как замену управляемому rollout и плану восстановления.
eval, iframe, worker, CDN и кэш.curl -sS -D - -o /dev/null https://example.test/ и убедитесь, что HTTPS-ответ содержит ровно одну CSP и один HSTS. Для http:// проверьте код и Location; не принимайте redirect за доказательство HSTS.includeSubDomains проверьте сертификат, HTTPS-ответ, API-клиента и административные пути. Если список неполон, начните с области без флага и не расширяйте её по привычке.unsafe-inline/unsafe-eval. В релизном плане укажите владельца, наблюдаемость нарушений и восстановление через версионируемую конфигурацию.Эта статья не обещает, что два заголовка закрывают всю модель угроз. CSP зависит от того, какие контексты и браузеры поддерживает продукт, как CDN переписывает ответы, где рендерится HTML и какие сервисы загружаются после навигации. Worker, iframe, кэш, service worker, API CORS и сертификаты нужно проверять отдельно. В частности, HSTS не заменяет настройку TLS, а CSP не контролирует логику сервера или уже украденный токен.
\nВ указанной датированной редакции W3C CSP Level 3 имеет статус Working Draft, поэтому директиву, поддержку браузеров и поведение конкретной версии следует сверять перед релизом. RFC 6797 — нормативная спецификация HSTS, но она не превращает первый HTTP-переход в защищённый. Если проект использует preload-список, рассматривайте его как отдельный операционный процесс с собственными условиями и стоимостью вывода домена.
\nГотовность можно доказать коротким пакетом: HTML-ответ содержит согласованные CSP и HSTS по HTTPS; Report-Only нарушения разобраны; enforce блокирует искусственно добавленный неизвестный ресурс; штатный bootstrap проходит с новым nonce; каждый поддомен прошёл TLS-проверку; CI ловит ослабление политики. Если хотя бы один пункт неизвестен, безопаснее оставить конкретную границу в ограничении и назначить следующий тест, чем назвать заголовок защитой.
\nmax-age, includeSubDomains и обязательное игнорирование STS-заголовка, пришедшего по незашифрованному соединению.