Работа с заголовками: add_header и proxy_set_header

Рейтинг: 45.3% · 9 голосов
Самый подробный курс по Nginx - от первого конфига до production API-gateway. Архитектура и event loop, контексты и location, виртуальные хосты, статика, reverse proxy и upstream-балансировка, кэширование, HTTPS и TLS (post-quantum), HTTP/2 и HTTP/3 QUIC, безопасность и rate limiting, realip, JSON-логи и сквозная трассировка, метрики, тюнинг и траблшутинг. Плюс OpenResty (Lua, фазы, JWT, динамика) и Angie (нативные Prometheus, ACME, dynamic upstream). На основе эталонного gateway и лучших практик. Для новичков и SRE/DevOps. Актуально на 2026 (nginx 1.30/1.31, Angie, ingress-nginx EOL -> Gateway API).
Ответить
Аватара пользователя
Igor_NgINX
Сообщения: 44
Зарегистрирован: 11 май 2026, 05:31

Работа с заголовками: add_header и proxy_set_header

Сообщение Igor_NgINX »

Оглавление курса (44)
  1. Что такое Nginx и почему он захватил веб
  2. Установка Nginx, Angie и OpenResty: пакеты, версии, Docker
  3. Первый конфиг с нуля: минимальный сервер за 5 минут
  4. Архитектура процессов: master, worker и event loop
  5. Анатомия конфигурации: контексты, наследование и модульность
  6. Виртуальные хосты: server и server_name
  7. Директива location: матчинг и приоритеты по шагам
  8. Раздача статики: root, alias, try_files, sendfile
  9. Переменные, map и блок if
  10. rewrite, return и канонизация URL
  11. Работа с заголовками: add_header и proxy_set_header (вы здесь)
  12. Модель доверия прокси-заголовков: realip и защита от спуфинга
  13. Сжатие: gzip, brotli и zstd
  14. Reverse proxy: proxy_pass и проброс запроса
  15. upstream и балансировка нагрузки
  16. Таймауты, повторы и устойчивость прокси
  17. FastCGI и PHP: nginx + PHP-FPM (и uwsgi/scgi)
  18. WebSocket, gRPC и стриминг через Nginx
  19. Кэширование ответов: proxy_cache и микрокэш
  20. HTTPS и TLS: сертификаты, протоколы, шифры (2026)
  21. Let's Encrypt и ACME: certbot, acme.sh, нативный ACME Angie
  22. HTTP/2 и HTTP/3 (QUIC) в 2026
  23. Безопасность: заголовки, скрытие версии, ограничения
  24. Rate limiting и защита от перегрузки
  25. Контроль доступа: allow/deny, auth_basic, auth_request и mTLS
  26. Логирование: access_log, форматы и структурированный JSON
  27. Сквозная трассировка запросов и OpenTelemetry
  28. Метрики и мониторинг Nginx
  29. Производительность и тюнинг под нагрузку
  30. Траблшутинг: 502, 504, 403, 404 и debug-лог
  31. OpenResty: Nginx как платформа на LuaJIT
  32. Фазы обработки запроса и Lua-хуки
  33. Lua API: ngx.*, shared dict, cosocket и lua-resty-core
  34. Практика OpenResty: авторизация, JWT, кэш, Redis
  35. Angie: современный форк Nginx 2026
  36. Динамическая конфигурация и service discovery
  37. Nginx как API-gateway и Kubernetes Gateway API
  38. Динамические модули, njs и WAF
  39. Stream-модуль: проксирование TCP и UDP
  40. Деплой, перезагрузка и конфиг как код
  41. Сценарий: статика, SPA и кэширование за CDN
  42. Сценарий: микросервисный gateway по образцу ngx-trace-gateway
  43. Капстоун: собираем production-gateway с нуля
  44. Чек-лист безопасности, CVE 2026 и аудит конфигурации
Зачем вообще лезть в заголовки

HTTP это не только тело ответа. Половина смысла каждого запроса и ответа живёт в заголовках: кто клиент, по какому протоколу пришёл, можно ли кэшировать, какой Content-Type, какие политики безопасности применить. nginx стоит ровно посередине между клиентом и бэкендом, и именно он решает, что отдать клиенту в ответ и что рассказать бэкенду о клиенте. Два инструмента для этого: add_header (добавить заголовок в ответ клиенту) и proxy_set_header (переписать заголовок запроса, который уйдёт на бэкенд).

Звучит просто. На практике nginx заголовки это минное поле. Самая частая боль звучит так: "я прописал X-Frame-Options и Content-Security-Policy в server, всё работало, потом добавил один add_header внутри location /api/ - и половина security-заголовков пропала на этом location". Человек ничего не ломал специально, а сайт стал уязвимее. Это не баг, это документированное и крайне контринтуитивное поведение наследования. Разберём его до винтика, потому что на нём горят даже опытные. И заодно научимся аккуратно пробрасывать контекст клиента на бэкенд - X-Real-IP, X-Forwarded-For, X-Forwarded-Proto и сквозные трассирующие заголовки, как это сделано в эталонном gateway.

Ключевое разделение, которое надо держать в голове с первой секунды: add_header правит ОТВЕТ клиенту, proxy_set_header правит ЗАПРОС к бэкенду. Это разные стороны nginx и разные модули. Путать их - классика для новичка.

Изображение

Главная грабля: nginx add_header отменяет родительские заголовки

Открываем официальную формулировку из ngx_http_headers_module и читаем медленно:
These directives are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level.
Переведём на человеческий. add_header наследуется с уровня выше (http -> server -> location) только если на текущем уровне нет НИ ОДНОГО add_header. Стоит написать хотя бы один add_header в location - и все add_header из server и http на этом location перестают применяться. Не дополняются, а просто исчезают. Наследование тут не кумулятивное, а "всё или ничего".

Смотрим, как это убивает безопасность. Конфиг, который выглядит правильным:

Код: Выделить всё

server {
    listen 443 ssl;
    server_name shop.example.com;

    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://frontend;
    }

    location /api/ {
        # хотели просто добавить CORS-заголовок
        add_header Access-Control-Allow-Origin "https://shop.example.com" always;
        proxy_pass http://backend;
    }
}
На location / все три security-заголовка отдаются - там нет своего add_header, значит наследование сработало. А на /api/ появился свой add_header, и nginx считает: на этом уровне add_header есть, родительские игнорируем. Клиент по /api/ получит только Access-Control-Allow-Origin. Ни nosniff, ни SAMEORIGIN, ни CSP. API-эндпоинты, как правило, самые чувствительные - и именно они остались голыми.

Проверяется элементарно, не на глаз:

Код: Выделить всё

$ curl -sI https://shop.example.com/ | grep -i -E 'x-frame|content-security|x-content'
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
content-security-policy: default-src 'self'

$ curl -sI https://shop.example.com/api/ | grep -i -E 'x-frame|content-security|x-content'
$
Второй вызов пустой. Заголовки пропали. Возьми за правило: после каждой правки заголовков прогоняй curl -I по разным location, а не верь конфигу на слово.

Маленькое, но важное про параметр always. Без always заголовок добавляется только к ответам 200, 201, 204, 206, 301, 302, 303, 304, 307, 308. Ответы вроде 404, 500, 502 уйдут без этого заголовка. Для security-заголовков это плохо: страница ошибки тоже должна быть защищена. Поэтому security-заголовки почти всегда пишут с always. Но always не меняет правило наследования - оно про другое.

Как с этим жить: три рабочих решения

Решение 1. Повторять весь набор там, где добавляешь новый заголовок. Грубо, но работает на ванильном nginx без модулей. На /api/ просто перечисляешь заново всё:

Код: Выделить всё

location /api/ {
    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 Access-Control-Allow-Origin "https://shop.example.com" always;
    proxy_pass http://backend;
}
Минус очевиден: дублирование, рассинхрон при правках. Чуть аккуратнее - вынести общий набор в отдельный файл и подключать include в каждый location, где есть свои add_header. Один источник правды, меньше шансов забыть:

Код: Выделить всё

# /etc/nginx/snippets/security_headers.conf
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
location /api/ {
    include snippets/security_headers.conf;
    add_header Access-Control-Allow-Origin "https://shop.example.com" always;
    proxy_pass http://backend;
}
include подставляет директивы текстуально на этом уровне, так что набор реально присутствует в location и работает.

Решение 2. add_header_inherit merge - нативно, начиная с nginx 1.29.3. Свежий ответ команды nginx на эту боль. Директива add_header_inherit меняет правило наследования: параметр merge заставляет дочерний уровень дополнять родительский набор, а не затирать его. Параметр off, наоборот, отключает наследование. Указываешь merge один раз на верхнем уровне - и дальше всё наследуется рекурсивно:

Код: Выделить всё

http {
    add_header_inherit merge;     # nginx 1.29.3+

    server {
        add_header X-Content-Type-Options "nosniff" always;
        add_header X-Frame-Options "SAMEORIGIN" always;

        location /api/ {
            # теперь родительские НЕ теряются - они смержены
            add_header Access-Control-Allow-Origin "https://shop.example.com" always;
            proxy_pass http://backend;
        }
    }
}
Это самый чистый путь, если у тебя nginx 1.30 stable или новее (директива доступна с 1.29.3). На старых версиях её нет - проверяй nginx -v перед тем как закладываться.

Решение 3. more_set_headers из модуля headers-more - кумулятивен по природе. Сторонний динамический модуль (ngx_http_headers_more_filter_module от той же команды, что и OpenResty), в эталонном проекте на OpenResty он доступен из коробки. Его директивы more_set_headers наследуются нормально: заголовки с родительских уровней не исчезают при добавлении нового в дочернем. more_set_headers вдобавок умеет то, чего add_header не может в принципе:
  • заменять заголовок, а не добавлять второй с тем же именем (add_header всегда плодит дубли);
  • ставить заголовки на ЛЮБОЙ статус ответа без плясок с always;
  • условно ставить по статусу: more_set_headers -s 404 ...;
  • удалять заголовки через more_clear_headers (об этом ниже).

Код: Выделить всё

server {
    more_set_headers "X-Content-Type-Options: nosniff";
    more_set_headers "X-Frame-Options: SAMEORIGIN";

    location /api/ {
        # родительские more_set_headers остаются, этот добавляется сверху
        more_set_headers "Access-Control-Allow-Origin: https://shop.example.com";
        proxy_pass http://backend;
    }
}
Важная тонкость в синтаксисе: у more_set_headers имя и значение пишутся в одной строке через двоеточие, как настоящий HTTP-заголовок, а у add_header имя и значение - два отдельных аргумента. Не перепутай. И ещё: add_header и more_set_headers работают на разных стадиях фильтра ответа, more_set_headers применяется позже. Если смешать оба в одном конфиге, легко получить кашу из дублей. Выбирай один подход для проекта и держись его.

Итог по граблям: на ванильном свежем nginx бери add_header_inherit merge; если модуль headers-more уже в сборке (OpenResty, многие дистрибутивные пакеты) - more_set_headers удобнее и мощнее; на совсем старом nginx без модулей - повторяй набор через include.

proxy_set_header: пробрасываем контекст клиента бэкенду

Теперь вторая сторона. Когда nginx проксирует запрос, по умолчанию бэкенд видит nginx, а не клиента. С точки зрения бэкенда соединение пришло с адреса nginx, заголовок Host может быть не тем, протокол неизвестен. Бэкенду нужно рассказать правду о клиенте - это и делает proxy_set_header. Без модуля realip (ему посвящён следующий урок) бэкенд иначе вообще не узнает реальный IP клиента: $remote_addr на бэкенде будет адресом nginx.

Канонический набор для проксирования, ровно как в global_proxy эталонного gateway:

Код: Выделить всё

location / {
    proxy_pass http://backend;

    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host  $host;

    proxy_http_version 1.1;
    proxy_set_header Connection "";
}
Разберём построчно, потому что тут каждая строка несёт смысл.

Host $host - бэкенду уходит имя хоста из запроса клиента, а не имя upstream-сервера. Многие приложения по Host выбирают виртуальный хост, генерируют абсолютные ссылки, разделяют тенантов. Если не поставить, в Host попадёт значение из proxy_pass (имя апстрима), и приложение запутается. Тонкость: $host это имя из строки запроса или из заголовка Host, приведённое к нижнему регистру и без порта. Есть ещё $http_host (ровно как прислал клиент, с портом) и $server_name. В 90 процентах случаев нужен именно $host.

X-Real-IP $remote_addr - один реальный IP-адрес клиента (точнее, того, кто подключился к nginx). Это де-факто стандарт nginx для "адреса клиента". Приложение читает X-Real-IP, чтобы знать, кто пришёл. Это и есть тот самый nginx x-real-ip, который ищут в каждом гайде.

X-Forwarded-For $proxy_add_x_forwarded_for - история проксей. nginx x-forwarded-for это список IP через запятую: каждый прокси дописывает в конец адрес, с которого к нему пришёл запрос. Переменная $proxy_add_x_forwarded_for сама делает магию - берёт значение X-Forwarded-For, которое прислал клиент, и добавляет к нему $remote_addr. Если перед nginx стоит ещё CDN или балансировщик, цепочка нарастает: 203.0.113.7, 10.0.0.5. Грабля безопасности: клиент может прислать поддельный X-Forwarded-For, и $proxy_add_x_forwarded_for честно припишет подделку в начало цепочки. Поэтому доверять можно только тем позициям в цепочке, что добавили твои собственные прокси. Как корректно вычислять реальный IP из этой цепочки - тема следующего урока про realip и set_real_ip_from.

X-Forwarded-Proto $scheme - http или https. Критично: если TLS терминируется на nginx, а до бэкенда идёт чистый http, бэкенд думает что соединение незащищённое и может генерировать http-ссылки, ломать редиректы, городить бесконечный цикл "редирект на https". $scheme = реальная схема, по которой клиент пришёл к nginx. Без этого заголовка фреймворки вроде Laravel, Django, Rails не понимают, что снаружи был https.

Про proxy_http_version 1.1 и Connection "" - это про keepalive к бэкенду. На современном nginx (с 1.29.7, вошло в stable 1.30) соединение к бэкенду по умолчанию уже HTTP/1.1 с keepalive, раньше был HTTP/1.0 с close на каждый запрос. Но для совместимости и явности привычка прописывать proxy_http_version 1.1 и пустой Connection (чтобы не утёк хоп-бай-хоп заголовок Connection: close) остаётся хорошим тоном. Это включает переиспользование соединений к апстриму и снимает оверхед на постоянные TCP+TLS-рукопожатия.

Сквозная трассировка: трассирующие nginx заголовки через цепочку хопов

Когда запрос идёт через несколько nginx-узлов и бэкендов, без сквозного идентификатора отладка превращается в гадание. Эталонный gateway решает это связкой proxy_set_header и map-цепочки. Идея: на входе подхватить X-Request-ID от клиента, если его нет - сгенерировать из встроенной $request_id, и протащить дальше вместе с цепочкой пройденных хопов.

Код: Выделить всё

# global_trace: вычисляем сквозной идентификатор
map $http_x_request_id $trace_request_id {
    default $http_x_request_id;   # пришёл извне - уважаем
    ""      $request_id;          # нет - генерим (16 hex-байт от nginx)
}

