1. 1
  2. 2
  3. 3
  4. 4

# теория · шаг 2 из 4

Ловушка add_header

Коротко

Один add_header во вложенном блоке отменяет ВСЕ заголовки, заданные выше. Молча: ни ошибки, ни предупреждения, nginx -t доволен.

  • Правило: заголовки берутся с ближайшего уровня, где есть хоть один add_header. Уровни выше не дополняют его - они выключаются.
  • С nginx 1.29.3 есть однострочное лечение: add_header_inherit merge; на уровне http. Заголовки начинают складываться сверху вниз.
  • Без always заголовок не попадает на ответы 4xx и 5xx - то есть исчезает ровно там, где нужнее всего.
  • Проверяется одной командой: сравни curl -I на главную и на несуществующую страницу.

Если про add_header_inherit merge уже знаешь - листай до «Теперь сам».

Сначала ответь сам

/etc/nginx/conf.d/shop.confhttp
server {
    server_name shop.local;
    root /var/www/shop;

    add_header X-Frame-Options DENY always;
    add_header Strict-Transport-Security "max-age=31536000" always;

    location /api/ {
        add_header X-Request-Id $request_id always;
        proxy_pass http://127.0.0.1:8080;
    }
}

Какие заголовки получит клиент в ответе на GET /api/cart?

Инстинктивный ответ - все три. Правильный - только X-Request-Id. Защита от кликджекинга и HSTSРазбирается в разделе 9, глава «HSTS, протоколы, шифры»: HSTS и цена необратимости (заголовок, требующий ходить только по https) не отправятся вообще, и именно на том адресе, где принимают деньги.

На что это похоже

Представь стопку распоряжений на столе у сотрудника.

Сверху лежит распоряжение отдела: «к каждой посылке класть чек и гарантийный талон». Начальник участка кладёт сверху своё: «к каждой посылке класть наклейку». Ты ждёшь, что теперь кладут три вещи.

А сотрудник читает только верхний лист. Не потому что ленив - потому что так устроена инструкция: действует ближайшее распоряжение, остальные не складываются с ним, а перестают действовать.

Наклейку положат. Чек и гарантийный талон - нет. И никто об этом не узнает, пока клиент не спросит про гарантию.

Механизм: почему выключается весь список

add_header - директива-список из прошлого урока. При сборке конфига nginx идёт от location вверх и ищет первый уровень, где есть хоть один add_header. Найденный список и становится итоговым; поиск на этом останавливается.

поиск идёт снизу вверх и останавливается на первом непустом уровне http: (пусто) server: X-Frame, HSTS location: X-Request-Id нашли список - поиск остановлен сюда уже не смотрят Итог: клиент получает один заголовок из трёх.
Список заголовков не складывается по уровням: nginx берёт ближайший непустой и выше не идёт. С add_header_inherit merge стрелка не останавливается и собирает все уровни.

Правило

Заголовки берутся с ближайшего уровня, где есть хоть один add_header. Всё, что задано выше, при этом выключается целиком. Чтобы уровни складывались, нужна директива add_header_inherit merge; - она появилась в nginx 1.29.3.

Разбор: одна строка чинит всё

Проверено на nginx 1.31.5. Ставим merge на уровень http и смотрим, что получит клиент на четырёх уровнях вложенности:

/etc/nginx/conf.d/shop.confhttp
add_header_inherit merge;
add_header X-Http "http" always;

server {
    add_header X-Server "server" always;

    location /deep/ {
        add_header X-Loc "loc" always;

        location /deep/deeper/ {
            add_header X-Deep "deep" always;
        }
    }
}

Две первые строки стоят прямо в файле из conf.d, вне server, - то есть на уровне http, как и обещает шапка.

$  команда
curl -sI localhost/deep/deeper/ | grep '^X-'
↳  выводcurl -sI localhost/deep/deeper/ | grep '^X-'
X-Deep: deep
X-Loc: loc
X-Server: server
X-Http: http

Все четыре уровня сложились, от самого глубокого к http - в таком порядке их собирает merge, а браузеру порядок заголовков безразличен. Причём сама директива add_header_inherit наследуется обычным способом, поэтому написанная один раз в http она действует на весь сервер - в том числе на блоки, которые допишут потом.

У директивы три значения:

Значение Что делает
on прежнее поведение и умолчание: ближайший непустой уровень, выше не смотрим
merge складывает заголовки со всех уровней
off не берёт ничего сверху вообще, даже если на своём уровне пусто

off нужен там, где вложенный блок обязан отдавать только своё: отдача чужого файла, редирект на партнёрский домен, health-check (служебный адрес, который опрашивает мониторинг, чтобы понять, жив ли сервер) без лишних подробностей о сервере.

Разбор: где наивное правило подводит

Наивное правило после этого урока звучит так: «поставил merge - заголовки на месте». Оно верно ровно наполовину, и вторая половина ломается на страницах ошибок.

/etc/nginx/conf.d/shop.confhttp server
add_header X-Frame-Options DENY;

Без always заголовок добавляется только к «успешным» ответам: 200, 201, 204, 301, 302, 303, 304, 307, 308. Ответ 404, 502 или 429 уедет клиенту без него.

$  команда
curl -I https://свой-сайт/ | grep -ci x-frame-options
curl -I https://свой-сайт/этой-страницы-нет | grep -ci x-frame-options

grep -ci считает строки с этим словом без учёта регистра: 1 - заголовок есть, 0 - нет. Если второе число меньше первого - ты только что нашёл это у себя. Лечится одним словом в конце каждой строки:

/etc/nginx/conf.d/shop.confhttp server
add_header X-Frame-Options DENY always;

Почему так вообще сделано: add_header задумывался для заголовков про содержимое (Cache-Control, Expires), а их на странице ошибки быть и не должно. Заголовки безопасности пришли позже, и для них появился always.

Почему это больнее, чем кажется

Страницу ошибки чаще всего показывают в момент, когда что-то уже пошло не так: приложение упало, лимит сработал, адрес подобрали перебором. Отдавать её без заголовков безопасности - значит снимать защиту ровно в тот момент, когда она нужна.

Что ломается без этого

  • Заголовков меньше на части адресов. Сравни curl -I на главную и на /api/. Разница - след add_header во вложенном блоке.
  • Заголовков меньше на ошибках. Сравни главную и несуществующую страницу. Разница - отсутствующий always.
  • Внешний сканер безопасности ругается через полгода после настройки. Классическая история: заголовки поставили, проверили, всё хорошо; через полгода кто-то добавил в один location add_header X-Request-Id для отладки и забыл убрать.

Зачем это в работе

Эта ловушка почти никогда не срабатывает в день настройки. Она срабатывает позже и от чужой руки: заголовки безопасности пишет один человек, а X-Request-Id для отладки добавляет другой, через год, в один location, и уверенно проверяет nginx -t.

Поэтому лечение выбирается не по красоте, а по тому, переживёт ли оно следующего человека:

  1. add_header_inherit merge; в http - одна строка на весь сервер, действует и на блоки, которых ещё нет. Лучший вариант, если у тебя nginx 1.29.3 или новее.
  2. Сниппет с заголовками и include в каждом блоке - работает на любой версии, но требует помнить про include при заведении нового location.
  3. Не добавлять add_header во вложенные блоки вообще - надёжнее всего, но держится на договорённости, а договорённости не переживают год.
/etc/nginx/snippets/security-headers.conf
add_header X-Frame-Options DENY always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options nosniff always;

Подключается он строкой в каждом server и location, где есть свои add_header:

/etc/nginx/conf.d/shop.confhttp server
include snippets/security-headers.conf;

Путь без / в начале nginx считает от /etc/nginx. В пакете Debian каталог snippets уже есть, в официальном образе Docker его создают сами: sudo mkdir -p /etc/nginx/snippets.

Теперь сам

1. На сервере nginx 1.26. Что выбрать из трёх способов и почему?

Второй, сниппет. Свою версию смотри командой nginx -v: add_header_inherit появилась только в 1.29.3, и на 1.26 такая строка уронит конфиг с unknown directive. Заодно это повод посмотреть, почему сервер сидит на старой ветке.

2. В location /healthz/ стоит add_header_inherit off; и ни одного add_header. Что получит клиент?

Ни одного заголовка из добавленных конфигом. off отключает наследование независимо от того, есть ли на своём уровне что-то своё. Для health-check это осмысленно: чем меньше сервер рассказывает о себе на служебном адресе, тем лучше.

3. Заголовок Content-Type тоже задан не тобой, и он приходит на 404. Он что, не подчиняется этому правилу?

Не подчиняется, потому что он и не от add_header. Content-Type, Content-Length, Server, Date ставит сам nginx на этапе формирования ответа. Правило про списки и always касается только заголовков, дописанных директивой add_header.

Главное

add_header не дополняет заголовки уровнем выше, а отменяет их: берётся ближайший непустой список. Начиная с nginx 1.29.3 это чинится строкой add_header_inherit merge; в http, а always нужен, чтобы заголовки доезжали до страниц ошибок.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.