
两个代理一层套一层,客户端在请求头里自己写了一个 X-Forwarded-For。到后端的请求头是这样:
== 1. 两层代理都做 append,客户端自带一个 X-Forwarded-For ==
代理1 收到 X-Forwarded-For = 1.2.3.4(对端 127.0.0.1),转发出去 = 1.2.3.4, 127.0.0.1
代理2 收到 X-Forwarded-For = 1.2.3.4, 127.0.0.1(对端 127.0.0.1),转发出去 = 1.2.3.4, 127.0.0.1, 127.0.0.1
后端 收到 X-Forwarded-For = 1.2.3.4, 127.0.0.1, 127.0.0.1(对端 127.0.0.1),不做转发
后端看到 X-Forwarded-For = '1.2.3.4, 127.0.0.1, 127.0.0.1',对端是 127.0.0.1
按第一个值取客户端 IP -> 1.2.3.4
客户端写进去的 1.2.3.4 就在第一个位置上。下面五条是常见的取值写法,一条条批。
错法一:取第一个值
这是最普遍的一种,理由是「最早的客户端在最左边」。上面这条链里,客户端自己填的那一段被代理原样保留在最前面,任何代码去读第一个值,读到的都是别人写的字符串。
同一个客户端,五个请求,每个请求换一个伪造值:
== 2. 同一个客户端,换五个伪造值,naive 取出的键有几种 ==
五个请求的客户端其实是同一个(都从 127.0.0.1 发出)
客户端自带的 1.2.3.4 -> naive 键 = 1.2.3.4
客户端自带的 5.6.7.8 -> naive 键 = 5.6.7.8
客户端自带的 9.9.9.9 -> naive 键 = 9.9.9.9
客户端自带的 10.0.0.1 -> naive 键 = 10.0.0.1
客户端自带的 不带头部 -> naive 键 = 127.0.0.1
不同键的数量 5 个
客户端自带的 1.2.3.4 -> 取最后一个 = 127.0.0.1
客户端自带的 5.6.7.8 -> 取最后一个 = 127.0.0.1
客户端自带的 9.9.9.9 -> 取最后一个 = 127.0.0.1
客户端自带的 10.0.0.1 -> 取最后一个 = 127.0.0.1
客户端自带的 不带头部 -> 取最后一个 = 127.0.0.1
不同键的数量 1 个
五个请求都从 127.0.0.1 发出,取第一个值得到五个不同的键,取最后一个得到同一个键。键是用来做限流计数的,也是用来查黑名单的,同一台机器就能造出五个身份,造五十个也一样。
错法二:每一跳都覆盖,省事但丢来源
有人反过来做,让每一跳把整个头覆盖成对端地址。这样客户端伪造的段确实没了:
== 3. 换成每一跳都覆盖,客户端自带的那个就没了 ==
代理1 收到 X-Forwarded-For = 1.2.3.4(对端 127.0.0.1),转发出去 = 127.0.0.1
代理2 收到 X-Forwarded-For = 127.0.0.1(对端 127.0.0.1),转发出去 = 127.0.0.1
后端 收到 X-Forwarded-For = 127.0.0.1(对端 127.0.0.1),不做转发
后端看到 X-Forwarded-For = '127.0.0.1'
客户端伪造的 1.2.3.4 还在吗 = False
代价是整条链只剩下一段,所有客户端的地址都变成最后一跳的地址。限流把它当成一个用户,日志里也查不到真实来源。它解决的问题是「不能伪造」,代价是「没有信息」。
错法三:链长写死
取值位置写成固定的下标,比如用逗号切开后取第三段,前提是链上正好有几跳。上面两种模式已经给了两个链长:全 append 得到三段,全覆盖得到一段。链上多加一跳,去掉一层代理,或者在代理和业务之间插一个负载均衡,下标就全错位,错位之后不会报错,只会悄悄拿到别人的地址。
改成从右边数更稳:自己这一侧有几层可信代理是已知的,从最右边数第 N 段就是可信来源。数错了会退到代理地址上,不会退到伪造值上,失败方向是安全的。
错法四:假装这个头一定存在
不带 X-Forwarded-For 的请求照样能到达后端:
== 4. 客户端完全不带头部时的取值 ==
代理1 收到 X-Forwarded-For = None(对端 127.0.0.1),转发出去 = 127.0.0.1
代理2 收到 X-Forwarded-For = 127.0.0.1(对端 127.0.0.1),转发出去 = 127.0.0.1, 127.0.0.1
后端 收到 X-Forwarded-For = 127.0.0.1, 127.0.0.1(对端 127.0.0.1),不做转发
后端看到 X-Forwarded-For = '127.0.0.1, 127.0.0.1'
链条长度 2,第一个 = 127.0.0.1,最后一个 = 127.0.0.1
头部不存在时,代理自己会补一段。取下标 0 的代码在这条路径上拿到的是对端地址,不会有异常,所以这类分支往往在测试里从没被走到过,上线之后才有人用不带头的请求把它打出来。
错法五:当身份用
前面四条都是取值位置的问题。更麻烦的是把取值结果当成不可伪造的身份:按它做 IP 白名单、按它判断「是不是内网来的」、按它做审计留痕。最左边那一整段都是客户端可控的字符串,把它当身份等于把判断权交给对方。
要判断「是不是内网来的」,用 TCP 连接的对端地址判断,那个值改不了。要做客户端身份,用签名或者令牌,别用地址。
正确的取值方式
三件事一起做。
服务端记录两个值:连接的对端地址,以及从右往左数、跳过已知可信跳数之后的那一段。前者不会被伪造,后者能穿透反向代理。两个都存下来,出现争议时能对上。
网络侧只保留一种行为:代理追加,绝不透传客户端的头,也绝不整段覆盖。追加模式下,客户端多塞的段只会出现在左边,离可信的位置更远。
解析按 RFC 7239 的 Forwarded 头更规范,它的语法规定了各段由每一跳自己追加、for= 里带引号、IPv6 用方括号。X-Forwarded-For 是早年的事实标准,没有出过正式规范,各家代理在「追加还是覆盖」上的默认行为并不一致,这也是同一份代码换一个网关就出现不同结果的原因。
我的取舍是:应用侧只认一个由网关写好的头,名字自己定,网关负责清空客户端同名头再写入。这样应用代码不用关心链上有几跳,取值位置也就固定了。要落地这条,前提是确认没有请求能绕过网关直达应用,这一条得在部署结构上保证,光靠代码不行。