АйТи Фреш
Главная / Статьи / DevOps и автоматизация
DevOps и автоматизация

Почему add_header в location убивает заголовки безопасности, а на 404 их нет вообще

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~19 мин чтения
Почему add_header в location убивает заголовки безопасности, а на 404 их нет вообще
Иллюстрация к статье «Почему 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 / никто не трогал. Проблема живёт до первого внешнего аудита.

Проверять сайт после правки только по главной странице — бесполезно. Замещение работает на том уровне, куда вы вписали директиву, и ровно там его и надо смотреть.
Памятка: Главное правило: add_header не дополняет, а замещает уровень целиком — схема
Памятка: Главное правило: 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 и наследование — это две ортогональные вещи. always решает вопрос «на каких кодах», наследование — вопрос «с каких уровней». Дописывание always в location не вернёт заголовки, потерянные из-за замещения.
Почему add_header в location убивает заголовки безопасности, а на 404 их нет вообще — схема
Схема к статье. Открыть схему в полном размере

Как это выглядело у клиента: магазин велосипедов «ВелоСтарт», 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 — про это дальше. Повторный прогон опросника дистрибьютора прошли без замечаний.

Отдельно проверьте разделы, которые отдают 401/403 — веб-публикации 1С, админки, API с авторизацией. Там заголовки без always отсутствуют ровно в тот момент, когда они нужнее всего.
Цифры и версии: Как это выглядело у клиента: магазин велосипедов «ВелоСтарт», 44 рабочих места — схема
Цифры и версии: Как это выглядело у клиента: магазин велосипедов «ВелоСтарт», 44 рабочих места. Открыть схему в полном размере

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 их нет.

merge добавляет, а не заменяет. Если на server у вас Cache-Control одного значения, а в location другого — при merge клиент получит два заголовка Cache-Control. Для заголовков, которые не должны дублироваться, всё равно нужен либо off на этом уровне, либо разнесение по разным веткам конфига.

Где взять нужную версию: дистрибутивных пакетов не хватит

Практический момент, о который спотыкаются все. Директива есть с 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, чтобы было с чем сверяться. Второе: если у вас собраны сторонние динамические модули под дистрибутивную версию, они после смены ветки не загрузятся — версии должны совпадать.

Не апгрейдьте фронт ради одной директивы в пятницу вечером. Задача решается сниппетом за сорок минут, а обновление ветки nginx — это плановое окно с проверкой TLS-настроек, модулей и лимитов.

Что делать, если обновиться нельзя: рабочие обходные пути

Способ первый и основной — сниппет с 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, либо договорённостью с разработчиками, кто из вас за этот заголовок отвечает. Я предпочитаю второе: заголовки безопасности логичнее держать в одном месте — на фронте.

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

Онлайн-чекеры проверяют только тот URL, который вы им дали, — обычно главную. Оценка A на главной ничего не говорит о состоянии /api, /admin и страниц ошибок.

Частые вопросы

Почему после добавления одного 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 на фронте или отключение заголовка в приложении.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи