Как безопасно получить IP клиента за цепочкой прокси
В журнале снова один и тот же адрес балансировщика, блокировка по IP задевает всех пользователей, а приложение послушно принимает любой X-Forwarded-For. Я, Семёнов Евгений Сергеевич, несколько раз разбирал такие схемы после ложных блокировок и неработающего rate limit на сайтах небольших магазинов. Ниже покажу мой рабочий вариант: где установить границу доверия, как пройти цепочку справа налево и как доказать тестами, что подставленный заголовок не превратится в «настоящий» адрес.
X-Forwarded-For — это показание, а не удостоверение личности
Сначала важная оговорка. Под «настоящим IP» я понимаю ближайший к нашей инфраструктуре недоверенный сетевой узел. Если покупатель заходит с мобильного интернета за CGNAT оператора, из офиса за корпоративным NAT или из Wi‑Fi торгового центра, адрес его телефона по HTTP-запросу не определить. Мы увидим внешний адрес этой сети. Для журналирования, ограничения частоты запросов и расследования инцидентов этого обычно достаточно.
Сам заголовок X-Forwarded-For, или XFF, ничего не доказывает. Клиент вправе прислать X-Forwarded-For: 127.0.0.1, адрес директора, конкурента или целую строку из десятков значений. Стандартизованный заголовок Forwarded из RFC 7239 не решает проблему доверия: в разделе 8.1 спецификация прямо предупреждает, что на значение нельзя полагаться: его может изменить, по ошибке или умышленно, любой узел на пути к серверу. Ценность появляется только тогда, когда значение добавил известный нам прокси, а запрос физически пришёл именно от него.
Из раза в раз я вижу две ошибки. Первая — доверить 0.0.0.0/0 и ::/0, после чего любой посетитель назначает себе адрес одной строкой curl. Вторая — брать первый элемент XFF, потому что «клиент всегда слева». Злоумышленник как раз может дописать значения слева. Я выбираю другую модель: внешний прокси уничтожает входной XFF и создаёт его заново, остальные доверенные прокси дописывают свой непосредственный источник, а origin разбирает результат справа налево.
- TCP-адрес соединения нельзя подменить обычным HTTP-заголовком, но его может скрыть прокси.
- Каждый доверенный узел подтверждается своим IP или узкой подсетью, а не самим фактом наличия XFF.
- Первый недоверенный адрес при движении справа налево считается клиентским.
- Адреса левее найденного значения не используются для авторизации и блокировок.
Как я строю цепочку доверия
Перед конфигом я рисую путь одного запроса вместе с адресами интерфейсов. Например: клиент 203.0.113.77 подключается к внешнему NGINX 172.20.10.11; тот идёт в HAProxy 10.20.0.21; HAProxy открывает соединение к origin 10.30.0.31. На origin TCP-пиром будет 10.20.0.21, а корректный XFF — 203.0.113.77, 172.20.10.11. Последний прокси в заголовок не входит: его адрес уже известен из соединения.
Разбираем справа. Origin доверяет непосредственному пиру 10.20.0.21, поэтому смотрит на соседний адрес в XFF. 172.20.10.11 тоже находится в нашем списке, значит продолжаем. 203.0.113.77 не доверенный — на нём останавливаемся. Именно это делает NGINX с real_ip_recursive on: по документации ngx_http_realip_module адрес пира, совпавший с set_real_ip_from, заменяется последним недоверенным адресом из заголовка. При real_ip_recursive off (значение по умолчанию) берётся просто последний адрес, и в нашей цепочке это был бы edge 172.20.10.11.
Почему я всё равно перезаписываю XFF на периметре, хотя рекурсивный разбор уже отсекает добавленный слева мусор? Так проще проверять и сопровождать схему. За внешней границей остаётся ровно одна известная точка начала цепочки. Если позже появится CDN, его адреса и правила обработки придётся явно добавить перед нашим edge. Автоматически считать CDN доверенным только по имени заголовка нельзя.
- Составить перечень всех HTTP- и L4-прокси по пути запроса.
- Выделить точные адреса или служебные CIDR каждого управляемого хопа.
- На первом своём HTTP-прокси заменить входной XFF адресом TCP-клиента.
- На промежуточных прокси добавлять адрес непосредственного источника справа.
- На последнем NGINX включить рекурсивный разбор и передать приложению уже нормализованный один адрес.
Сайт «КлёвЛавки»: где всё сломалось
Название «КлёвЛавка» условное, проект и адреса обезличены. Это рыболовный магазин на 34 рабочих места: торговый зал, склад, небольшой офис и два оператора, которые принимают заказы с сайта. Интернет-магазин со снастями, личным кабинетом и выгрузкой остатков из учётной системы досталась нам от прежнего подрядчика с «запасом на рост»: два внешних NGINX, пара HAProxy и два серверных NGINX перед приложением. В обычные дни сайт обрабатывал около 180 тыс. HTTP-запросов в сутки, в весенний сезон и перед открытием летней рыбалки пик доходил до 45 запросов в секунду. В 99,2 % строк журнала стоял один из адресов HAProxy. Однажды Fail2ban после волны перебора паролей к личному кабинету заблокировал один балансировщик, и примерно половина покупателей получила ошибку при оформлении заказа. Это был не «сбой безопасности», а закономерный итог неправильной идентификации.
Для контрольного стенда к публикации я повторил схему на NGINX 1.30.4 stable и HAProxy 3.4.4 — это текущий stable NGINX от 15 июля 2026 года и версия, для которой опубликован действующий мануал HAProxy ветки 3.4. Каждый edge и HAProxy работал на ВМ с 1 vCPU и 1 Гбайт ОЗУ, серверы приложения — с 2 vCPU и 4 Гбайт: для такого магазина этого с запасом. Дополнительного железа обработка адреса не потребовала; здесь важнее корректная сборка NGINX. Модуль realip не собирается по умолчанию из исходников, поэтому перед изменениями я проверяю наличие --with-http_realip_module.
На внешнем NGINX я намеренно не использовал $proxy_add_x_forwarded_for: эта переменная сохраняет полученный заголовок и добавляет $remote_addr. На границе мне нужно не продолжить неизвестную цепочку, а начать собственную.
# NGINX 1.30.4, внешний узел
upstream shop_haproxy {
server 10.20.0.21:8080;
server 10.20.0.22:8080 backup;
}
server {
listen 443 ssl;
server_name shop.example.com;
ssl_certificate /etc/nginx/tls/shop.crt;
ssl_certificate_key /etc/nginx/tls/shop.key;
location / {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://shop_haproxy;
}
}- Проверить, что `nginx -V` показывает `--with-http_realip_module`: из исходников модуль по умолчанию не собирается.
- Нарисовать путь запроса с адресами каждого хопа, включая резервные узлы.
- Выгрузить из журнала долю строк, где клиентом записан адрес собственного прокси.
- Найти все места, где решение принимается по IP: Fail2ban, `limit_req`, антифрод корзины, allow-списки админки.
- Отключить автоблокировку по IP на время перенастройки, чтобы не повторить общую аварию.
HAProxy добавляет хоп, а origin выбирает клиента
HAProxy принимал HTTP только от двух edge-узлов. Директива option forwardfor в HAProxy 3.4 вставляет заголовок X-Forwarded-For с адресом источника в конец списка заголовков запроса; мануал прямо требует, чтобы сервер за ним учитывал последнее вхождение, потому что клиент мог принести своё. Я не ставлю if-none: с ним HAProxy добавит заголовок только при его отсутствии, и документация предупреждает, что это допустимо лишь в полностью доверенной среде. Проверка источника в HAProxy — второй рубеж; сетевой ACL между сегментами оставался первым.
# HAProxy 3.4.4
frontend from_edge
bind :8080
mode http
acl trusted_edge src 172.20.10.11 172.20.10.12
http-request deny deny_status 403 unless trusted_edge
default_backend app_nginx
backend app_nginx
mode http
option forwardfor
balance roundrobin
server app1 10.30.0.31:8080 check
server app2 10.30.0.32:8080 checkНа серверном NGINX доверенными должны быть и непосредственные HAProxy, и edge-узлы, адреса которых встречаются внутри цепочки. Если указать только HAProxy, рекурсивный поиск честно остановится на edge и в журнале опять появится прокси. Если указать всех подряд, подмена снова станет возможной. Список лучше вынести в отдельный включаемый файл и менять вместе с сетевой схемой.
# /etc/nginx/conf.d/trusted-proxies.conf
set_real_ip_from 10.20.0.21;
set_real_ip_from 10.20.0.22;
set_real_ip_from 172.20.10.11;
set_real_ip_from 172.20.10.12;
real_ip_header X-Forwarded-For;
real_ip_recursive on;Я журналирую сразу три сущности: вычисленный адрес, реального TCP-пира и сырой XFF. Без этого расследовать ошибку доверия мучительно. Приложению передаю уже очищенное одиночное значение и не использую на этом этапе $proxy_add_x_forwarded_for, иначе после замены $remote_addr можно получить дублирование адреса.
http {
include /etc/nginx/conf.d/trusted-proxies.conf;
log_format proxy_chain escape=json
'{"time":"$time_iso8601",'
'"client":"$remote_addr",'
'"peer":"$realip_remote_addr",'
'"xff":"$http_x_forwarded_for",'
'"request":"$request",'
'"status":$status}';
upstream shop_app {
server 127.0.0.1:9000;
}
server {
listen 8080;
access_log /var/log/nginx/shop-access.json proxy_chain;
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass http://shop_app;
}
}
}- На HAProxy пропускать HTTP только от адресов edge через `acl ... src` и `http-request deny`.
- Не использовать `option forwardfor if-none` там, где заголовок может прийти от посетителя.
- В `set_real_ip_from` перечислять только конкретные адреса HAProxy и edge, без широких CIDR.
- Включать `real_ip_header X-Forwarded-For` и `real_ip_recursive on` на серверном NGINX.
- Передавать приложению одно нормализованное значение и журналировать `$realip_remote_addr` рядом с `$remote_addr`.
Проверяем не счастливый путь, а попытку обмана
После настройки я сначала валидирую бинарники и конфиги. Только затем делаю reload. Для HAProxy проверка -c разбирает конфигурацию без запуска процесса. У NGINX по выводу -V видно, включён ли realip.
nginx -V 2>&1 | tr ' ' '\n' | grep -- --with-http_realip_module
nginx -t
haproxy -c -f /etc/haproxy/haproxy.cfg
systemctl reload nginx
systemctl reload haproxyГлавный тест — запрос с заведомо поддельной цепочкой через публичную точку. В лаборатории источник запроса имел адрес 203.0.113.77. Клиент прислал 198.51.100.55, 127.0.0.1, но edge удалил это значение. На origin пришёл XFF 203.0.113.77, 172.20.10.11, поле client стало 203.0.113.77, а peer — 10.20.0.21.
curl -sk -o /dev/null \
-H 'X-Forwarded-For: 198.51.100.55, 127.0.0.1' \
https://shop.example.com/health
tail -n 1 /var/log/nginx/shop-access.jsonНа стенде «КлёвЛавки» мы прогнали 1200 запросов по шести сценариям: обычный IPv4, IPv6, один поддельный адрес, длинная поддельная цепочка, обращение к HAProxy из недоверенной сети и прямое обращение к origin. Во всех запросах через штатный маршрут origin выбрал адрес лабораторного клиента; HAProxy вернул 403 постороннему источнику, а сетевой экран не допустил соединение с origin. После суток наблюдения Fail2ban, limit_req для формы входа в личный кабинет и антифрод корзины перевели на нормализованный адрес. Общих блокировок больше не возникло, при этом конфигурация ВМ и число экземпляров не изменились.
- Проверить запрос без XFF и с одним поддельным значением.
- Проверить несколько поддельных IPv4 и IPv6 слева.
- Проверить оба edge и оба HAProxy, а не только основной маршрут.
- Попробовать обратиться к HAProxy в обход edge.
- Проверить, что origin недоступен из пользовательских и контейнерных сетей.
- Сопоставить в журнале `client`, `peer` и сырой `xff`.
PROXY protocol, Forwarded и trusted proxies в приложении
Если бы в «КлёвЛавке» HAProxy работал в mode tcp и не расшифровывал TLS, XFF было бы некуда вписать. Тогда адрес передаётся PROXY protocol ещё до первого байта HTTP. У HAProxy это параметр send-proxy-v2 на строке server, у NGINX — параметр proxy_protocol директивы listen (с 1.5.12, вторая версия протокола — с 1.13.11). Адрес из заголовка доступен в $proxy_protocol_addr, а подменять им $remote_addr разрешает real_ip_header proxy_protocol, причём снова только для пиров из set_real_ip_from.
backend app_tcp
mode tcp
server app1 10.30.0.31:8443 send-proxy-v2 check
server app2 10.30.0.32:8443 send-proxy-v2 checkserver {
listen 8443 ssl proxy_protocol;
server_name shop.example.com;
set_real_ip_from 10.20.0.21;
set_real_ip_from 10.20.0.22;
real_ip_header proxy_protocol;
ssl_certificate /etc/nginx/tls/shop.crt;
ssl_certificate_key /etc/nginx/tls/shop.key;
}Заголовок Forwarded из RFC 7239 я встречаю в основном у облачных балансировщиков и в Java-стеке. Синтаксис строже, чем у XFF: Forwarded: for=203.0.113.77;proto=https;by=203.0.113.43, а IPv6 обязательно берётся в кавычки и квадратные скобки — for="[2001:db8:cafe::17]:4711". NGINX realip умеет брать адрес из произвольного поля, но разбирать параметры for= не станет, поэтому для Forwarded нужен код в приложении или отдельный модуль. Модель доверия от этого не меняется: разбор идёт справа налево, пока не встретится недоверенный узел.
Последний рубеж — фреймворк. В Express по документации app.set('trust proxy', true) делает клиентом самый левый адрес XFF, то есть ровно то, что может подделать посетитель. Там, где приложение слушает только локальный NGINX, я указываю loopback или конкретные адреса; тогда req.ip вычисляется справа налево до первого недоверенного адреса. Предустановка uniquelocal включает весь 10.0.0.0/8, 172.16.0.0/12 и 192.168.0.0/16 — это та же ошибка с широким RFC1918, только в коде.
// приложение слушает только 127.0.0.1, перед ним серверный NGINX
app.set('trust proxy', 'loopback');- PROXY protocol — для L4-балансировки без HTTP-терминации; порт с `proxy_protocol` закрыть от всех, кроме балансировщиков.
- Forwarded — если его формирует облачный балансировщик; разбирать `for=` с учётом кавычек и IPv6.
- X-Forwarded-For — в HTTP-цепочке, с перезаписью на своём edge и рекурсивным разбором на origin.
- Trusted proxies во фреймворке — только конкретные адреса или `loopback`, никогда `true` без перезаписи заголовка выше.
- Один источник истины: нормализовать адрес в одном месте и не давать двум слоям разбирать цепочку по-разному.
Что поддерживать после внедрения и когда нужен PROXY protocol
Такая схема ломается не от обновления NGINX, а от незадокументированного нового хопа. Появился резервный балансировщик, сменились адреса CDN, оркестратор поднял ingress в другой подсети — и либо в журнале снова виден прокси, либо администратор в спешке расширяет доверие до огромного CIDR. Я храню список доверенных адресов рядом с инфраструктурным кодом, проверяю его на стенде и сначала добавляю новый адрес, затем переключаю трафик. Старый удаляю только после вывода узла из эксплуатации.
PROXY protocol я применяю, когда между узлами идёт TCP-проксирование без HTTP-терминации. У HAProxy это send-proxy или send-proxy-v2, у принимающего NGINX — параметр proxy_protocol в listen и real_ip_header proxy_protocol. Но это не криптографическая подпись и не волшебный способ узнать пользователя. Порт должен принимать PROXY protocol только от доверенного отправителя, иначе обычное подключение завершится ошибкой, а при слишком широком доступе посторонний узел сможет подать собственный заголовок. Спецификация PROXY protocol прямо говорит, что получатель не должен пытаться угадывать, есть заголовок или нет, и должен пускать на такой порт только доверенные прокси. В смешанной HTTP-цепочке «КлёвЛавки» XFF оказался понятнее и легче для диагностики.
Мой порядок работ простой. Сначала закрыть прямой доступ к origin. Потом перезаписывать XFF на своей внешней границе. Затем перечислить точные доверенные прокси, включить рекурсивный разбор и писать в журнал одновременно клиентский и транспортный адреса. После этого — негативные тесты и мониторинг. Переезд с XFF на Forwarded ради одного только соответствия стандарту можно отложить: модель доверия от смены имени заголовка не станет безопаснее.
- Алерт на запросы к HAProxy не от edge-адресов.
- Алерт на `$realip_remote_addr`, отсутствующий в перечне штатных HAProxy.
- Проверка доверенных CIDR при каждом изменении маршрута.
- Негативные тесты после обновления прокси и фреймворка приложения.
- Ограниченный срок хранения сырых заголовков и доступ к журналам по необходимости.
Частые вопросы
Нужно брать первый или последний адрес из X-Forwarded-For?
Ни тот ни другой вслепую. Начинайте с реального TCP-пира и двигайтесь по XFF справа налево, пропуская только известные доверенные прокси. Первый недоверенный адрес и будет результатом.
Почему нельзя просто доверить все внутренние адреса?
Потому что тогда любой узел из большой внутренней сети сможет представить поддельный XFF как достоверный. Указывайте конкретные адреса балансировщиков или минимальные служебные подсети.
Что делать, если перед нами CDN с меняющимися адресами?
Использовать официальный список диапазонов CDN, автоматизировать его проверку и обновление, а прямой доступ к origin закрыть. Если провайдер передаёт клиента в собственном заголовке, доверять ему можно только для соединений от официальных адресов этого провайдера.
Безопаснее ли X-Real-IP?
Нет. Это такой же HTTP-заголовок, который клиент способен прислать сам. Он удобен после нормализации, потому что содержит одно значение, но безопасность по-прежнему определяется доверенным источником соединения.
Решит ли проблему переход на стандартный Forwarded?
Нет. RFC 7239 прямо описывает риск изменения заголовка клиентом и посредниками. Forwarded удобнее формализован, но требует той же границы доверия.
Когда выбирать PROXY protocol?
Когда адрес нужно передать через контролируемый L4-прокси до установления HTTP-сеанса. На принимающем порту всё равно необходимо разрешать соединения только от доверенных отправителей PROXY protocol. В NGINX это `listen ... proxy_protocol` и `real_ip_header proxy_protocol`, адрес доступен в `$proxy_protocol_addr`.
Какое значение trust proxy ставить во фреймворке?
Конкретные адреса прокси или loopback, если перед приложением стоит локальный NGINX. Значение true в Express берёт самый левый адрес X-Forwarded-For, а его посетитель может подставить сам.
Источники
- NGINX: модуль ngx_http_realip_module — Директивы set_real_ip_from, real_ip_header (X-Real-IP, X-Forwarded-For, proxy_protocol), real_ip_recursive (по умолчанию off), переменные $realip_remote_addr/$realip_remote_port; модуль собирается с --with-http_realip_module. https://nginx.org/en/docs/http/ngx_http_realip_module.html
- NGINX: модуль ngx_http_proxy_module — Директива proxy_set_header и семантика переменной $proxy_add_x_forwarded_for. https://nginx.org/en/docs/http/ngx_http_proxy_module.html
- NGINX: модуль ngx_http_log_module — Директивы log_format, access_log и параметр escape=json. https://nginx.org/en/docs/http/ngx_http_log_module.html
- NGINX News 2026 — Сведения о stable-релизе NGINX 1.30.4 от 15 июля 2026 года и исправлениях безопасности. https://nginx.org/2026.html
- NGINX: модуль ngx_http_core_module — Параметр proxy_protocol директивы listen (1.5.12, PROXY v2 с 1.13.11) и переменные $proxy_protocol_addr, $proxy_protocol_port. https://nginx.org/en/docs/http/ngx_http_core_module.html
- HAProxy 3.4.4 Configuration Manual — Раздел option forwardfor: добавление X-Forwarded-For в HTTP-режиме; синтаксис ACL и http-request deny. https://docs.haproxy.org/3.4/configuration.html#4-option%20forwardfor
- HAProxy 3.4 Configuration Manual (документ версии 3.4.4) — Официальный мануал ветки 3.4, раздел 4.2 option forwardfor: вставка X-Forwarded-For, except/header/if-none и предупреждение о недоверенной среде. https://docs.haproxy.org/3.4/configuration.html
- The PROXY protocol, Versions 1 & 2 — Спецификация Willy Tarreau в репозитории HAProxy: получатель не должен угадывать наличие заголовка, доступ только от доверенных прокси; раздел Security considerations. https://github.com/haproxy/haproxy/blob/master/doc/proxy-protocol.txt
- IETF RFC 7239 — Forwarded HTTP Extension, разделы 7 и 8 о цепочках прокси, целостности заголовка и модели доверия. https://datatracker.ietf.org/doc/html/rfc7239
- Express: Express behind proxies — Настройка trust proxy: значения true, loopback/linklocal/uniquelocal, число хопов; вычисление req.ip справа налево. https://expressjs.com/en/guide/behind-proxies.html
