АйТи Фреш
Главная / Статьи / Безопасность
Безопасность

Как безопасно получить IP клиента за цепочкой прокси

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~17 мин чтения
Как безопасно получить IP клиента за цепочкой прокси
Иллюстрация к статье «Как безопасно получить 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 разбирает результат справа налево.

Не применяйте IP из X-Forwarded-For для доступа к административным функциям, пока не закрыли прямое подключение к origin и не описали все доверенные хопы.

Как я строю цепочку доверия

Перед конфигом я рисую путь одного запроса вместе с адресами интерфейсов. Например: клиент 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 доверенным только по имени заголовка нельзя.

Не добавляйте в доверенные сети весь RFC1918-диапазон только потому, что прокси имеют частные адреса. Доверять `10.0.0.0/8` означает доверять также случайной виртуальной машине, ноутбуку подрядчика в офисной сети и скомпрометированному контейнеру в этой сети.
Как безопасно получить IP клиента за цепочкой прокси — схема
Схема к статье. Открыть схему в полном размере

Сайт «КлёвЛавки»: где всё сломалось

Название «КлёвЛавка» условное, проект и адреса обезличены. Это рыболовный магазин на 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 действительно стоит CDN или операторский WAF, `$remote_addr` будет адресом CDN. Тогда сначала настройте доверие по официально публикуемым диапазонам провайдера либо используйте его документированный заголовок. Не копируйте этот фрагмент без поправки на фактическую схему.
Порядок действий: Сайт «КлёвЛавки»: где всё сломалось — схема
Порядок действий: Сайт «КлёвЛавки»: где всё сломалось. Открыть схему в полном размере

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;
        }
    }
}
Обычный `allow 10.20.0.0/24` внутри того же server-блока может проверять уже заменённый `$remote_addr`, а не TCP-пира. Доступ к порту origin я ограничиваю межсетевым экраном или security group по адресам HAProxy; журнал `$realip_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, а peer10.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 для формы входа в личный кабинет и антифрод корзины перевели на нормализованный адрес. Общих блокировок больше не возникло, при этом конфигурация ВМ и число экземпляров не изменились.

Не публикуйте диагностический endpoint, который без авторизации возвращает всю цепочку 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 check
server {
    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` на порту, куда ходят обычные браузеры или мониторинг: NGINX будет ожидать заголовок PROXY в каждом соединении, и сайт перестанет открываться у всех, кто подключается напрямую.

Что поддерживать после внедрения и когда нужен 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 ради одного только соответствия стандарту можно отложить: модель доверия от смены имени заголовка не станет безопаснее.

Если приложение само разбирает XFF, его список доверенных прокси должен соответствовать сетевой схеме. Я предпочитаю нормализовать адрес один раз на серверном NGINX и не давать каждому фреймворку независимо угадывать цепочку.
Памятка: Что поддерживать после внедрения и когда нужен PROXY protocol — схема
Памятка: Что поддерживать после внедрения и когда нужен PROXY protocol. Открыть схему в полном размере

Частые вопросы

Нужно брать первый или последний адрес из 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, а его посетитель может подставить сам.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи