# теория · шаг 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 уже знаешь - листай до «Теперь сам».
Сначала ответь сам
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. Найденный список и становится итоговым; поиск на этом
останавливается.
Правило
Заголовки берутся с ближайшего уровня, где есть хоть один add_header. Всё,
что задано выше, при этом выключается целиком. Чтобы уровни складывались,
нужна директива add_header_inherit merge; - она появилась в nginx 1.29.3.
Разбор: одна строка чинит всё
Проверено на nginx 1.31.5. Ставим merge на уровень http и смотрим, что
получит клиент на четырёх уровнях вложенности:
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-'
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 - заголовки
на месте». Оно верно ровно наполовину, и вторая половина ломается на страницах
ошибок.
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 - нет. Если второе число меньше первого - ты только что нашёл это у
себя. Лечится
одним словом в конце каждой строки:
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.
Поэтому лечение выбирается не по красоте, а по тому, переживёт ли оно следующего человека:
add_header_inherit merge;вhttp- одна строка на весь сервер, действует и на блоки, которых ещё нет. Лучший вариант, если у тебя nginx 1.29.3 или новее.- Сниппет с заголовками и
includeв каждом блоке - работает на любой версии, но требует помнить проincludeпри заведении нового location. - Не добавлять
add_headerво вложенные блоки вообще - надёжнее всего, но держится на договорённости, а договорённости не переживают год.
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:
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 нужен, чтобы заголовки
доезжали до страниц ошибок.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий