有读者在《X-Forwarded-For 取首段的陷阱》那篇下面留了一条评论,大意是:

搞太复杂了。就一点——这个 HTTP header 的内容是不是来自可信来源?比如自己信任的 proxy。如果不是,直接忽略掉。或者用更好的办法:直接在 proxy 上 strip 掉非可信来源的 header 内容,把复杂性丢到 proxy 上,简化后端业务代码。

这条评论我盯了两天才想清楚该怎么回。原因是它有一半是对的,而且是那种你说不出"不对"的对;另一半听起来更简洁,但那个简洁是错觉。

先说结论:**它描述的不是"更简单的实现",而是"把同一条约束换个地方执行"。**换个地方本身没问题——问题在于换过去之后,那条约束落进了一层基本没人测的配置里,而它本来是一段可测的代码。

一、评论其实说了两件事

# 评论的主张 它是什么 我的判定
A header 是否来自可信来源,不是就直接忽略 解析逻辑的第一步 原文已经这么写了
B 在 proxy 上 strip 掉非可信来源的 header,把复杂性丢给 proxy 架构层面的归属主张 方向对,但它是"搬迁",不是"消除"

先把 A 说清楚,因为它经常被当成"更简单的替代方案",其实不是。

XFF 的解析确实分两步,前一篇拆过:

  1. **先看直连方是不是可信代理。**不是 → XFF 里任何内容都不采信,直接用 TCP 对端地址。
  2. 是 → 从右往左跳过可信地址,取第一个不可信地址。

评论的 A 就是第 1 步。它成立,但没回答第 2 步的问题:来源可信之后,那段 XFF 里到底取哪一段?

这一步省不掉,因为"来源可信"和"可以取首段"是两件事。

二、分水岭:代理是覆盖写头,还是追加写头

决定"取哪一段"的,是代理怎么写这个头。

同一个请求,两种写法留下的东西不一样 覆盖(首跳) proxy_set_header X-Forwarded-For $remote_addr; 客户端带来的 9.9.9.9 被丢掉,写进去的是 Nginx 亲眼看到的对端 X-Forwarded-For: 1.2.3.4 ← 单值,可直接采信 追加(非首跳,模板里最常见) proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 客户端带来的 9.9.9.9 原样保留,对端地址缀到右边 X-Forwarded-For: 9.9.9.9, 1.2.3.4 ← 伪造值留在最左

Nginx 侧的两种写法:

1
2
3
4
5
# 首跳:覆盖。客户端带来的 XFF 被丢掉,只写 Nginx 亲眼看到的对端
proxy_set_header X-Forwarded-For $remote_addr;

# 非首跳:追加。客户端带来的 XFF 原样保留,对端地址缀到右边
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

$proxy_add_x_forwarded_for 的语义是"客户端带来的 XFF(若有)+ 自己的对端地址"——追加,不是覆盖。客户端自己塞一个 XFF 时,结果就是 伪造值, 真实IP,伪造值稳稳留在最左边。

而现实里这两种写法是共存的:一份 nginx 模板里,首跳写覆盖、后面几跳写追加,非常常见。于是到了后端,XFF 里混着三种来源的东西——客户端自称的、中间跳追加的、首跳亲眼所见的——你只能靠位置去分辨

这就是两步解析躲不掉的原因。不是我把问题想复杂了,是追加语义把答案放在了右边

本质一句话:取哪一段,取决于这个头是被覆盖过还是被追加过;而这件事在读它之前看不出来,所以只能从可信的那一端往左数。

三、"边界 strip"的正式名字:Nginx 与 Envoy

评论的 B 我认。而且它不是野路子,是工业界的默认姿势。

Envoy 做边缘节点时要求 use_remote_address: true,它的语义是不采信 x-forwarded-for,改用下游真实地址;前面还有几层可信代理,再用 xff_num_trusted_hops 告诉它"从右数第几跳"。文档里有一句写得很直白:

Malicious clients can forge XFF, but the last address in XFF can be trusted if it was put there by a trusted proxy.

在 Envoy 头部清洗绕过(CVE-2025-0752)的处置讨论里,给出的建议也正是这两条:用 request_headers_to_removex-forwarded-for 摘掉,再配 xff_num_trusted_hops = 0 + use_remote_address = true这就是评论说的"在 proxy 上 strip"。

