Почему add_header в location убивает заголовки безопасности, а на 404 их нет вообще
Ситуация, которую я видел десятки раз: админ добавил в один location заголовок вроде X-Robots-Tag, проверил главную страницу — всё на месте, выкатил. Через месяц приходит отчёт сканера: на /api и на страницах ошибок нет ни HSTS, ни CSP, ни X-Frame-Options. Никто ничего не удалял. Это штатное поведение add_header, о котором в мануале написано одной строкой, и её принято пролистывать. Разбираю обе ловушки — замещение по уровням и фильтр по кодам ответа, — показываю, как это выглядело у клиента, и что теперь можно сделать нормально с директивой add_header_inherit, появившейся в nginx 1.29.3.
Главное правило: add_header не дополняет, а замещает уровень целиком
Формулировка из официального мануала звучит так: директивы add_header наследуются с предыдущего уровня конфигурации тогда и только тогда, когда на текущем уровне не определено ни одной директивы add_header. Читайте медленно. Не «наследуются, а локальные добавляются сверху». Не «наследуются те, которых нет локально». А по принципу «всё или ничего»: как только вы написали в location хотя бы один add_header, весь набор с уровня server и http для этого location перестаёт существовать.
Это общий для nginx механизм наследования директив-массивов, тот же самый, что у proxy_set_header, add_trailer, fastcgi_param. Логика движка простая: массив на текущем уровне либо пустой (тогда берём указатель на родительский), либо непустой (тогда используем свой и на родителя не смотрим). Никакого слияния в классической модели нет и не было.
Отсюда и берётся классический сценарий поломки. Было так:
server {
listen 443 ssl;
server_name portal.example.tld;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy "default-src 'self'" always;
location / {
proxy_pass http://app_backend;
}
location /files/ {
alias /srv/files/;
}
}Пришла задача «закрой выгрузки от индексации». Админ дописывает в /files/ ровно одну строку — add_header X-Robots-Tag "noindex, nofollow"; — и с этого момента по всему пути /files/ отдаётся ровно один заголовок: X-Robots-Tag. HSTS нет. CSP нет. nosniff нет. Конфиг валиден, nginx -t доволен, главная страница по-прежнему идеальна, потому что location / никто не трогал. Проблема живёт до первого внешнего аудита.
- Признак 1: заголовки безопасности есть на «/», но пропадают на конкретном разделе — почти наверняка локальный add_header.
- Признак 2: заголовки пропали ровно после правки, не связанной с безопасностью (кэширование, robots, CORS, download-заголовки).
- Признак 3: в location всего один add_header, а исчезло четыре — это и есть «всё или ничего».
Вторая ловушка: коды ответа. Почему на 404 и 500 заголовков нет и без всяких правок
Даже если вы вообще ничего не ломали, заголовков может не быть на ошибках. По умолчанию add_header добавляет поле в ответ при условии, что код ответа равен 200, 201, 204, 206, 301, 302, 303, 304, 307 или 308. Всё. Ни 400, ни 401, ни 403, ни 404, ни 500, ни 502 в этот список не входят.
Параметр always появился в nginx 1.7.5 и распространяет добавление заголовка на любой код ответа. Это единственный способ получить HSTS на странице 502, когда бэкенд лежит, и CSP на кастомной 404-й. Заголовки безопасности имеет смысл писать с always всегда — исключений я в практике не встречал. А вот кэширующие заголовки с always ставить не надо: незачем говорить клиенту кэшировать ошибку.
Сюда же примыкает третья, менее известная ловушка — error_page. Когда nginx делает внутренний редирект на страницу ошибки, ответ формируется уже в другом location, и применяются add_header того location, куда попал внутренний запрос. То есть даже с always вы можете получить не тот набор заголовков, который ожидали, если страницы ошибок отдаются из отдельного блока со своими add_header. Это ровно тот же механизм замещения, просто он срабатывает в неочевидном месте.
И четвёртое место, где всё ломается тихо: if внутри location. Блок if — это отдельный уровень конфигурации со своим массивом. Один add_header внутри if гасит все add_header самого location.
location /download/ {
add_header X-Content-Type-Options "nosniff" always;
if ($arg_attach = 1) {
add_header Content-Disposition "attachment"; # nosniff здесь уже нет
}
proxy_pass http://storage;
}- Без always заголовок ставится только для 200, 201, 204, 206, 301, 302, 303, 304, 307, 308.
- always (с 1.7.5) снимает фильтр по коду полностью.
- error_page уводит генерацию ответа в другой location — с его собственным набором add_header.
- if в location — это отдельный уровень: любой add_header в нём отменяет заголовки location.
Как это выглядело у клиента: магазин велосипедов «ВелоСтарт», 44 рабочих места
Разберу конкретный случай — магазин велосипедов «ВелоСтарт»: розничный зал, склад и сервисная мастерская, 44 рабочих места, наш аутсорсинг. Периметр: pfSense, за ним nginx-фронт на Debian 12 (nginx 1.22.1 из дистрибутивного пакета), за фронтом три бэкенда — интернет-магазин на PHP-FPM, веб-публикация 1С для менеджеров и мастерской через Apache и небольшой сервис отчётов на Node.js. Всё под одним доменом, разведено по location.
Пришли к нам после того, как их крупный поставщик — дистрибьютор, через портал которого магазин получает товар под реализацию, — прислал по итогам своего опросника по безопасности замечание: «на части ресурсов отсутствуют заголовки транспортной безопасности». Внутренний админ проверил через онлайн-чекер главную, получил оценку A и честно ответил, что всё в порядке. Формально он был прав: на «/» все заголовки стояли.
Первое, что я делаю в таких историях, — снимаю полный развёрнутый конфиг и смотрю, где вообще встречается add_header. Не читаю файлы по одному: include-ов там было 14 штук, и половина подключалась из conf.d по маске.
nginx -T 2>/dev/null | grep -n -E 'server_name|location |add_header|error_page' | lessКартина вскрылась за пять минут. На уровне server стояли четыре заголовка безопасности с always. И три location, каждый со своим add_header, дописанным в разное время разными людьми: в /1c/ — add_header X-Frame-Options "SAMEORIGIN"; (без always, добавлено, когда публикация 1С не открывалась во фрейме), в /reports/ — пара CORS-заголовков, в /static/ — add_header Cache-Control "public, max-age=31536000, immutable";. Итог: три из четырёх основных разделов сайта работали вообще без HSTS и без CSP, а раздел /1c/ имел X-Frame-Options только на успешных ответах — на 401 и 403, которые публикация 1С отдаёт при неверном пароле, его не было.
Проверил руками, чтобы не гадать:
for p in / /1c/ /reports/ /static/app.js /nope-$RANDOM; do
echo "== $p"
curl -sSI "https://shop.example.tld$p" \
| grep -i -E 'HTTP/|strict-transport|content-security|x-frame|x-content'
doneНа выходе — ровно то, что предсказывал мануал: на «/» четыре заголовка, на /1c/ один, на /reports/ два CORS-заголовка и ничего больше, на /static/ только Cache-Control, на несуществующем пути (404 из location /) — пусто, потому что 404 не входит в список кодов по умолчанию, а always там стоял не везде.
Лечили в два приёма. Сначала быстро и по-старому: вынес весь блок заголовков безопасности в /etc/nginx/snippets/security-headers.conf и продублировал include в каждый location, где был свой add_header. Заняло сорок минут вместе с тестами. Потом, через две недели, на плановом окне перевели фронт на официальный репозиторий nginx.org, обновили nginx до текущей стабильной ветки и переписали конфиг на add_header_inherit merge — про это дальше. Повторный прогон опросника дистрибьютора прошли без замечаний.
- Было: 4 заголовка на server, 3 location со своими add_header, реально защищён 1 раздел из 4.
- Стало (шаг 1): один сниппет + include в каждом location с локальными заголовками.
- Стало (шаг 2): nginx обновлён до stable-ветки с nginx.org, add_header_inherit merge на уровне http, дубли include убраны.
- Время: 40 минут на быстрый фикс, ещё около часа на переезд конфига после апгрейда.
add_header_inherit из nginx 1.29.3: наконец-то нормальное слияние
В версии 1.29.3 (mainline, вышла 28 октября 2025 года; обзор 1.29.3 и 1.29.4 NGINX опубликовал в блоге 10 декабря 2025-го) появилась директива add_header_inherit — ровно про ту боль, которую я описал выше. Синтаксис: add_header_inherit on | off | merge;, значение по умолчанию — on, контексты http, server, location и if in location. Значение on — это классическая модель наследования, то самое «всё или ничего», которое работало все предыдущие годы; поведение по умолчанию не изменилось, обновление ничего вам не сломает.
Параметр merge включает добавление значений с предыдущего уровня к значениям, определённым на текущем. То есть локальный add_header больше не выбрасывает родительские, а дописывается к ним. Параметр off — наоборот, полностью отменяет наследование с предыдущего уровня, даже если на текущем уровне add_header нет вообще. Это нужно там, где вы сознательно хотите отдать «голый» ответ: какой-нибудь /healthz, статика на CDN-домене, отдельный технический эндпоинт.
Отдельно важно: сами правила наследования наследуются стандартным образом. То есть add_header_inherit merge; один раз на уровне http распространяется рекурсивно на все вложенные уровни, пока где-то ниже не будет переопределён. Один раз написали — и дальше все location ведут себя предсказуемо.
http {
add_header_inherit merge;
add_header X-Content-Type-Options "nosniff" always;
server {
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'" always;
location /files/ {
# HSTS, CSP и nosniff остаются на месте
add_header X-Robots-Tag "noindex, nofollow" always;
}
location = /healthz {
add_header_inherit off; # здесь сознательно ничего лишнего
return 200 "ok\n";
}
}
}add_header_inherit в 1.29.3 пришла не одна: в том же релизе добавилась add_trailer_inherit с теми же тремя значениями (on, off, merge) и той же семантикой, но для директивы add_trailer. Если вы отдаёте трейлеры (например, при стриминге или gRPC-подобных ответах), правило то же самое. В stable-ветку обе директивы попали с выходом 1.30.x, поэтому на stable ниже 1.30 их нет.
- on — поведение по умолчанию, классическое замещение уровня целиком.
- merge — значения родительского уровня добавляются к локальным.
- off — наследование отменяется полностью, даже при пустом локальном наборе.
- Сама директива add_header_inherit наследуется вниз рекурсивно.
Где взять нужную версию: дистрибутивных пакетов не хватит
Практический момент, о который спотыкаются все. Директива есть с 1.29.3, а в репозиториях распространённых дистрибутивов на сентябрь 2026 лежит заметно более старое. Debian 13 (trixie) — nginx 1.26.3. Ubuntu 24.04 LTS (noble) — 1.24.0. Ни там, ни там add_header_inherit нет, и nginx -t честно скажет «unknown directive». Актуальные ветки на nginx.org сейчас: стабильная 1.30.4 и mainline 1.31.5 — обе, разумеется, новее 1.29.3 и директиву поддерживают.
Значит, либо вы живёте на дистрибутивном пакете и решаете задачу по-старому (сниппет с include, об этом ниже), либо переезжаете на официальный репозиторий nginx.org. Я обычно выбираю второе на фронтах, которые сам обслуживаю: там же приходят свежие исправления TLS и HTTP/2, и не надо ждать бэкпортов. На чужих серверах, где я не отвечаю за жизненный цикл ОС, наоборот, стараюсь не сходить с дистрибутивного пакета — потому что через полгода придёт другой человек, сделает apt upgrade и удивится.
Подключение официального репозитория на Debian выглядит так:
sudo apt install curl gnupg2 ca-certificates lsb-release debian-archive-keyring
curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \
| sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \
https://nginx.org/packages/debian `lsb_release -cs` nginx" \
| sudo tee /etc/apt/sources.list.d/nginx.list
printf 'Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n' \
| sudo tee /etc/apt/preferences.d/99nginx
sudo apt update && sudo apt install nginx
nginx -vДва предостережения из опыта. Первое: пакет с nginx.org кладёт конфиг в /etc/nginx с другой структурой, чем дистрибутивный — там нет sites-available/sites-enabled, есть conf.d. Перед апгрейдом снимите nginx -T > /root/nginx-full-$(date +%F).conf, чтобы было с чем сверяться. Второе: если у вас собраны сторонние динамические модули под дистрибутивную версию, они после смены ветки не загрузятся — версии должны совпадать.
- Debian 13 (trixie): nginx 1.26.3 — add_header_inherit нет.
- Ubuntu 24.04 LTS: nginx 1.24.0 — нет.
- nginx.org stable: 1.30.4 — есть. nginx.org mainline: 1.31.5 — есть.
- Проверка одной строкой: `nginx -V 2>&1 | head -1` и попытка `nginx -t` с тестовым конфигом.
Что делать, если обновиться нельзя: рабочие обходные пути
Способ первый и основной — сниппет с include. Складываете все заголовки безопасности в один файл и подключаете его в каждый location, где есть свой add_header. Некрасиво, дублирование, зато работает на любой версии nginx с 2014 года и понятно любому админу, который откроет конфиг после вас.
# /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy "default-src 'self'" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;location /files/ {
include snippets/security-headers.conf;
add_header X-Robots-Tag "noindex, nofollow" always;
alias /srv/files/;
}Способ второй — сторонний модуль headers-more (ngx_headers_more) с директивой more_set_headers. Он умеет работать на любых кодах ответа без отдельного always и умеет именно устанавливать заголовок, а не добавлять ещё один такой же — это важно, когда бэкенд уже прислал свой CSP и вы получаете два заголовка вместо одного. И с наследованием у него иначе, чем у add_header: по документации модуля директивы, унаследованные с верхних уровней (http, server), выполняются перед директивами location, то есть наборы накапливаются, а не замещаются. Модуль закрывает сразу три проблемы — дубли, коды ответа и замещение, — но ценой нестандартного синтаксиса и зависимости от сборки. И он не входит в официальный пакет: либо ставить nginx-extras из дистрибутива, либо собирать динамический модуль под свою версию.
Способ третий, про который стоит помнить: заголовки, приходящие от апстрима. add_header ничего не заменяет — он добавляет. Если ваше приложение само шлёт Content-Security-Policy, а вы добавляете свой, браузер получит два заголовка CSP и будет применять пересечение политик, то есть самую строгую комбинацию. Обычно это выглядит как «на бою половина скриптов не грузится, а локально всё хорошо». Лечится либо proxy_hide_header Content-Security-Policy; перед своим add_header, либо договорённостью с разработчиками, кто из вас за этот заголовок отвечает. Я предпочитаю второе: заголовки безопасности логичнее держать в одном месте — на фронте.
- include-сниппет — универсально, работает везде, минус только в дублировании строк.
- headers-more / more_set_headers — работает на всех кодах, устанавливает вместо дублирования, директивы верхних уровней выполняются перед location; минус — сторонний модуль.
- proxy_hide_header — когда апстрим шлёт свой вариант того же заголовка.
- Карта ответственности: кто ставит CSP — фронт или приложение. Одновременно оба — гарантированные сюрпризы.
Проверка: как убедиться, что заголовки реально есть везде
Правило простое: проверять надо не сайт, а каждый location и каждый интересующий код ответа. Начинаю всегда с полного дампа конфига — он показывает результат всех include и подключённых из conf.d файлов, а не то, что вы думаете, будто там написано.
# все места, где вообще встречаются add_header и локальные уровни
nginx -T 2>/dev/null | grep -n -E 'server_name|^\s*location |add_header|add_header_inherit|error_page|if \('Дальше — обход по URL. Минимальный набор точек: корень, каждый location с собственным add_header, один заведомо несуществующий путь (даёт 404), один защищённый URL без авторизации (даёт 401 или 403) и, если можно погасить бэкенд на тестовом стенде, — 502. Скрипт, который я держу в закладках:
#!/usr/bin/env bash
HOST="https://portal.example.tld"
PATHS=("/" "/api/" "/1c/" "/static/app.js" "/admin/" "/nope-$RANDOM")
WANT='strict-transport-security|content-security-policy|x-content-type-options|x-frame-options'
for p in "${PATHS[@]}"; do
code=$(curl -s -o /dev/null -w '%{http_code}' "$HOST$p")
n=$(curl -sI "$HOST$p" | grep -icE "$WANT")
printf '%-24s code=%s headers=%s\n' "$p" "$code" "$n"
doneВывод читается мгновенно: если у какого-то пути headers меньше, чем у корня, — там локальный add_header съел наследование. Если заголовков нет только на строках с кодами 401/403/404/502 — значит, где-то потерян always. Этот же скрипт удобно повесить в мониторинг раз в сутки: заголовки безопасности ломаются не сразу, а через несколько недель после чьей-то правки, и заметить это по факту деплоя почти невозможно.
И последнее по порядку, но не по важности: заведите привычку после любой правки location прогонять проверку по всем разделам, а не по тому, который правили. Стоимость ошибки здесь несимметричная — правка на пять секунд, а последствия вылезают у контрагента в опроснике по информационной безопасности через полгода.
- nginx -T — единственный честный источник правды по конфигу.
- Проверять коды 200 / 301 / 401 / 404 / 502 отдельно.
- Сравнивать количество заголовков по location с эталоном на «/».
- Поставить проверку в cron или в мониторинг — деградация происходит тихо.
Частые вопросы
Почему после добавления одного add_header в location пропали все остальные заголовки?
Так работает наследование директив-массивов в nginx: add_header наследуется с предыдущего уровня только при полном отсутствии add_header на текущем. Достаточно одной локальной директивы, чтобы весь набор с уровня server и http для этого location перестал применяться. Это не баг, а документированное поведение, и до nginx 1.29.3 изменить его было нельзя.
Поможет ли параметр always вернуть унаследованные заголовки?
Нет. always управляет только тем, на каких кодах ответа заголовок добавляется: без него — только 200, 201, 204, 206, 301, 302, 303, 304, 307, 308, с ним — на любых. К наследованию по уровням конфигурации always отношения не имеет. Это две независимые механики, и их постоянно путают.
Как включить слияние заголовков вместо замещения?
Директивой add_header_inherit merge; — она появилась в nginx 1.29.3 (28 октября 2025, по CHANGES), работает в контекстах http, server, location и if in location. Значение merge добавляет значения предыдущего уровня к локальным. Само правило наследуется вниз рекурсивно, поэтому достаточно указать его один раз на уровне http.
В моём дистрибутиве nginx не понимает add_header_inherit — что делать?
Значит, версия старше 1.29.3: в Debian 13 это 1.26.3, в Ubuntu 24.04 — 1.24.0. Варианты два: перейти на официальный репозиторий nginx.org (stable 1.30.4 или mainline 1.31.5) либо решить задачу по-старому — вынести заголовки в отдельный сниппет и подключать его через include в каждом location, где есть свои add_header.
Почему на странице 404 или 502 нет заголовков безопасности, хотя в конфиге всё есть?
Потому что коды 404 и 502 не входят в список кодов, для которых add_header срабатывает по умолчанию. Нужен параметр always. Дополнительно проверьте error_page: при внутреннем редиректе ответ формируется в другом location, и применяются его add_header, а не те, что вы ожидали.
Почему браузер ругается на CSP, хотя политика выглядит корректной?
Скорее всего, заголовок Content-Security-Policy приходит дважды — от приложения и от nginx. Браузер в этом случае применяет обе политики одновременно, то есть их пересечение, и итог получается строже каждой из них. Уберите лишний источник: proxy_hide_header на фронте или отключение заголовка в приложении.
Источники
- Официальная документация nginx — Module ngx_http_headers_module — директивы add_header (синтаксис, список кодов ответа 200/201/204/206/301/302/303/304/307/308, параметр always с 1.7.5, правило наследования) и add_header_inherit (on|off|merge, по умолчанию on, появилась в 1.29.3): https://nginx.org/ru/docs/http/ngx_http_headers_module.html (англ. версия: https://nginx.org/en/docs/http/ngx_http_headers_module.html); там же add_trailer_inherit (1.29.3)
- nginx.org — CHANGES — Журнал изменений nginx: «Changes with nginx 1.29.3 — 28 Oct 2025», Feature: директивы add_header_inherit и add_trailer_inherit: https://nginx.org/ru/CHANGES (англ.: https://nginx.org/en/CHANGES)
- Блог NGINX — What's New in NGINX Open Source 1.29.3 and 1.29.4 (публикация 10 декабря 2025, мотивация появления add_header_inherit и режима merge для глобальных заголовков безопасности): https://blog.nginx.org/blog/nginx-open-source-1-29-3-and-1-29-4
- Сообщество NGINX — Тема «Debian 13 Nginx 1.29.3 add_header don't show up on html page» — разбор случая, где ответы указывают на правило наследования, add_header_inherit и параметр always: https://community.nginx.org/t/debian-13-nginx-1-29-3-add-header-dont-show-up-on-html-page/8444
- nginx.org — Страница загрузки (актуальные ветки: stable 1.30.4, mainline 1.31.5 на сентябрь 2026): https://nginx.org/en/download.html и инструкция по официальному apt-репозиторию: https://nginx.org/en/linux_packages.html
- Репозитории дистрибутивов — Версии пакета nginx: Debian 13 trixie — 1.26.3 (https://packages.debian.org/trixie/nginx), Ubuntu 24.04 noble — 1.24.0 (https://packages.ubuntu.com/noble/nginx). В обеих директивы add_header_inherit нет.
- Модуль headers-more (OpenResty) — README headers-more-nginx-module: more_set_headers по умолчанию действует на все коды ответа, включая 4xx и 5xx; директивы верхних уровней выполняются перед директивами location: https://github.com/openresty/headers-more-nginx-module