# в global_proxy пробрасываем трассировку бэкенду и следующему хопу
proxy_set_header X-Request-ID    $trace_request_id;
proxy_set_header X-HOP-Name      $hop_name;
proxy_set_header X-Request-Chain $request_hops_chain;
$request_id это встроенная переменная nginx - уникальная 32-символьная hex-строка на каждый запрос, бесплатный источник идентификатора. $hop_name в эталоне берётся из map по $hostname (логическая роль узла, например api-nginx-01), а $request_hops_chain накапливает имена хопов через запятую по мере прохождения цепочки. В логах (структурный log_format с escape=json) те же поля пишутся в каждую строку - и ты можешь grep-нуть один trace_request_id через все узлы и собрать полный путь запроса. Это фундамент distributed tracing на голом nginx, ещё до всякого OpenTelemetry.

Условные заголовки, удаление и проброс: тонкая работа

Условный заголовок через map. add_header не умеет if по-человечески (if в nginx коварен), зато отлично дружит с map. Хитрость: если значение переменной пустая строка, add_header такой заголовок вообще не отправляет. Этим и пользуемся - включаем заголовок только при нужном условии:

Код: Выделить всё

map $sent_http_content_type $cache_control_value {
    default                  "no-store";
    "~*application/json"     "no-cache";
    "~*text/css"             "public, max-age=2592000";
    "~*application/javascript" "public, max-age=2592000";
}

server {
    add_header Cache-Control $cache_control_value always;
}
Тут Cache-Control подстраивается под Content-Type ответа. А чтобы заголовок появлялся только иногда, отдай в map пустую строку для default - и для всех неподходящих случаев заголовка просто не будет.

Удаление заголовков. Два инструмента под разные задачи.

proxy_hide_header убирает заголовок, который прислал бэкенд, до того как ответ уйдёт клиенту. Классика - спрятать палево о бэкенде:

Код: Выделить всё

location /api/ {
    proxy_pass http://backend;
    proxy_hide_header X-Powered-By;      # PHP/Express любят это слать
    proxy_hide_header Server;            # версия бэкенда
    proxy_hide_header X-AspNet-Version;
}
Парная директива proxy_pass_header делает обратное: по умолчанию nginx не передаёт клиенту некоторые заголовки бэкенда (Date, Server, X-Accel-... и пара других), а proxy_pass_header принудительно их пропускает, если зачем-то нужно. Нужно это редко, но знать полезно.

more_clear_headers (из headers-more) сильнее: чистит любой заголовок ответа независимо от того, добавил его бэкенд, nginx или сам модуль, и умеет шаблоны:

Код: Выделить всё

more_clear_headers "X-Powered-By";
more_clear_headers "Server";
more_clear_headers -s 404 "Cache-Control";   # только на 404
Разница на пальцах: proxy_hide_header работает только с заголовками от апстрима и только в контексте проксирования; more_clear_headers - универсальный ластик по финальному набору заголовков ответа.

И сразу про устаревшее, чтобы не тащить из старых гайдов: X-XSS-Protection в 2026 считается мёртвым. OWASP прямо рекомендует его не слать (или слать значение 0) и полагаться на Content-Security-Policy. Если видишь X-XSS-Protection в чужом конфиге - это легаси, не копируй.

Мини-лаба: безопасные дефолты плюс проброс контекста

