Ты поднял балансировщик, поставил перед ним Cloudflare, добавил второй слой nginx внутри кластера - и вдруг в логах у тебя один и тот же IP на всех запросах. Rate limit срабатывает не на абьюзера, а на всю аудиторию сразу. Geo-блокировка пропускает кого попало. Авторизация по IP пускает чужих. Аналитика показывает, что 100% трафика идёт из одной точки. Знакомо?
Причина простая и фундаментальная. Переменная $remote_addr в nginx - это адрес того, кто открыл TCP-соединение к этому конкретному nginx. Если перед ним стоит балансировщик, CDN или ещё один прокси - то $remote_addr это адрес ближайшего прокси, а вовсе не клиента. Клиентский адрес едет внутри HTTP-заголовка (обычно X-Forwarded-For), но nginx по умолчанию на него вообще не смотрит. Для него это просто строка в запросе.
Поэтому этот урок идёт до уроков про rate limit, логи и авторизацию. Все они опираются на $binary_remote_addr и $remote_addr. Если ты не починил клиентский IP в самом начале обработки запроса, то строишь rate limit на лжи, считаешь географию по адресу дата-центра CDN и пишешь в лог бесполезные данные. Сначала восстанавливаем правду об источнике, потом всё остальное. Тема nginx real ip - это не косметика логов, это фундамент.

