{ "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-запрос всё ещё может уйти без шифрования. Цена ошибки — украденная сессия, отправленная форма или недоступный поддомен, а не «потерянный зелёный чек».

\n

Главный вопрос не в том, какой шаблон заголовка скопировать, а в том, какую границу он действительно проводит. CSP ограничивает ресурсы и выполнение внутри страницы. HSTS учит браузер обращаться к уже известному хосту только по HTTPS. Ни один из них не исправляет XSS, не выдаёт сертификат и не делает безопасным секрет, который уже попал в JavaScript. Значит, внедрение нужно вести как проверяемое изменение поведения браузера: симптом → причина → отрицательный тест → откат.

\n

Две политики, два владельца

\n

CSP приходит в ответе страницы и применяется к её документу или связанному worker-контексту. Браузер сопоставляет попытку загрузки или выполнения с директивой: script-src отвечает за JavaScript, connect-src — за fetch/XHR и другие соединения, img-src — за изображения, а default-src задаёт запасное правило там, где нет более узкой директивы. object-src 'none' закрывает plugin-контент, если он приложению не нужен; base-uri 'self' ограничивает адреса, допустимые в элементе base.

\n

HSTS работает иначе. После корректного заголовка Strict-Transport-Security, полученного по TLS без ошибки, браузер сохраняет хост как известный HSTS-хост на срок max-age. При следующем обращении к нему по HTTP браузер сам меняет схему на HTTPS. Заголовок, пришедший по обычному HTTP, браузер обязан проигнорировать. Поэтому серверный redirect нужен для первого контакта и старых клиентов, но не заменяет HSTS для клиента, который ещё не знает домен.

\n
Что меняется после включения и кто должен это доказать
СлойДействие браузераЧего не делаетПроверка и владелец
CSPБлокирует ресурс или выполнение, не совпавшее с политикой; может только отправить отчётНе санитизирует HTML и не исправляет небезопасный innerHTMLНегативные сценарии страницы; владелец приложения
HSTSДля известного хоста заменяет HTTP на HTTPS и требует успешный TLSНе защищает первый HTTP-переход и не чинит сертификатыHTTPS-ответы и каждый поддомен; владелец платформы
RedirectПолучает ответ сервера с новой схемойНе скрывает исходный HTTP-запрос от сетевого посредникаЦепочка 3xx и канонический URL; владелец edge
Исправление XSSУбирает исполняемый ввод из приложенияНе заменяется заголовкомКонтекстное экранирование и безопасные API; владелец кода
\n
Схема двух независимых слоёв: карта ресурсов ведёт к CSP, HTTPS-ответ ведёт к HSTS, неизвестный ресурс блокируется или попадает в отчёт
Карта ресурсов и HTTPS-ответ входят в разные ветки. Их нельзя свести к одной «строке безопасности».
\n

Как CSP создаёт барьер, а не иллюзию

\n

Начните с одной страницы и составьте список фактических источников: скрипты, inline-блоки, стили, шрифты, изображения, API, iframe, service worker и plugin-контент. Затем выражайте этот список в директивах. Разрешение https: или широкого CDN может убрать сообщения в консоли, но одновременно разрешить больше серверов, чем нужно странице. Узкий список — не самоцель: каждую внешнюю зависимость нужно связать с конкретной загрузкой и владельцем.

\n

Content-Security-Policy-Report-Only позволяет сначала наблюдать нарушения без блокировки. Это полезный этап инвентаризации, но не защита: сломанный или вредоносный inline-скрипт продолжит выполняться. После разбора отчётов ту же политику переводят в Content-Security-Policy. Проверка должна включать отрицательный путь — например, попытку загрузить https://unknown.example/evil.js — иначе успешная загрузка штатного bundle ничего не доказывает.

\n

Nonce — исключение для конкретного inline-скрипта. Сервер создаёт новое случайное значение для каждого ответа, помещает его в script-src и в атрибут nonce нужного элемента. CSP Level 3 требует уникальное значение; спецификация рекомендует не менее 128 бит до кодирования и криптографически стойкий генератор. Статическая строка в конфигурации нарушает эту модель: её можно повторно использовать и предсказать.

\n

Nonce разрешает inline-скрипт, но не inline-обработчик события вроде onclick и не произвольный HTML. Если пользовательская строка попала в DOM через небезопасный sink, атакующий может использовать разрешённый bootstrap или другой разрешённый путь. Поэтому исправление XSS, контекстное экранирование и безопасные DOM-API остаются первым слоем, а CSP — ограничителем последствий.

\n

Самодостаточный пример заголовков

\n

Ниже — учебный файл security-headers.mjs. Он запускается в Node.js без сторонних пакетов, создаёт nonce и проверяет связь между политикой и HTML. Значения img-src, connect-src, frame-ancestors и годовой max-age — проектный пример, а не готовая политика для любого сайта.

\n
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 ослабит модель.

\n

Для страницы с внешним CDN добавьте конкретный origin только после проверки реального запроса, например https://cdn.example/asset.js, и повторите отрицательный тест. Не добавляйте 'unsafe-inline' или 'unsafe-eval' ради исчезновения одной ошибки сборки: сначала выясните, какой код создаёт inline или динамическое выполнение. Если библиотека требует это исключение, запишите риск, владельца и срок удаления рядом с конфигурацией.

\n

HSTS: что происходит до и после первого посещения

\n

У HSTS есть неудобная граница: политика появляется только после доверенного HTTPS-ответа, если браузер заранее не знает домен из своего preload-списка. Пользователь, впервые открывший http://example.test, сначала зависит от сети и server redirect. Посредник может изменить этот первый запрос или ответ до того, как браузер узнает о HSTS. Поэтому порядок такой: исправить TLS, включить редирект на HTTPS, проверить сертификат и только затем отдать HSTS в HTTPS-ответе.

\n

includeSubDomains расширяет политику на все поддомены. Это не декоративный флаг: отдельный API, старый кабинет, health endpoint или внешний инструмент может перестать открываться, если он не готов к HTTPS или имеет другой сертификат. Сначала соберите список имён и владельцев, затем проверьте их вручную и автоматикой. В учебном примере выше включение флага — осознанное значение для домена, где такая инвентаризация уже пройдена.

\n

max-age=0 позволяет сообщить браузеру по HTTPS, что политика хоста больше не должна считаться действующей. Это не мгновенный глобальный откат: клиент должен снова успешно соединиться по HTTPS, а другие клиенты могли ещё не получить новое значение. Чем дольше срок и шире область, тем дороже ошибка конфигурации. HSTS нельзя использовать как замену управляемому rollout и плану восстановления.

\n

Runbook перед переводом в enforce

\n
  1. Зафиксировать границы. Выпишите страницу, её домен, поддомены, типы ресурсов и владельцев. Отдельно отметьте inline-код, динамический eval, iframe, worker, CDN и кэш.
  2. Проверить TLS и redirect. Выполните curl -sS -D - -o /dev/null https://example.test/ и убедитесь, что HTTPS-ответ содержит ровно одну CSP и один HSTS. Для http:// проверьте код и Location; не принимайте redirect за доказательство HSTS.
  3. Собрать нарушения. Отправьте CSP в Report-Only на одной странице, откройте штатные сценарии и сгруппируйте сообщения по директиве, URL и типу ресурса. Фильтруйте шум, но не удаляйте повторяемые нарушения без объяснения.
  4. Сузить политику. Вынесите inline-код в файл или выдайте ему свежий nonce. Удалите широкие источники и лишние исключения. Для каждого оставшегося origin укажите запрос, владельца и тест.
  5. Проверить блокировку. Переведите CSP в enforce на тестовом маршруте. Убедитесь, что штатный сценарий работает, неизвестный script блокируется, запрещённый frame не встраивает страницу, а форма не уходит на непредусмотренный origin.
  6. Проверить поддомены. Для каждого имени под includeSubDomains проверьте сертификат, HTTPS-ответ, API-клиента и административные пути. Если список неполон, начните с области без флага и не расширяйте её по привычке.
  7. Закрепить контроль. Добавьте в CI проверку обязательных директив и отсутствия случайных unsafe-inline/unsafe-eval. В релизном плане укажите владельца, наблюдаемость нарушений и восстановление через версионируемую конфигурацию.
\n

Ограничения и критерий готовности

\n

Эта статья не обещает, что два заголовка закрывают всю модель угроз. 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 ловит ослабление политики. Если хотя бы один пункт неизвестен, безопаснее оставить конкретную границу в ограничении и назначить следующий тест, чем назвать заголовок защитой.

\n

Проверяемые источники

\n" }