Соберём руками рабочий фрагмент и проверим оба эффекта - и ответные заголовки, и то, что уходит бэкенду. Понадобится nginx (лучше 1.30+, чтобы был add_header_inherit) и любой эхо-бэкенд, который покажет полученные заголовки. Подойдёт mendhak/http-https-echo или python -m http.server для статики, но эхо нагляднее.
  • Шаг 1. Создай /etc/nginx/snippets/security_headers.conf с тремя строками: add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "no-referrer" always;
  • Шаг 2. В server подключи include snippets/security_headers.conf; и подними два location: / и /api/. Во втором добавь свой add_header (например Access-Control-Allow-Origin) И не забудь снова include тот же сниппет - воспроизведи правильное решение грабли.
  • Шаг 3. В обоих location пропиши блок proxy_set_header: Host $host, X-Real-IP $remote_addr, X-Forwarded-For $proxy_add_x_forwarded_for, X-Forwarded-Proto $scheme, плюс proxy_http_version 1.1 и Connection "".
  • Шаг 4. nginx -t, затем nginx -s reload. Никаких правок без проверки конфига - это рефлекс.
  • Шаг 5. Проверь ответные заголовки на ОБОИХ путях: curl -sI http://localhost/ и curl -sI http://localhost/api/. Убедись, что security-набор присутствует в обоих. Потом ради эксперимента убери include из /api/, перезапусти и посмотри, как заголовки исчезли - почувствуй грабли руками.
  • Шаг 6. Проверь, что уходит бэкенду: curl -s http://localhost/api/ и найди в эхо-ответе X-Real-IP, X-Forwarded-For, X-Forwarded-Proto. Пошли curl с подставным заголовком: curl -s -H "X-Forwarded-For: 1.2.3.4" http://localhost/api/ - и увидь, как твоя подделка приклеилась в начало цепочки. Это и есть та дыра, которую закроет realip в следующем уроке.
Контрольные вопросы
  • Ты добавил один add_header внутри location, и часть заголовков из server пропала именно на этом location. Почему так и какие три способа это починить ты знаешь?
  • В чём разница между $host, $http_host и значением, которое попадёт в Host без proxy_set_header Host?
  • Зачем бэкенду X-Forwarded-Proto $scheme и что сломается без него при TLS-терминации на nginx?
  • Чем proxy_hide_header отличается от more_clear_headers, и какой из них уберёт заголовок, добавленный самим nginx?
Итог

add_header правит ответ клиенту и наследуется по принципу "всё или ничего": один add_header в дочернем контексте отменяет весь родительский набор. Лечится повтором набора через include, нативной add_header_inherit merge (nginx 1.29.3+) или кумулятивным more_set_headers из headers-more. proxy_set_header правит запрос к бэкенду: Host $host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto $scheme плюс трассирующие X-Request-ID и цепочка хопов - так бэкенд узнаёт правду о клиенте и так строится сквозная трассировка. Удаление - proxy_hide_header для заголовков апстрима и more_clear_headers как универсальный ластик. Главное, что ты унёс: после любой правки заголовков проверяй curl -I по разным location, не верь конфигу на слово. И помни про дыру X-Forwarded-For - её мы закроем в следующем уроке про realip.
👍6 ❤️ 🔥1 😄 🤔2
Аватара пользователя
xsoldier
Сообщения: 1
Зарегистрирован: 05 июн 2026, 02:40

Re: Работа с заголовками: add_header и proxy_set_header

Сообщение xsoldier »

А add_header_inherit merge уже точно в стабильной 1.30 есть? У меня на проде как раз 1.30, не хочется тащить headers-more если можно нативно обойтись.
👍2 ❤️ 🔥 😄 🤔1
Аватара пользователя
zfs98
Сообщения: 1
Зарегистрирован: 14 май 2026, 11:14

Re: Работа с заголовками: add_header и proxy_set_header

Сообщение zfs98 »

Вот это про all-or-nothing реально больно. У меня ровно так CSP отвалился на /api/ после добавления CORS, месяц не замечал. curl -I по каждому location теперь святое правило.
👍3 ❤️1 🔥 😄 🤔
Ответить
← Предыдущая глава
rewrite, return и канонизация URL
Следующая глава →
Модель доверия прокси-заголовков: realip и защита от спуфинга

Все главы курса «Nginx профессионально: от первого конфига до API-gateway на OpenResty и Angie»

Поделиться темой: ✈ Telegram VK
Похожие запросы: Безопасность nginx: заголовки и hardeningЗаголовки в nginx: add_header и proxy_set_headerrealip в nginx: реальный IP за прокси и CDN

Вернуться в «Nginx профессионально: от первого конфига до API-gateway на OpenResty и Angie»

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и 1 гость