Механика модуля realip: как nginx подменяет $remote_addr
За восстановление клиентского адреса отвечает модуль ngx_http_realip_module. Он встроен в стандартную сборку, но включается флагом --with-http_realip_module - в официальных пакетах nginx, Angie и OpenResty он уже есть, проверять не нужно. Логика у него ровно три директивы.
Код: Выделить всё
set_real_ip_from 10.0.0.0/8; # кому мы ДОВЕРЯЕМ
real_ip_header X-Forwarded-For; # откуда брать настоящий адрес
real_ip_recursive on; # разворачивать цепочку прокси
Критичный момент, который многие упускают: если адрес соединения не входит в set_real_ip_from, модуль не делает ничего. Заголовок игнорируется целиком, $remote_addr остаётся как есть. Именно в этом и зашита защита от подделки, к ней вернёмся.
Что такое real_ip_recursive. Заголовок X-Forwarded-For - это не один адрес, а список через запятую, который каждый прокси по пути дополняет справа. Запрос прошёл клиент -> CDN -> твой внешний балансировщик -> внутренний nginx, и заголовок выглядит так:
Код: Выделить всё
X-Forwarded-For: 203.0.113.7, 198.51.100.10, 10.0.0.5
Спуфинг X-Forwarded-For: почему доверять можно только своим
Вот центральная мысль всего урока. Заголовок nginx x-forwarded-for - это просто текст, который кто угодно может прислать в запросе. Если твой сервер открыт в интернет напрямую (а его реальный IP рано или поздно утечёт - через DNS-историю, сертификаты, ошибки конфигов), злоумышленник подключится в обход CDN и пришлёт:
Код: Выделить всё
curl -H "X-Forwarded-For: 8.8.8.8" https://origin.example.com/admin
Защита ровно одна и она встроена в саму модель: set_real_ip_from должен содержать только подсети твоих реальных прокси и CDN. Тогда подмена сработает лишь для соединений, физически пришедших с этих адресов. Пакет напрямую от атакующего придёт с его собственного IP, который в доверенных сетях не значится - и его X-Forwarded-For будет проигнорирован полностью. $remote_addr останется настоящим адресом атакующего, и rate limit его поймает.
Из этого же следует, почему нельзя слепо пробрасывать клиентский X-Forwarded-For вглубь. Если внешний слой nginx уже восстановил клиента, то Connection и заголовки внутрь должны идти контролируемо. Хороший паттерн - внешний прокси ставит X-Real-IP $remote_addr (уже восстановленный) и добавляет себя в X-Forwarded-For через $proxy_add_x_forwarded_for, а внутренние слои доверяют только внешнему. В эталонном gateway ngx-trace-gateway проброс заголовков централизован в globals/proxy: туда вынесены X-Real-IP $remote_addr и X-Forwarded-For, чтобы каждый хоп добавлял ровно одно доверенное звено, а не переписывал чужие данные.Золотое правило: доверяй заголовку nginx real_ip_header РОВНО настолько, насколько доверяешь TCP-источнику, который его прислал. Никогда не пиши set_real_ip_from 0.0.0.0/0. Это эквивалент "верю любому, кто скажет, что он клиент".
Практика: правильный realip за CDN и балансировщиком
Разберём боевой конфиг для типового случая - origin за Cloudflare, плюс внутренний балансировщик. Сначала глобально, в http-блоке (или в отдельном include, как globals/realip в эталонном проекте).
Код: Выделить всё
# --- доверенные сети: ТОЛЬКО наши прокси и CDN ---
# выходные диапазоны Cloudflare (обновляются, держи актуальными)
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;
set_real_ip_from 2400:cb00::/32; # их IPv6 тоже
# наш собственный внутренний балансировщик
set_real_ip_from 10.0.0.0/8;
# за Cloudflare надёжнее их собственный заголовок - он содержит
# ровно один адрес клиента и его нельзя замусорить промежуточными хопами
real_ip_header CF-Connecting-IP;
real_ip_recursive on;
Проверим, что подмена действительно произошла. Добавим диагностический заголовок и сравним с прямым запросом:
Код: Выделить всё
location = /whoami {
add_header X-Seen-Client $remote_addr always;
return 200 "remote=$remote_addr xff=$http_x_forwarded_for\n";
}
Код: Выделить всё
$ curl -s -H "X-Forwarded-For: 1.2.3.4" -H "CF-Connecting-IP: 1.2.3.4" \
http://origin.example.com/whoami
remote=88.88.88.88 xff=1.2.3.4
PROXY protocol: реальный IP на уровне L4
HTTP-заголовки работают, только когда балансировщик разбирает HTTP (L7). Но если перед тобой стоит чисто транспортный балансировщик - L4-балансер, AWS NLB, HAProxy в режиме tcp, ssl-passthrough - он не трогает HTTP вообще, просто прокидывает байты TCP. Заголовок добавить некому. Здесь спасает PROXY protocol: балансировщик в самом начале TCP-соединения шлёт маленький служебный заголовок с реальными адресами клиента и сервера, ещё до TLS-хендшейка и до HTTP.
Включается так - параметр proxy_protocol в директиве listen:
Код: Выделить всё
server {
listen 443 ssl proxy_protocol; # ждём PROXY-заголовок в начале соединения
http2 on;
# источник, которому доверяем PROXY-заголовок - адрес L4-балансера
set_real_ip_from 10.0.0.0/8;
real_ip_header proxy_protocol; # брать клиента из PROXY protocol
location / {
proxy_pass http://backend;
proxy_set_header X-Real-IP $remote_addr;
}
}
Важная грабля безопасности: сервер с listen ... proxy_protocol обязан получать PROXY-заголовок в каждом соединении, иначе nginx порвёт соединение с ошибкой. Поэтому такой listen нельзя выставлять в открытый интернет - туда должны ходить только твои балансировщики. Обычные клиенты PROXY protocol не умеют и получат разрыв. Держи L4-listen за закрытым периметром.
Типичные грабли
- set_real_ip_from 0.0.0.0/0 в проде. Самая опасная ошибка - доверие всем. Любой подделает свой IP одним заголовком. Только конкретные подсети прокси.
- Забыли real_ip_recursive on за несколькими слоями. Тогда в $remote_addr попадёт адрес последнего прокси из цепочки, а не клиент. Симптом: в логах вместо клиентов адреса твоих же балансировщиков.
- Устаревшие диапазоны CDN. Cloudflare и другие меняют сети. Если их свежий выходной IP не в set_real_ip_from, для части трафика подмена молча перестанет работать и ты увидишь адреса CDN. Обновляй списки (у Cloudflare они публикуются, многие автоматизируют синхронизацию по cron).
- realip объявлен глубже, чем нужно. set_real_ip_from в http-блоке наследуется во все server/location - это удобно. Но если объявить в одном location, в других подмены не будет. Решай осознанно, обычно глобально.
- Доверяете X-Forwarded-For там, где надёжнее CF-Connecting-IP. За Cloudflare многозвенный XFF можно замусорить с клиентской стороны. Их собственный однозначный заголовок безопаснее.
- proxy_protocol-listen смотрит в интернет. Соединения без PROXY-заголовка будут рваться. Этот listen - только для балансировщиков.
- Перепутали X-Real-IP и X-Forwarded-For при пробросе вглубь. Внутренние слои должны доверять адресу, который проставил твой внешний прокси, а не сырому заголовку от клиента.
Повтори по шагам, чтобы увидеть и подмену, и защиту своими глазами.
- Шаг 1. Подними простой server без realip. Сделай location /whoami как выше с выводом $remote_addr и $http_x_forwarded_for. Запусти nginx -t, потом перезагрузи.
- Шаг 2. Сходи с одной машины: curl -H "X-Forwarded-For: 9.9.9.9" http://СЕРВЕР/whoami. Убедись, что remote показывает твой реальный адрес, а xff - 9.9.9.9. Это база: nginx заголовок не трогает.
- Шаг 3. Включи в http-блоке set_real_ip_from С ПОДСЕТЬЮ, в которую входит адрес твоей тестовой машины, real_ip_header X-Forwarded-For, real_ip_recursive on. Перечитай конфиг.
- Шаг 4. Повтори curl из шага 2. Теперь remote стал 9.9.9.9 - ты сам себе доверенный прокси, и подмена сработала. Так выглядит "доверяю кому попало".
- Шаг 5. Поменяй set_real_ip_from на подсеть, в которую твоя машина НЕ входит (например 198.18.0.0/15). Перечитай конфиг и снова сделай тот же curl. remote вернулся к настоящему адресу, заголовок 9.9.9.9 проигнорирован. Это и есть защита от спуфинга в действии.
- Шаг 6. Добавь limit_req_zone по $binary_remote_addr и зафиксируй: при доверии всем (шаг 4) ты сам себе обходишь лимит сменой заголовка, при правильных доверенных сетях (шаг 5) - нет. Вот зачем этот урок идёт до rate limit.
- Запрос пришёл с адреса 10.0.0.5 (он в set_real_ip_from), заголовок X-Forwarded-For: 203.0.113.7, 10.0.0.5, real_ip_recursive on. Какой адрес окажется в $remote_addr и почему?
- Почему set_real_ip_from 0.0.0.0/0 полностью убивает защиту от подделки клиентского IP?
- В каком случае HTTP-заголовки (XFF/CF-Connecting-IP) вообще не помогут восстановить клиента и придётся включать PROXY protocol?
- Почему server с listen ... proxy_protocol опасно открывать в публичный интернет?
$remote_addr за прокси - это адрес соседа, а не клиента, и пока ты это не починил, твои логи, rate limit, geo и auth по IP работают по ложным данным. Модуль realip решает задачу тремя директивами: set_real_ip_from задаёт доверенные сети прокси и CDN, real_ip_header указывает источник настоящего адреса (X-Forwarded-For, CF-Connecting-IP или proxy_protocol), real_ip_recursive on разворачивает цепочку хопов до первого чужого. Вся безопасность держится на одном принципе - доверять заголовку ровно настолько, насколько доверяешь TCP-источнику, и никогда не шире своих подсетей. Для чисто транспортных балансировщиков клиентский адрес приходит через PROXY protocol на уровне L4. Сделай этот фундамент правильно сейчас - и все следующие уроки про лимиты, логи и доступ будут стоять на правде.