1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6

# теория · шаг 1 из 6

Что бэкенд перестаёт видеть

Коротко

Между клиентом и приложением появился посредник, и приложение теперь видит его, а не клиента. Потерянное возвращают четырьмя строками proxy_set_header.

  • По умолчанию бэкенд получает Host: app:8000 - адрес из proxy_pass, а не имя сайта. IP клиента и схему он не видит вовсе.
  • Хоп-хоп-заголовки (Connection, Upgrade) nginx до бэкенда не доносит.
  • proxy_set_header наследуется всё или ничего: одна своя строка в location отменяет весь набор сверху. Симптом - Host молча вернулся к app:8000.
  • Пустое значение убирает заголовок. У Host не убирает: он возвращается к умолчанию.

Если четыре строки набора знакомы и правило «всё или ничего» тоже - листай до «Теперь сам».

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

/etc/nginx/conf.d/shop.local.confhttp
server {
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;

    location /api/ {
        proxy_set_header X-Api-Key "secret";
        proxy_pass http://app:8000/;
    }
}

Заголовки объявлены на уровне server, в location добавлен ещё один. Какой Host увидит приложение на запросе к /api/cart?

Стенд отвечает app:8000. Три строки сверху не действуют, потому что в блоке появилась своя. Отладка такого выглядит абсурдно: «мы же передаём Host, вот он, строкой выше».

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

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

Получатель, которому нужно ответить отправителю, смотрит в накладную и пишет на склад. Ошибки тут нет: посредник честно назвал себя. Просто про исходного отправителя в накладной не сказано ничего, и, если это важно, отправителя вписывают отдельной строкой.

Механизм: что меняется на границе

браузер Host: shop.local адрес 203.0.113.9 схема https Connection: ... nginx своё соединение приложение видит Host: app:8000 адрес 172.24.0.5 схема http Connection: нет запрос
Три потери и один отброшенный класс заголовков. Остальное едет как есть.

Заголовки клиента nginx передаёт почти все: User-Agent, Accept, Cookie, Authorization. Отбрасывает он хоп-хоп-заголовки - те, что относятся к одному участку пути, а не ко всему маршруту: Connection, Upgrade, Keep-Alive, Transfer-Encoding. Их отсутствие ниже по цепочке правильно и станет темой отдельной главы про WebSocket. Замер на nginx 1.31.5: к бэкенду он ходит по HTTP/1.1 и Connection не шлёт вовсе. Адрес 172.24.0.5 на схеме - адрес самого nginx в сети Docker: его приложение и видит вместо клиента.

Правило

/etc/nginx/proxy_headers.confhttp server location
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;

$host - имя, которое запросил клиент: в нижнем регистре и без порта. Стенд на запросе с Host: HOST.local:8443 даёт $host = host.local, $http_host = HOST.local:8443, $proxy_host = app:8000. Нужен порт - бери $http_host, но помни, что это сырая строка от клиента.

$scheme - схема запроса к nginx, http или https. $proxy_add_x_forwarded_for берёт пришедший заголовок и дописывает к нему адрес клиента, открывшего соединение ($remote_addr). Пришло 203.0.113.9 - уйдёт 203.0.113.9, 172.24.0.1.

Разбор: что видит бэкенд до и после

Бэкендом на стенде служит nc -l -p 8001 - программа, которая печатает пришедший запрос целиком. Два блока: /raw/ проксирует без единой строки proxy_set_header, /full/ - с набором из четырёх. Клиент в обоих случаях один и тот же:

$  команда
curl -s -H 'X-Forwarded-For: 203.0.113.9' -H 'Cookie: a=1' -H 'Accept-Encoding: gzip' http://shop.local/raw/cart
↳  выводnc -l -p 8001
GET /raw/cart HTTP/1.1
Host: 127.0.0.1:8001
User-Agent: curl/8.22.0
Accept: */*
X-Forwarded-For: 203.0.113.9
Cookie: a=1
Accept-Encoding: gzip
$  команда
curl -s -H 'X-Forwarded-For: 203.0.113.9' -H 'Cookie: a=1' -H 'Accept-Encoding: gzip' http://shop.local/full/cart
↳  выводnc -l -p 8001
GET /full/cart HTTP/1.1
Host: shop.local
X-Real-IP: 127.0.0.1
X-Forwarded-For: 203.0.113.9, 127.0.0.1
X-Forwarded-Proto: http
User-Agent: curl/8.22.0
Accept: */*
Cookie: a=1
Accept-Encoding: gzip

В первом дампе Host - адрес из proxy_pass, на стенде 127.0.0.1:8001 (в Docker это было бы app:8000).

Обрати внимание на первый дамп: X-Forwarded-For: 203.0.113.9 доехал до бэкенда без всякой настройки - его прислал клиент, а nginx передал как обычный заголовок. Это и есть причина, по которой заголовку нельзя верить: любой может написать в нём что угодно, и без специальных мер это доедет до приложения.

Разбор: наследование всё или ничего

Правило то же, что у add_header из второго раздела. Замер по четырём блокам, у каждого свой набор строк в location при трёх объявленных на уровне server:

в location что получил бэкенд
ничего Host: shop.local, X-Real-IP на месте
proxy_set_header X-Api-Key secret Host - адрес из proxy_pass, X-Real-IP пропал
proxy_set_header Accept-Encoding "" Accept-Encoding убран, набор сверху пропал
proxy_set_header Host "" Host - адрес из proxy_pass, набор сверху пропал

Третья строка показывает второй приём: пустое значение убирает заголовок. Так снимают Accept-Encoding, когда от бэкенда нужен несжатый ответ.

Четвёртая показывает исключение: Host убрать нельзя. Пустое значение возвращает его к умолчанию, то есть к $proxy_host, - запрос без Host в HTTP/1.1 недопустим, и nginx его не отправит.

Лечится всё это одинаково: общий набор держат отдельным файлом и подключают в каждый проксирующий блок. Файл кладут рядом с nginx.conf, а не в conf.d: всё из conf.d/*.conf nginx подключает в http сам, и набор оказался бы там вторым экземпляром.

/etc/nginx/conf.d/shop.local.confhttp server location /api/
include /etc/nginx/proxy_headers.conf;
proxy_set_header X-Api-Key "secret";
proxy_pass http://app:8000;

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

Host: app:8000 уезжает в места, где его никто не ждёт. Приложение строит по нему абсолютные ссылки, и человек получает письмо со ссылкой на сброс пароля вида http://app:8000/reset?token=.... Проверка источника формы не проходит, потому что домен в заголовке не совпал с настроенным. Мультидоменное приложение показывает не тот сайт: имени, по которому выбирать, у него нет.

Потерянная схема даёт цикл: сайт живёт на https, фреймворк видит http и отправляет клиента на https, оттуда снова приходит запрос со схемой http. Браузер сдаётся после десятка кругов и пишет ERR_TOO_MANY_REDIRECTS.

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

Четыре строки набора - первое, что пишут в любом проксирующем блоке, и первое, что проверяют, когда приложение ведёт себя странно. Увидеть, что nginx на самом деле отправил, можно тем же приёмом, что в дампах выше: временно направь проблемный блок на proxy_pass http://127.0.0.1:8001;, запусти рядом nc -l -p 8001 и отправь запрос. Приём с add_header X-Debug-Host $host тут не годится: он печатает значение переменной, а не заголовок, который ушёл, - и поломку наследования не покажет.

Теперь сам

В блоке server объявлены все четыре строки набора. В location /upload/ добавили proxy_request_buffering off; - и загрузка перестала работать, а приложение пишет, что не узнаёт домен. Виноват ли добавленный off?

Нет. proxy_request_buffering - другая директива, наследование proxy_set_header она не трогает. Ищи в этом же блоке добавленный proxy_set_header - скорее всего, рядом появилась строка вроде proxy_set_header Content-Type ..., и она увела весь набор. Проверяется дампом заголовков на бэкенде.

Главное

За прокси приложение теряет три вещи: имя сайта в Host, адрес клиента и схему. Возвращают их четыре строки: Host $host, X-Real-IP $remote_addr, X-Forwarded-For $proxy_add_x_forwarded_for, X-Forwarded-Proto $scheme. proxy_set_header наследуется всё или ничего, поэтому набор держат отдельным файлом и подключают через include в каждый блок. Пустое значение убирает заголовок, кроме Host - он возвращается к адресу из proxy_pass. И помни, что X-Forwarded-For от клиента доезжает до бэкенда сам по себе: доверять ему без realip нельзя.

Комментарии

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

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

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