Nginx 做同一件事的是 realip 模块:

1
2
3
set_real_ip_from 10.0.0.0/8;      # 声明谁是可信代理
real_ip_header X-Forwarded-For; # 从哪个头里取
real_ip_recursive on; # 怎么取

关键在最后一行。官方文档对 real_ip_recursive 的定义是:

  • on:匹配可信地址的客户端地址,被替换成请求头里「最后一个非可信地址」(the last non-trusted address);
  • off(默认):被替换成请求头里「最后一个地址」(the last address)。

而 nginx 作者 Maxim Dounin 在邮件列表里把这个过程逐步走过一遍,正好是最好的注解。他给的链路是:

1
2
3
4
5
客户端 192.168.1.79 → 客户端网关 178.150.189.138 → 你的 CDN 8.47.98.129
→ 你的负载均衡 10.6.1.56 → 你的 nginx

X-Forwarded-For = 192.168.1.79, 178.150.189.138, 8.47.98.129
可信名单 = 10.6.1.0/24, 8.47.98.0/24

他逐步推出来的过程是这样的:

  1. TCP 对端是 10.6.1.56,它在可信名单里 → 才去看 XFF;
  2. 取 XFF 里最近的那个地址 8.47.98.129,它也在可信名单里 → 继续往前一轮;
  3. 再取剩下的 192.168.1.79, 178.150.189.138 里最近的 178.150.189.138,这次不在可信名单里 → 停下,它就是结果。

结论与文档那句一一对应:结果就是 XFF 里「最右边那个不可信地址」。Dounin 也顺带点出文档那句话为什么容易读岔——它描述的是「这一串动作跑完之后的结果」,没有把过程摊开讲。而把过程摊开,其实就是前面那两步解析从右往左走了一遍。real_ip_recursive on 和后端那套两步解析是同一个算法。

同一套遍历、同一份信任名单,差别只在落在哪一层 边界层:nginx.conf set_real_ip_from 10.0.0.0/8; real_ip_header X-Forwarded-For; real_ip_recursive on; 语义:取「最后一个非可信地址」 应用层:C++ 插件 / 业务代码 trust_ips = ["10.0.0.0/8"] for (ip : 反向遍历(XFF)) if (!可信(ip)) return ip; 语义:取「第一个不可信地址」 搬到边界不等于复杂度消失:语言换了,可测性也换了

所以搬过去之后,复杂度并没有消失。它从 C++ 的 trust_ips 遍历,搬进了 nginx.confset_real_ip_from + real_ip_recursive换了个语言,顺带从"可测的代码"变成了"基本没人测的配置"。

这里有个容易忽略的细节,Dounin 那个例子正好点破了:最靠近 nginx 的那一跳(10.6.1.56)根本不在 XFF 里,它只以「TCP 对端」的身份存在,也就是 $realip_remote_addr 保留的那个值。XFF 里的条数等于上游代理的个数——每一跳追加的是它自己的对端,而不是它自己。

把这件事说透,是因为它解释了一个常见的误判:看到 XFF 有 3 个地址,就以为链路上有 3 个代理加一个客户端。实际是 3 个上游代理各追加一次,加上不在头里的那一跳直连对端,链上一共 4 跳。"我到底该信几个"这个数,数 XFF 的条数是数不出来的。

四、但它是"搬迁",而且是有损的

如果只是搬家,那还好说。真正让我把这条评论按住的是另一件事:覆盖式 strip 会丢东西。

链路是:客户端 1.2.3.4 → 别人家的 CDN 8.47.98.129 → 你的 Nginx → 后端。

  • Nginx 用 $proxy_add_x_forwarded_for(追加):到后端的头是 1.2.3.4, 8.47.98.129中间跳亲眼所见的 1.2.3.4 还在列表里,只要把 CDN 网段列进 set_real_ip_from,右→左就能把它捞回来。
  • Nginx 用 $remote_addr(覆盖):到后端的头是 8.47.98.129你写进去的是自己亲眼看到的那一跳,真实客户端 1.2.3.4 就这么没了——后端拿到这个头,用什么算法都救不回来。

所以"入口全由自己控住时,后端那步可以被边界替代"这句话,条件比字面严。要替代掉的不是"代理是不是我的",而是"入口能不能被绕过"。

前提 检验方式 不满足会怎样
入口不可绕过 有没有一条路能不经边界直达后端?(集群内直连 svc、debug 端口、第二个 ingress、IPv6 旁路) strip 形同虚设,而后端毫无防备
strip 覆盖全族 不只 XFF,见下一节 漏掉的那个头,正好会被业务代码拿去用
配置进版本控制 + 有测试 新加入口时 CI 会不会拦 洞原样回来,代码零改动、测试全绿
每个入口都配 一共有几个入口 漏一处等于没做

第三行最值得盯。用一个不可测的层去承载安全决策,等于把风险从"看得见的代码"挪到"看不见的配置"。 Nginx 配置没有单元测试,也很少有人为它写"伪造头"的用例。这跟"压测全绿不代表安全"是同一个形状的坑,只是换了一层。

五、要 strip,就 strip 干净

评论说的是"strip 掉非可信来源的 header 内容"。这句如果只做一半,比不做更糟——因为你会信任一个没摘干净的头

要摘的是一整族

1
2
3
X-Forwarded-For      X-Real-IP          X-Forwarded-Host
X-Forwarded-Proto Forwarded CF-Connecting-IP
True-Client-IP Client-IP X-Originating-IP

其中 Forwarded 是 RFC 7239 定义的标准头(写法如 Forwarded: for=1.2.3.4;proto=https),不少框架会顺手读它;CF-Connecting-IPTrue-Client-IP 则是 CDN 厂商的单值头。业务代码只要读了一个没被摘掉的头,洞就回来了——而且这次连"取哪一段"的困惑都没有,伪造值就是唯一的值。

两层并存的写法大致是这样:

1
2
3
4
# 第一层:边界。覆盖式重写全族 —— 客户端带来的这些头一律丢掉
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
1
2
3
4
5
// 第二层:后端。信任名单里只填边界的地址,别人谁都不信
{
"name": "drogon::plugin::RealIpResolver",
"config": { "trust_ips": ["10.0.0.7"], "from_header": "x-forwarded-for" }
}

六、这不是"又实现了一遍":写侧消歧与读侧求解

写到这里,可以澄清一件很容易误会的事:后端那一步不是把边界该做的事又做了一遍

  • 边界做的是写侧消歧——把可能被伪造的内容删掉,让后面只看到一种可能。
  • 后端做的是读侧求解——在内容还是脏的、可能有三段来源的时候,把答案算出来。

strip 的产出其实是"没有了",也就是缺省。缺省没法在 C++ 里再实现一次;能在 C++ 里再实现的,只有那份"谁可信"的名单和那段遍历。

所以更准确的说法是:**我是在读侧把同一条判断又执行了一遍。**边界那层失效时——漏配一个新入口、有旁路、中间夹着不受控的一跳——它还能给出正确答案。

这就是纵深防御的定义:不是不信任边界,是不假设边界永不出错

两层各挡什么 边界 strip(写侧) 挡得住:一切经过边界的伪造输入 挡不住:旁路直连、新入口漏配 前提是入口不可绕过 后端解析(读侧) 挡得住:无论从哪条路进来的伪造 挡不住:入口本身被攻陷 不依赖网络拓扑 两者不是二选一:纵深防御是不假设前一层永不出错

七、三种读取策略,跑一遍看差异

空口说"右→左才对"没意思。把同一批请求头喂给三种策略,看它们各自算出什么(可信网段 10.0.0.0/8172.16.0.0/12,直连方 10.0.0.1):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import ipaddress

# 声明的可信代理网段(对应 Nginx 的 set_real_ip_from / Drogon 的 trust_ips)
TRUSTED = [ipaddress.ip_network('10.0.0.0/8'),
ipaddress.ip_network('172.16.0.0/12')]
PEER = '10.0.0.1' # 直连方(本例中是 Nginx 自己)


def split_xff(xff):
return [s.strip() for s in xff.split(',') if s.strip()]


def is_trusted(ip):
"""True=可信代理;False=不可信;None=解析不了(按不可信处理)"""
try:
a = ipaddress.ip_address(ip)
except ValueError:
return None
return any(a.version == n.version and a in n for n in TRUSTED)


def take_first(xff):
return split_xff(xff)[0]


def take_last(xff):
return split_xff(xff)[-1]


