信任边界放哪一层:XFF 的「边界 strip」与后端兜底之辩
有读者在《X-Forwarded-For 取首段的陷阱》那篇下面留了一条评论,大意是:
搞太复杂了。就一点——这个 HTTP header 的内容是不是来自可信来源?比如自己信任的 proxy。如果不是,直接忽略掉。或者用更好的办法:直接在 proxy 上 strip 掉非可信来源的 header 内容,把复杂性丢到 proxy 上,简化后端业务代码。
这条评论我盯了两天才想清楚该怎么回。原因是它有一半是对的,而且是那种你说不出"不对"的对;另一半听起来更简洁,但那个简洁是错觉。
先说结论:**它描述的不是"更简单的实现",而是"把同一条约束换个地方执行"。**换个地方本身没问题——问题在于换过去之后,那条约束落进了一层基本没人测的配置里,而它本来是一段可测的代码。
一、评论其实说了两件事
| # | 评论的主张 | 它是什么 | 我的判定 |
|---|---|---|---|
| A | header 是否来自可信来源,不是就直接忽略 | 解析逻辑的第一步 | 原文已经这么写了 |
| B | 在 proxy 上 strip 掉非可信来源的 header,把复杂性丢给 proxy | 架构层面的归属主张 | 方向对,但它是"搬迁",不是"消除" |
先把 A 说清楚,因为它经常被当成"更简单的替代方案",其实不是。
XFF 的解析确实分两步,前一篇拆过:
- **先看直连方是不是可信代理。**不是 → XFF 里任何内容都不采信,直接用 TCP 对端地址。
- 是 → 从右往左跳过可信地址,取第一个不可信地址。
评论的 A 就是第 1 步。它成立,但没回答第 2 步的问题:来源可信之后,那段 XFF 里到底取哪一段?
这一步省不掉,因为"来源可信"和"可以取首段"是两件事。
二、分水岭:代理是覆盖写头,还是追加写头
决定"取哪一段"的,是代理怎么写这个头。
Nginx 侧的两种写法:
1 | # 首跳:覆盖。客户端带来的 XFF 被丢掉,只写 Nginx 亲眼看到的对端 |
$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_remove 把 x-forwarded-for 摘掉,再配 xff_num_trusted_hops = 0 + use_remote_address = true。这就是评论说的"在 proxy 上 strip"。
Nginx 做同一件事的是 realip 模块:
1 | set_real_ip_from 10.0.0.0/8; # 声明谁是可信代理 |
关键在最后一行。官方文档对 real_ip_recursive 的定义是:
- on:匹配可信地址的客户端地址,被替换成请求头里「最后一个非可信地址」(the last non-trusted address);
- off(默认):被替换成请求头里「最后一个地址」(the last address)。
而 nginx 作者 Maxim Dounin 在邮件列表里把这个过程逐步走过一遍,正好是最好的注解。他给的链路是:
1 | 客户端 192.168.1.79 → 客户端网关 178.150.189.138 → 你的 CDN 8.47.98.129 |
他逐步推出来的过程是这样的:
- TCP 对端是
10.6.1.56,它在可信名单里 → 才去看 XFF; - 取 XFF 里最近的那个地址
8.47.98.129,它也在可信名单里 → 继续往前一轮; - 再取剩下的
192.168.1.79, 178.150.189.138里最近的178.150.189.138,这次不在可信名单里 → 停下,它就是结果。
结论与文档那句一一对应:结果就是 XFF 里「最右边那个不可信地址」。Dounin 也顺带点出文档那句话为什么容易读岔——它描述的是「这一串动作跑完之后的结果」,没有把过程摊开讲。而把过程摊开,其实就是前面那两步解析从右往左走了一遍。real_ip_recursive on 和后端那套两步解析是同一个算法。
所以搬过去之后,复杂度并没有消失。它从 C++ 的 trust_ips 遍历,搬进了 nginx.conf 的 set_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 | X-Forwarded-For X-Real-IP X-Forwarded-Host |
其中 Forwarded 是 RFC 7239 定义的标准头(写法如 Forwarded: for=1.2.3.4;proto=https),不少框架会顺手读它;CF-Connecting-IP、True-Client-IP 则是 CDN 厂商的单值头。业务代码只要读了一个没被摘掉的头,洞就回来了——而且这次连"取哪一段"的困惑都没有,伪造值就是唯一的值。
两层并存的写法大致是这样:
1 | # 第一层:边界。覆盖式重写全族 —— 客户端带来的这些头一律丢掉 |
1 | // 第二层:后端。信任名单里只填边界的地址,别人谁都不信 |
六、这不是"又实现了一遍":写侧消歧与读侧求解
写到这里,可以澄清一件很容易误会的事:后端那一步不是把边界该做的事又做了一遍。
- 边界做的是写侧消歧——把可能被伪造的内容删掉,让后面只看到一种可能。
- 后端做的是读侧求解——在内容还是脏的、可能有三段来源的时候,把答案算出来。
strip 的产出其实是"没有了",也就是缺省。缺省没法在 C++ 里再实现一次;能在 C++ 里再实现的,只有那份"谁可信"的名单和那段遍历。
所以更准确的说法是:**我是在读侧把同一条判断又执行了一遍。**边界那层失效时——漏配一个新入口、有旁路、中间夹着不受控的一跳——它还能给出正确答案。
这就是纵深防御的定义:不是不信任边界,是不假设边界永不出错。
七、三种读取策略,跑一遍看差异
空口说"右→左才对"没意思。把同一批请求头喂给三种策略,看它们各自算出什么(可信网段 10.0.0.0/8、172.16.0.0/12,直连方 10.0.0.1):
1 | import ipaddress |
1 | 场景 取首段 取最右 右→左跳过可信 |
第 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,就得整族 strip:
X-Forwarded-For/X-Real-IP/Forwarded(RFC 7239) /CF-Connecting-IP/True-Client-IP…… 漏一个等于没做。 - 最后一句跟框架无关:边界 strip 是写侧消歧,后端解析是读侧求解。strip 产出的是"缺省",缺省没法在代码里再实现一次——所以这不是重复劳动,是不假设前一层不出错。🐾

