只要 Nginx 前面还有一层负载均衡、CDN 或者云厂商的四层转发,$remote_addr 拿到的就不再是真实客户端地址,而是上一跳代理的地址。这个看似微小的偏差会一路污染下游:访问日志里的来源统计失真、基于 IP 的限流把整栋楼的用户合并成一个桶、风控系统拿到的全是同一个内网地址。本文从头部语义讲起,逐层拆解信任链的构建方式,再落到 real_ip 模块与 PROXY protocol 两套还原机制上。
一句话总结: 真实 IP 还原的本质是「确定哪些上游可信、再从可信链上取下第一个不可信地址」,机制本身很简单,难的是信任边界一旦放宽就等于把伪造能力交给了攻击者。
1. 为什么 remote_addr 会失真
一句话总结: Nginx 只认识 TCP 对端地址,任何在其之前的代理都会让 remote_addr 变成上一跳的地址,这既影响日志也影响所有基于 IP 的判断。
$remote_addr 直接取自 ngx_connection_t 的 sockaddr,也就是 TCP 连接的对端地址。当链路是「客户端 → CDN → 负载均衡 → Nginx」时,Nginx 看到的对端是负载均衡的内网地址,与真实用户没有任何关系。
客户端 1.2.3.4
|
v TCP 源地址 1.2.3.4
CDN 节点
|
v TCP 源地址 10.0.0.5(CDN 回源地址)
四层负载均衡
|
v TCP 源地址 10.0.1.9(LB 地址)
Nginx -> $remote_addr = 10.0.1.9
除了日志统计,受影响的还有三类常见能力:limit_req / limit_conn 的共享内存 key 通常写 $binary_remote_addr,失真后所有用户共用一个桶;allow / deny 的 ACL 判断会失效;应用层风控拿到的 X-Real-IP 也一并错误。因此还原真实 IP 不是可选项,而是多层架构下的必备配置。
Nginx 为这个场景提供了三个互相配合的变量,理解它们的分工能少走很多弯路:
$remote_addr 当前认为的客户端地址,可能被 real_ip 模块替换
$realip_remote_addr 替换前的原始对端地址,用于审计与二次校验
$proxy_protocol_addr 从 PROXY protocol 头中解析出的源地址
还原动作发生时,$remote_addr 被改写,而 $realip_remote_addr 保留原值。把两者同时写进日志,就能在事后区分「谁连的我」与「我认为是谁」,这在排查信任链配置错误时非常有用。
2. X-Forwarded-For 信任链
一句话总结: X-Forwarded-For 是一个由各跳代理依次追加的列表,最左侧是客户端,但整个头部都是客户端可写的,只能信任其中靠右侧由自己代理写入的部分。
2.1 头部的生成与追加规则
一句话总结: 标准做法是每跳把自己的对端地址追加到列表尾部,于是列表从左到右依次是原始客户端与各级代理。
请求经过三层代理后:
X-Forwarded-For: 1.2.3.4, 10.0.0.5, 10.0.1.9
1.2.3.4 客户端真实地址(最左)
10.0.0.5 CDN 回源地址(由 CDN 写入)
10.0.1.9 负载均衡地址(由 LB 写入,即 Nginx 的对端)
Nginx 自身在 proxy_set_header 中配置 X-Forwarded-For $proxy_add_x_forwarded_for;,这个变量会把已有头部与 $remote_addr 拼接起来,实现「追加而非覆盖」。用 $remote_addr 而不是直接覆盖头部,是为了保留上游信息;但这也意味着如果客户端自己伪造了一段头部,Nginx 会忠实地把它保留在最左侧。
2.2 伪造风险与信任边界
一句话总结: 客户端可以任意伪造 X-Forwarded-For,因此绝不能取最左值作为客户端地址,只能从右侧倒数第 N 跳取。
攻击者只需发一个 X-Forwarded-For: 8.8.8.8 的请求,若后端直接取列表第一个元素,就会认为请求来自 8.8.8.8,从而绕过基于 IP 的白名单或者污染统计。正确做法有两种:
# 做法一:由边缘代理强制覆盖,丢弃客户端传入的头部
proxy_set_header X-Forwarded-For $remote_addr;
# 做法二:保留追加语义,但由 real_ip 模块按可信跳数取值
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
做法一适用于「自己完全掌控边缘入口」的场景,代价是丢失中间跳信息;做法二保留完整链路,但必须配合 set_real_ip_from 声明的可信网段,由 Nginx 自己决定从哪个位置取值。混用是最危险的:既保留了客户端头部,又用取最左值的逻辑解析。
3. real_ip 模块配置
一句话总结: ngx_http_realip_module 通过 set_real_ip_from 声明可信来源、real_ip_header 指定取值头部,把 remote_addr 原地替换为真实地址,替换后所有依赖该变量的模块都自动受益。
3.1 set_real_ip_from 与 real_ip_header
一句话总结: 只有连接来源落在 set_real_ip_from 声明的网段内时,头部才会被采信,这是整个机制的安全基石。
# 需在编译时包含 --with-http_realip_module
server {
listen 80;
# 声明可信代理网段:只有这些来源发来的头部才被采信
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
# 云厂商 CDN 回源段需按其文档逐个补齐
set_real_ip_from 203.0.113.0/24;
# 指定从哪个头部取值
real_ip_header X-Forwarded-For;
# 取值后是否继续向前递归查找
real_ip_recursive on;
}
set_real_ip_from 可以出现多次,也支持直接写 IP 或 CIDR。它的判定对象是TCP 对端地址(也就是替换前的 $remote_addr),而不是头部内容,因此攻击者无法通过伪造头部来绕过这层校验。
3.2 real_ip_recursive 的语义
一句话总结: real_ip_recursive on 时从列表最右侧向左跳过所有可信地址取第一个不可信值,off 时只取列表最右侧一个值。
这两种行为差异很大,用一个具体例子说明。假设可信网段包含 10.0.0.0/8,请求头部为:
X-Forwarded-For: 1.2.3.4, 10.0.0.5, 10.0.1.9
real_ip_recursive off 会直接取最右侧的 10.0.1.9——如果这只是内网 LB 地址,还原结果依然是错的;real_ip_recursive on 会从右向左逐个检查,10.0.1.9 与 10.0.0.5 都落在可信网段内被跳过,最终取到 1.2.3.4,这才是正确结果。因此在多层可信代理场景下应始终开启递归。
递归模式下的安全前提是:攻击者无法在可信代理右侧注入内容。如果某跳代理没有覆盖而是追加客户端头部,那么客户端伪造的值会落在列表中偏左的位置;递归查找会跳过自己写入的地址,但仍可能取到伪造值。结论是每一跳都必须遵循「追加自身地址」的规范。
下面这段配置演示了「可信段较窄」时的典型误配,它会让递归取值提前停止:
# 反例:只声明了 LB 地址,但链路中还有一跳 CDN
set_real_ip_from 10.0.1.9; # 只有 LB 可信
real_ip_recursive on;
# 头部:1.2.3.4, 10.0.0.5, 10.0.1.9
# 从右向左:10.0.1.9 可信跳过 -> 10.0.0.5 不可信,立即停止
# 结果 $remote_addr = 10.0.0.5,仍然是 CDN 回源地址
正确的写法是把链路上每一跳的地址段都列进来,或者干脆把整段内网地址声明为可信,再依赖「边缘代理覆盖写入」来阻断伪造。两者取其一,不要既信任宽网段又保留客户端追加的头部。
4. PROXY protocol
一句话总结: PROXY protocol 在 TCP 层把原始源目地址前置到连接数据之前,让四层代理无需依赖 HTTP 头部即可传递真实地址,代价是两端必须同时支持。
4.1 协议格式与版本
一句话总结: v1 是可读的文本行,v2 是带签名的二进制结构,两者都不加密也不鉴权,只应在可信网络内使用。
v1(文本,人眼可读):
PROXY TCP4 1.2.3.4 10.0.1.9 51234 443\r\n
v2(二进制,带魔数与长度):
\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A
+ 版本命令字节 + 地址族 + 地址与端口 + 可选的 TLV 扩展
PROXY protocol 的价值在于它工作在四层,因此对 HTTP、gRPC、WebSocket 一视同仁,甚至对数据库协议也适用。它同时解决了 X-Forwarded-For 在非 HTTP 协议中无处安放的问题。代价是接收端必须显式开启,否则会把协议行当作应用数据解析而报错。
4.2 Nginx 两侧配置
一句话总结: 发送端用 proxy_protocol on 打开,接收端用 listen … proxy_protocol 声明,两者必须成对出现。
# 发送端(stream 模块中,把连接转发给后端 Nginx 时带上 PROXY 头)
stream {
upstream backend_nginx {
server 10.0.2.10:8443;
}
server {
listen 443;
proxy_pass backend_nginx;
proxy_protocol on;
}
}
# 接收端:listen 上声明 proxy_protocol,并据此还原地址
server {
listen 8443 proxy_protocol;
set_real_ip_from 10.0.2.0/24;
real_ip_header proxy_protocol;
location / {
proxy_pass http://app_backend;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
开启 proxy_protocol 后,该端口的 $remote_addr 会被协议头中的源地址替换。需要注意的是,接收端若未声明而发送端已开启,握手阶段就会失败,典型表现是浏览器报 ERR_EMPTY_RESPONSE、Nginx 日志出现 broken header。反过来,接收端声明了但发送端没开,则所有连接都会因解析失败而被拒绝。
5. 日志与限流联动
一句话总结: 还原后的 remote_addr 会被所有模块共用,因此日志格式、限流 key、ACL 都能自动获得真实地址,前提是 real_ip 配置位于同一 server 或更上层。
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" rt=$request_time '
'xff="$http_x_forwarded_for"';
# 按真实 IP 限流:key 使用 $binary_remote_addr 而非头部
limit_req_zone $binary_remote_addr zone=perip:10m rate=20r/s;
limit_conn_zone $binary_remote_addr zone=connperip:10m;
server {
listen 80;
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
location /api/ {
limit_req zone=perip burst=40 nodelay;
limit_conn connperip 20;
proxy_pass http://api_backend;
}
}
限流 key 用 $binary_remote_addr 而不是 $http_x_forwarded_for 的原因有两点:前者是二进制定长(IPv4 四字节、IPv6 十六字节),共享内存占用固定且哈希快;后者是任意长度字符串,既浪费内存又容易被超长头部打爆。同理,limit_conn 也应基于还原后的地址。日志中同时保留原始 $http_x_forwarded_for 便于事后审计链路。
如果业务需要按「真实 IP 但保留原始对端」做双层限流(例如防止某个代理整体过载),可以用 map 组合出复合 key:
map $realip_remote_addr $edge_bucket {
default $realip_remote_addr;
}
limit_req_zone $edge_bucket zone=peredge:10m rate=500r/s;
server {
listen 80;
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
location /api/ {
limit_req zone=peredge burst=1000 nodelay;
proxy_pass http://api_backend;
}
}
这样一层按真实用户限速、一层按入口代理限速,可以同时防住「单用户刷接口」与「某个代理整体异常」两类问题。注意 $realip_remote_addr 在未配置 real_ip 时等于 $remote_addr,因此这段配置在没有代理的直连场景下也安全。
6. 边界场景与常见坑
一句话总结: 大部分翻车来自信任网段过宽、多跳头部拼接错误、以及 CDN 场景下回源 IP 段变更未同步。
常见问题可以归纳为以下几类:
[现象] 限流把全部用户当成一个人
[原因] limit_req_zone 的 key 写成了 $http_x_forwarded_for 但未配 real_ip
[修复] 改用 $binary_remote_addr 并补齐 set_real_ip_from
[现象] 内网扫描器伪造 XFF 触发风控误封
[原因] 只信任了负载均衡地址,但负载均衡并未覆盖客户端头部
[修复] 在边缘代理改为覆盖写入,或收紧 real_ip_recursive 的使用
[现象] IPv6 用户全部被识别为同一个地址
[原因] set_real_ip_from 只写了 IPv4 网段
[修复] 补上 ::1/128 与对应的 IPv6 内网段
[现象] 切换 CDN 后真实 IP 全变成 CDN 回源段
[原因] 新 CDN 的回源网段未加入 set_real_ip_from
[修复] 用配置管理把网段清单集中维护并定期同步
另一个容易忽略的点是 real_ip_header 的取值必须与实际协议匹配:HTTP 场景用 X-Forwarded-For,四层用 proxy_protocol,部分云厂商使用 X-Real-IP(此时只能取单个地址,无法递归)。三者混用会导致取到空值或上一跳地址。
7. 验证与排错
一句话总结: 验证的核心是构造带伪造头部的请求,观察 remote_addr 是否落在预期值上,并用 debug 日志确认取值路径。
# 从可信网段内发起请求,验证头部被采信
curl -s -H 'X-Forwarded-For: 1.2.3.4, 10.0.0.5' \
-o /dev/null -w '%{remote_ip}\n' http://127.0.0.1/echo
# 直接访问(非可信来源)时头部应被忽略,remote_addr 保持对端地址
curl -s -H 'X-Forwarded-For: 8.8.8.8' \
-o /dev/null -w '%{remote_ip}\n' http://127.0.0.1/echo
# 开启 debug 日志观察 real_ip 模块的取值过程
# error_log /var/log/nginx/error.log debug;
更完整的做法是在后端放一个回显服务,直接打印收到的 X-Real-IP 与 X-Forwarded-For,再用不同来源、不同头部组合跑一组用例。下面这段脚本把常见组合一次性覆盖:
#!/usr/bin/env bash
# 逐条验证信任链行为,输出「头部 -> 还原结果」
endpoint="http://127.0.0.1/echo"
cases=(
"1.2.3.4"
"1.2.3.4, 10.0.0.5"
"8.8.8.8, 1.2.3.4, 10.0.0.5, 10.0.1.9"
)
for xff in "${cases[@]}"; do
ip=$(curl -s -H "X-Forwarded-For: ${xff}" -o /dev/null -w '%{remote_ip}' "$endpoint")
printf '%-45s -> %s\n' "$xff" "$ip"
done
用 nginx -T 导出完整生效配置,确认 set_real_ip_from 与 real_ip_header 落在预期的 server 块内——这两个指令既可在 http 层也可在 server 层声明,写在错误的层级会导致部分虚拟主机不生效。同时用 nginx -V 2>&1 | grep realip 确认 --with-http_realip_module 已编译进去,缺失时 nginx -t 会直接报 unknown directive。
8. 总结
| 环节 | 要点 |
|---|---|
| 失真原因 | remote_addr 是 TCP 对端地址,前置代理越多偏差越大 |
| XFF 语义 | 各跳依次追加,最左是客户端,但整段头部可被伪造 |
| 信任边界 | 只信任自己掌控的代理,边缘入口优先覆盖而非追加 |
| real_ip | set_real_ip_from 声明可信段,real_ip_header 指定取值头部 |
| 递归取值 | real_ip_recursive on 从右向左跳过可信地址取首个不可信值 |
| PROXY protocol | 四层传递原始地址,两端必须同时开启且仅在可信网络使用 |
| 日志限流 | 限流 key 用 binary_remote_addr,日志同时保留原始 XFF |
| 验证 | 构造伪造头部 + debug 日志 + nginx -T 核对生效层级 |
真实 IP 还原是「安全与可用性互相拉扯」的典型场景:信任网段配得太窄会导致部分链路不生效,配得太宽等于把伪造能力交给攻击者。稳妥的做法是把可信网段清单纳入配置管理、与云厂商文档定期对齐,并在边缘入口坚持覆盖语义。地址还原清楚之后,下一类高频问题出现在长连接场景——WebSocket 与 SSE 的代理需要对超时、心跳和连接数做完全不同的取舍,这是下一篇的主题。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。