def walk_rtl(xff, peer=PEER):
"""Nginx real_ip_recursive on / Drogon RealIpResolver 的等价逻辑"""
if is_trusted(peer) is not True: # 第一步:直连方不可信 → 完全不看 XFF
return peer
for ip in reversed(split_xff(xff)): # 第二步:从右往左,跳过可信,取第一个不可信
if is_trusted(ip) is False:
return ip
return f'{peer}(回退 TCP 对端)'


CASES = [
('单跳:客户端不带 XFF', '1.2.3.4'),
('单跳:客户端自带伪造值', '9.9.9.9, 1.2.3.4'),
('双跳:客户端自带伪造值', '9.9.9.9, 1.2.3.4, 172.16.0.5'),
('链上全是可信代理', '172.16.0.5, 10.0.0.9'),
('尾部混入脏数据', '1.2.3.4, not-an-ip'),
]


def w(s):
return sum(2 if ord(c) > 0x2E80 else 1 for c in s)


def pad(s, n):
return s + ' ' * max(1, n - w(s))


print(pad('场景', 30) + pad('取首段', 18) + pad('取最右', 18) + '右→左跳过可信')
print('-' * 84)
for name, xff in CASES:
print(pad(name, 30) + pad(take_first(xff), 18) + pad(take_last(xff), 18) + walk_rtl(xff))
1
2
3
4
5
6
7
场景                          取首段            取最右            右→左跳过可信
------------------------------------------------------------------------------------
单跳:客户端不带 XFF 1.2.3.4 1.2.3.4 1.2.3.4
单跳:客户端自带伪造值 9.9.9.9 1.2.3.4 1.2.3.4
双跳:客户端自带伪造值 9.9.9.9 172.16.0.5 1.2.3.4
链上全是可信代理 172.16.0.5 10.0.0.9 10.0.0.1(回退 TCP 对端)
尾部混入脏数据 1.2.3.4 not-an-ip 1.2.3.4

第 2、3 行是关键:单跳伪造时"取最右"碰巧是对的1.2.3.4),一进多跳就错——它拿到 172.16.0.5,那是倒数第二跳代理。第 5 行说明脏数据要跳过而不是当成结果:not-an-ip 这种条目如果跟着位置走,就会跑到频控 key 里去。四列里只有最后一列在五个场景下都站得住。

八、对比表与小结

做法 单跳伪造 多跳 链路中间有不受控代理 落在哪一层 可测性
取 XFF 首段 ✗ 直接绕过 应用代码 可测
取 XFF 最右 ✓ 碰巧对 ✗ 拿到倒数第二跳 配置 / 代码 一般
边界覆盖式 strip ✗ 真实客户端被永久抹掉 边界配置 基本没人测
后端两步解析 应用代码 可测
两层一起做(推荐) 两层 边界配置 + 代码

小结:

  • 评论说的"只信可信来源、不是就忽略",是解析的第 1 步;第 2 步"取哪一段"省不掉——Nginx 模板里最常见的 $proxy_add_x_forwarded_for追加语义,会把客户端伪造的值留在最左边。
  • "在 proxy 上 strip"不是野路子:Envoy 有 use_remote_address / xff_num_trusted_hops,Nginx 有 set_real_ip_from + real_ip_recursive。但它不是消除复杂度,是搬迁——搬过去用的还是同一套右→左,只是从可测的代码变成了不可测的配置。
  • 它还有个更硬的前提:覆盖式 strip 是有损的。链路中间夹着不受自己控制的代理时,覆盖会把真实客户端永久抹掉;想捞回来,那一层的 Nginx 自己还得跑 real_ip_recursive
  • 判据不是"代理是不是我的",是**"入口能不能被绕过"**。入口可强制 → 边界兜得住;不可强制 → 后端那步就是这条约束唯一的执行者。
  • 真要 strip,就得整族 stripX-Forwarded-For / X-Real-IP / Forwarded(RFC 7239) / CF-Connecting-IP / True-Client-IP…… 漏一个等于没做。
  • 最后一句跟框架无关:边界 strip 是写侧消歧,后端解析是读侧求解。strip 产出的是"缺省",缺省没法在代码里再实现一次——所以这不是重复劳动,是不假设前一层不出错。🐾

相关阅读