Заголовки безопасности — дешёвый и при этом недооценённый слой защиты веб-приложения. Они не заменяют валидацию ввода и экранирование вывода, но заметно сужают возможности атакующего: ограничивают источники скриптов, запрещают встраивание сайта в чужой фрейм, отключают угадывание MIME-типа и заставляют браузер работать только по HTTPS. Ниже — практическая настройка CSP и HSTS в nginx и Apache, диагностика через curl и разбор ошибок, которые ломают сайт при неаккуратном внедрении.
Где задавать заголовки
Заголовки можно отдавать из веб-сервера, из приложения или из CDN. Надёжнее всего — на границе, где завершается TLS: тогда одинаковые правила получают все бэкенды. Но CSP часто требует знания конкретных скриптов страницы, поэтому её нередко формирует приложение. Практичный компромисс: статические заголовки в nginx, а CSP — в приложении или через include с общим шаблоном.
curl -sI https://example.com | grep -iE 'strict|content-security|x-content|referrer'
nginx -T | grep -n add_header
apachectl -S
curl -sI https://example.com | wc -l
Важно: директива add_header в nginx не наследуется во вложенные location, если там объявлен свой add_header. Это классическая причина, по которой часть страниц остаётся без защиты. Проверяйте итоговый ответ, а не только конфиг.
CSP: как ограничить источники
Content-Security-Policy перечисляет, откуда браузеру разрешено загружать скрипты, стили, шрифты, изображения и фреймы. Директива default-src задаёт правило по умолчанию, остальные её уточняют. Ключевой момент — script-src: ‘self’ разрешает только свои файлы, ‘unsafe-inline’ разрешает встроенные скрипты и обработчики и тем самым почти обнуляет защиту от XSS.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-$request_id'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
С nonce сервер подставляет в каждый ответ случайное значение и то же значение в атрибут script. В nginx конструкция с переменной из $request_id работает не во всех сборках, поэтому чаще nonce генерирует приложение. Встроенные обработчики вида onclick при этом перестают работать — их нужно переносить в отдельные файлы.
Начинайте с Content-Security-Policy-Report-Only: браузер ничего не блокирует, но отправляет отчёты о нарушениях. Укажите report-uri или report-to и соберите статистику за несколько дней. Только после того, как отчёты опустеют, переключайте заголовок в боевой режим.
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; report-uri /csp-report" always;
grep -c 'csp-report' /var/log/nginx/access.log
curl -sI https://example.com | grep -i content-security
sudo tail -f /var/log/nginx/error.log
Отчёты содержат URL страницы и заблокированный ресурс. Не храните их бессрочно и не смешивайте с персональными данными: достаточно счётчиков по директивам. Если сайт использует сторонние виджеты, аналитику и шрифты, сначала разрешите конкретные домены, а не звёздочку: script-src https://cdn.example.net безопаснее, чем script-src *.
HSTS: принудительный HTTPS
Strict-Transport-Security сообщает браузеру, что к этому домену нужно подключаться только по HTTPS в течение указанного времени. Заголовок имеет смысл лишь на ответах по HTTPS: по HTTP браузер его игнорирует. Параметр includeSubDomains распространяет правило на поддомены, preload добавляет домен в список, встроенный в браузеры.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
curl -sI https://example.com | grep -i strict-transport
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | grep -i 'TLSv'
curl -sI http://example.com | head -5
С preload спешить не стоит. После включения домен может месяцами оставаться в списке, а любой поддомен без рабочего HTTPS станет недоступен. Схема безопасного внедрения: сначала короткий max-age, например 300 секунд, проверка всех поддоменов, затем увеличение до года и только потом заявка на preload. Откат возможен, но занимает время.
Остальные заголовки
X-Content-Type-Options: nosniff запрещает браузеру угадывать MIME-тип и снижает риск выполнения загруженного файла как скрипта. Referrer-Policy управляет тем, какие данные об источнике уходят на сторонние сайты; значение strict-origin-when-cross-origin — разумный компромисс. X-Frame-Options устарел, но всё ещё полезен для старых браузеров, а современная замена — frame-ancestors в CSP.
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
Permissions-Policy отключает ненужные API: геолокацию, камеру, микрофон. Это не абсолютная защита, но уменьшает поверхность атаки при компрометации стороннего кода. COOP и COEP стоит включать осознанно: они изолируют контекст, но могут сломать встраивание и работу некоторых библиотек.
Проверка и типичные ошибки
Проверяйте не только главную страницу. Заголовки должны быть на HTML-ответах, на страницах ошибок и на редиректах. Частая ошибка — заголовок есть на ответе 200, но отсутствует на 404, которую как раз и показывают атакующему. Вторая ошибка — дублирование: два разных значения HSTS или CSP в одном ответе приводят к непредсказуемому поведению браузера.
curl -sI https://example.com/nonexistent | grep -iE 'content-security|strict'
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://example.com
for u in / /login /api/health; do curl -sI "https://example.com$u" | grep -ci content-security; done
curl -sI https://example.com | grep -i x-content-type
Третья ошибка — CSP, который ничего не ограничивает: default-src * или script-src ‘unsafe-inline’ ‘unsafe-eval’. Такой заголовок создаёт ощущение защиты, но пропускает большинство XSS. Четвёртая — HSTS на stage-домене, который потом переиспользуют для тестов по HTTP. Пятая — отсутствие мониторинга: после релиза нужно смотреть консоль браузера и отчёты, иначе сломанные формы заметят пользователи.
Чек-лист внедрения
- Снимите текущие заголовки со всех типов страниц и сохраните как baseline.
- Включите nosniff, Referrer-Policy и frame-ancestors — они почти не ломают сайт.
- CSP сначала в режиме Report-Only, минимум несколько дней наблюдения.
- Уберите ‘unsafe-inline’ и встроенные обработчики, перейдите на nonce или хеши.
- HSTS вводите с коротким max-age, проверьте все поддомены, потом увеличивайте.
- Добавьте проверку заголовков в CI, чтобы регрессия не прошла незаметно.
Итог
CSP и HSTS — не магическая кнопка, а управляемые ограничения, которые нужно вводить постепенно и проверять на реальном трафике. Начните с безопасных заголовков, затем добавьте CSP в режиме отчётов, устраните нарушения и только после этого включайте блокировку. HSTS требует ещё большей аккуратности из-за долгого действия в браузерах. Регулярная проверка ответов через curl и мониторинг отчётов превращают эти заголовки из формальности в работающий слой защиты.