nginx 反代后后端只看到 127.0.0.1:三层信任链怎么补
现象:地址退化了,但服务是好的
业务从单机直连改成 nginx 反代之后,功能一切正常,只有一类东西开始不对:凡是跟客户端地址相关的逻辑,全部失灵。应用日志里一整列 127.0.0.1;按 IP 做的登录失败限流再也拦不住人,因为所有请求看起来都来自同一个地址;审计里记录的操作来源全是内网网段;风控规则跑出来的是一堆无意义的数据。
大部分人会去搜「nginx 获取真实 IP」,然后照着抄两行 proxy_set_header 就结束了。但如果入口不止一层 nginx,或者入口本身是云上的负载均衡,只改这两行通常修不好,因为这里其实有三条独立的信任链,任何一条断了,地址都会退回去:
- 代理层有没有把地址传给上游:默认情况下 nginx 会原样转发客户端自己带来的头,但不会追加自己的判断,所以上游可能什么都拿不到,也可能拿到一个客户端自己编的值。
- nginx 自己认不认上一跳传来的地址:不做处理时
$remote_addr只等于 TCP 对端。上游如果是负载均衡或另一台 nginx,这个值就是上一跳的地址,不是用户的。 - 应用层信不信这个头:应用如果把
X-Forwarded-For的第一段当真,等于把判断权交给了客户端。
下面这组实验是在一台 Ubuntu 22.04 云主机上跑的,nginx 1.18.0(Ubuntu 官方包),四个 server 块全部只绑 127.0.0.1,用临时配置启动,不碰 /etc/nginx,跑完即退出。所有 IP 都是文档保留网段,只做占位。
第一层:代理到底往下传了什么
先说一个反直觉的实测结果。很多人以为「nginx 默认不传 X-Forwarded-For,所以后端拿不到」,实际情况是:nginx 默认会把客户端原始请求里的头原样透传给上游,只是不追加自己的判断。搭一个只回显请求头的后端,再配一个什么都不设置的 proxy,结果是这样:
# 后端只做一件事:把收到的头原样吐回来
curl -s -H 'X-Forwarded-For: 203.0.113.9' 127.0.0.1:8083/a
# {"path": "/a", "x_forwarded_for": "203.0.113.9", "x_real_ip": null,
# "host": "127.0.0.1:8099", "peer": "127.0.0.1"}
也就是说,客户端自己伪造的 203.0.113.9 一路直达后端,中间没有被清洗。这时候后端看到的「客户端地址」完全是攻击者填的。补上头之后行为才对:
curl -s -H 'X-Forwarded-For: 203.0.113.9' 127.0.0.1:8083/b
# {"path": "/b", "x_forwarded_for": "203.0.113.9, 127.0.0.1",
# "x_real_ip": "127.0.0.1", "host": "127.0.0.1", "peer": "127.0.0.1"}
注意 X-Forwarded-For 变成了 203.0.113.9, 127.0.0.1:$proxy_add_x_forwarded_for 的语义是「保留客户端原有的值,把 TCP 对端追加在末尾」。它不清洗,只追加。这一条决定了后端绝不能取第一段。
配置就三行,但 Host 那行经常被漏掉,漏掉时上游会看到 127.0.0.1:8099 这种内部地址,如果上游要用 Host 做站点判断或拼回调地址,会出另一类问题:
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;
proxy_pass 127.0.0.1:8099;
}
第二层:nginx 的 $remote_addr 是不是用户
X-Real-IP 传的是 $remote_addr,那 $remote_addr 到底是什么?在没有任何 realip 配置时,它就是 TCP 对端的地址。入口是多层代理时,这个值是上一跳的地址,于是「真实 IP」被忠实地传成了 127.0.0.1 或 10.x 内网地址。
要让 nginx 把上一跳传来的地址当成客户端地址,得用 http_realip_module。同一个请求,一个 server 块不配,另一个配上:
# 不启用 realip
curl -s -H 'X-Forwarded-For: 203.0.113.9' 127.0.0.1:8081/
# A addr=127.0.0.1 realip_src=127.0.0.1 xff=203.0.113.9
# 启用 realip:set_real_ip_from 127.0.0.1; real_ip_header X-Forwarded-For; real_ip_recursive on;
curl -s -H 'X-Forwarded-For: 203.0.113.9' 127.0.0.1:8082/
# B addr=203.0.113.9 realip_src=127.0.0.1 xff=203.0.113.9
$remote_addr 从 127.0.0.1 变成了 203.0.113.9,而 $realip_remote_addr 保留了真实的 TCP 对端。这两个变量的差值,就是判断「这条记录有没有被伪造」的依据,日志里应该同时记下来。
配置写法:
# 只信任来自这些来源的头,来源要写具体网段
set_real_ip_from 127.0.0.1;
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
real_ip_recursive on 的行为值得单独说,因为它和直觉相反。让客户端带一条两跳的伪造链:
curl -s -H 'X-Forwarded-For: 198.51.100.7, 203.0.113.9' 127.0.0.1:8082/
# B addr=203.0.113.9 realip_src=127.0.0.1 xff=198.51.100.7, 203.0.113.9
开启递归后,nginx 从右往左走,跳过所有被信任的地址,停在第一个不可信地址上,所以取到的是 203.0.113.9,而不是最左边那个 198.51.100.7。最左边的值永远是客户端自己写的,不可信。这也是为什么「取 XFF 最后一段」在单层代理下是对的,但多层代理下要看信任网段来算,不能靠位置。
set_real_ip_from 千万别写成 0.0.0.0/0。那等于宣布所有来源都可信,任何人都能用一个头把自己伪装成任意地址,比不配还糟——不配至少日志里记的是真实 TCP 对端。如果入口是云上的负载均衡,这里要填的是负载均衡回源时使用的地址段(在控制台里可以查到),而不是用户网段。
还有一个前提:模块得编译进去。Ubuntu 的官方包带了,自己编的镜像不一定:
nginx -V 2>&1 | tr ' ' '\n' | grep realip
# --with-http_realip_module
没有这个输出,上面所有配置都会在 nginx -t 时报 unknown directive,只能换包或重编。这一点在 22.04 的官方源上验证过,其他发行版需要自己确认。
第三层:应用到底该信谁
前两层修完,X-Real-IP 和 X-Forwarded-For 都对了,第三层才轮到应用。这里最常见的错误是「一律信任这个头」。只要后端端口对外可达——容器映射到 0.0.0.0、或者安全组多开了一个端口——别人就能绕过 nginx 直接连后端,自己填一个头。
所以应用侧的规则是:只有请求来自已知代理地址时,才采信代理头;否则用 TCP 对端地址。
以 Node 为例,trust proxy 的设置方式决定了取哪个值:
# 只信任本机与内网代理;数字表示"信任前几跳",true 表示全信(不要用)
app.set('trust proxy', 'loopback, 10.0.0.0/8');
# 取客户端地址时用 req.ip,不要把 req.headers['x-forwarded-for'] 的第一段当真
trust proxy 设成 true 是最省事也最危险的写法,它等于相信任何来源的头。设成具体网段后,Express 会按与 nginx 相同的逻辑从右往左跳过可信地址,得到的才是可用的地址。
配套的动作是收口上游的暴露面,让「绕过 nginx 直连后端」这条路走不通:
# 上游只绑本机,不从 0.0.0.0 监听
ss -lntp | grep -E '8080|8099' # 期望看到 127.0.0.1:端口,而不是 0.0.0.0:端口
ufw status numbered # 对外只留 22/80/443,应用端口不放行
用访问日志自查,别靠猜
排查这类问题时,最快的办法不是读应用代码,而是让 nginx 自己的日志把三个值同时打出来:$remote_addr(经过 realip 计算后的地址)、$realip_remote_addr(实际 TCP 对端)、$http_x_forwarded_for(客户端带来的原始链)。三者一致说明链路干净,不一致就说明中间有代理或伪造。
log_format trace 'addr=$remote_addr realip_src=$realip_remote_addr '
'xff="$http_x_forwarded_for" ua="$http_user_agent"';
access_log /var/log/nginx/trace.log trace;
实验里四条请求的日志是这样的,一眼能看出哪条是多跳伪造:
addr=203.0.113.9 realip_src=127.0.0.1 xff="198.51.100.7, 203.0.113.9" ua="curl/7.81.0"
addr=127.0.0.1 realip_src=127.0.0.1 xff="203.0.113.9" ua="curl/7.81.0"
排查顺序建议固定成三步,从外往里,每一步都能独立证伪:
# 1 直连 nginx,看它算出来的地址对不对(带一个已知的假 XFF)
curl -s -H 'X-Forwarded-For: 203.0.113.9' 127.0.0.1:8082/
# 2 绕过 nginx 直连后端,看后端到底收到什么
curl -s 127.0.0.1:8099/echo
# 3 对比两边:nginx 对了而应用不对,问题在应用;两边都不对,问题在 realip
常见现象对照
| 现象 | 原因 | 处理 |
|---|---|---|
| 应用日志里全是 127.0.0.1 | 代理没传头,或应用读的是 TCP 对端 | 加 proxy_set_header X-Real-IP,应用改读 req.ip |
| 地址是 10.x 或 172.x 内网段 | 入口有负载均衡,nginx 未启用 realip | 配 set_real_ip_from + real_ip_header,来源填负载均衡回源网段 |
| 地址看起来正常,但偶尔出现陌生公网 IP | 客户端伪造 XFF,被原样透传 | 后端只取可信代理追加的那一段;日志同时记 $realip_remote_addr |
nginx -t 报 unknown directive “set_real_ip_from” |
模块未编译进 nginx | nginx -V 确认,换带 realip 的包或重编 |
Host 变成 127.0.0.1:端口 |
漏了 proxy_set_header Host $host |
补上,或显式写业务域名 |
| 限流规则形同虚设 | 限流键用的是真实 TCP 对端(同一个 nginx) | 限流键改用 realip 后的地址,或直接用 limit_req_zone $binary_remote_addr 放在最外层入口 |
| 客户端地址全变成负载均衡的健康检查 IP | 健康检查请求也被算进业务统计 | 按 UA 或专用路径把探活流量单独记录 |
应用侧 trust proxy 设为 true 后地址可任意伪造 |
信任范围过大 | 改成具体网段或跳数 |
收口清单
三层修完,剩下的是一致性问题:同一个地址在所有地方必须是同一个含义,否则排查时会互相矛盾。
- 入口只有一层时,
set_real_ip_from可以不写,但proxy_set_header一定要写。 - 入口有多层时,每一层都要配,且信任网段要逐层收紧,最外层只信负载均衡回源段。
- 上游端口只绑
127.0.0.1,对外只经 nginx,这一条比任何头部配置都重要。 - 日志里固定记三个值,出问题时先看日志再看代码。
- 限流、风控、审计统一取同一个来源字段,不要一处取
req.ip、另一处取 XFF 第一段。
写这篇文章时唯一想强调的是:真实 IP 不是一个配置项,而是一条链。只改其中一层,问题会以另一种形式回来——最常见的是「nginx 日志对了,应用还是 127.0.0.1」,那其实是第一层和第三层没对齐,跟 realip 没关系。上面的实验数据都在 22.04 + nginx 1.18.0 上得到,其他发行版与更高版本的 nginx 行为应当一致,但模块编译情况和默认包内容需要各自确认。
- 点赞
- 收藏
- 关注作者
评论(0)