X-Forwarded-For 想填谁就填谁:信任链里那段 Pod 网段

外部请求自带一个伪造的 X-Forwarded-For,就能改掉日志里的来源地址,六次里成功三次。realip 信任了一段 Pod 网段,而跨节点流量恰好被 SNAT 到那段里。收窄到单个地址后,十二次全部失败。

周一早上,安全的同事把一份登录失败清单丢过来:同一个账号在两百多个不同地址上尝试登录,按我们的规则早该被限流,但计数一直停在 3。他问我是不是规则写错了,或者阈值配大了。

我先看了计数逻辑,没问题;改看入口访问日志,问题立刻清楚了——同一个客户端,每次请求落到日志里的来源地址都不一样,有一批根本不是我们的网段。请求头里带着自造的 X-Forwarded-For,而我们把它当成了真实来源。

一、先弄清每一层是谁在写这个头

这条链路上有三个会碰 X-Forwarded-For 的角色:最外面的 WAF、中间的 ingress、最后的应用。WAF 侧用的是 $proxy_add_x_forwarded_for,也就是"原来有什么就保留什么,末尾追加对端地址";ingress 侧开了 realip 模块,会按可信代理列表决定采信谁;应用侧直接把首段当地址用。

所以我先做了一件最简单的事:带着假地址从集群外打入口,看最终落到访问日志里的是谁。

# 从集群外直连入口 VIP,请求头自带一个伪造地址
for i in $(seq 1 6); do
  curl -s -o /dev/null -w '%{http_code} ' \
    -H 'X-Forwarded-For: 203.0.113.77' \
    -H "Host: example.com" \
    "http://$INGRESS_VIP/login.html"
done; echo

# 看入口 Pod 自己记了什么
kubectl -n ingress-nginx logs deploy/ingress-nginx-controller --tail=80 \
| grep login.html

六次请求,访问日志里三次记的是 203.0.113.77,三次记的是集群内部地址。同一份请求头,一半采信一半不采信——这说明判断依据不是请求头长什么样,而是"这条 TCP 连接从哪来"。

二、realip 模块只认连接的对端地址

nginx 的 realip 逻辑很短:只有当 TCP 连接的对端地址命中 set_real_ip_from,才从这个对端给的 X-Forwarded-For 里取值替换 $remote_addr;不命中就完全忽略请求头。ingress-nginx 把这项配置暴露成 ConfigMap 里的 proxy-real-ip-cidr。

我们当时的写法是:

# 当时(错的)
data:
  use-forwarded-headers: "true"
  compute-full-forwarded-for: "false"
  proxy-real-ip-cidr: "10.244.0.0/16"

当时的想法很直白:链路上只有 Pod 会发请求进来,那整段 Pod 网段就是"可信代理"。这个推理在只有一层代理时成立,在我们这套网络里不成立。

三、SNAT 让一段内部地址变成外部可命中的集合

集群用的是 Calico,跨节点访问 Service 时,源地址会被 SNAT 成该节点 Pod 网段的块网关地址——每个节点一个 /26,源地址就是这块的起始地址。这些地址天然落在 10.244.0.0/16 里面。

结果就是:一个从集群外发起的请求,只要走了 SNAT 路径,它在 ingress 看来"对端是一个可信代理",于是请求头里的假地址被采信;如果恰好命中本节点的 endpoint、没走 SNAT,对端是真实外部地址,不命中白名单,请求头被忽略。

这也解释了为什么是"六次里三次",以及为什么日志里的地址分布看着毫无规律——它取决于请求落在哪台节点、有没有被转发一跳。

配置项 当时的写法 实际效果
proxy-real-ip-cidr 10.244.0.0/16 整段 Pod 网段被当可信代理,SNAT 后的外部请求同样被信任
proxy-real-ip-cidr <tengine-pod-ip>/32 只信任入口那一个地址,伪造头不再被采信
use-forwarded-headers true ingress 向上游重写转发头,应用侧看到的才是它算出来的地址
compute-full-forwarded-for false 追加而非整串拼接,避免首段留在客户端可控范围

四、收窄到单个地址后,必须双向验证

把值改成 WAF Pod 的地址 /32 只需要一行,但验证不能只测一个方向。只测"伪造被挡",很容易漏掉"正向链路也失效"——后者的表现是所有用户的地址都变成 WAF 的地址,页面完全正常,只有 IP 黑名单和登录失败计数静默失效。

# 反向:外部伪造,期望采信次数为 0
for i in $(seq 1 12); do
  curl -s -o /dev/null -H 'X-Forwarded-For: 203.0.113.77' \
    -H "Host: example.com" "http://$INGRESS_VIP/api/health"
done
kubectl -n ingress-nginx logs deploy/ingress-nginx-controller --tail=40 \
| grep -c '203.0.113.77'      # 期望 0

# 正向:从 WAF 容器内发一次,应用侧应看到 WAF 之前的地址
kubectl -n safeline exec deploy/safeline-tengine -- \
  curl -s -H 'X-Forwarded-For: 198.51.100.20' \
  "http://ingress-nginx-controller.ingress-nginx/api/health"

改完之后,反向我打了十二次,采信 0 次;正向从 WAF 容器内发请求,应用侧记到的仍是链路上的真实客户端地址,不是 WAF 自己的地址。两个方向都过,才算改对。

顺带说一句:WAF 自己的 location 里原来写的是 $proxy_add_x_forwarded_for,也会把客户端可控的首段带进来。我们在 WAF 侧把它改成 $remote_addr,只留一跳,和 ingress 侧的信任链对齐。

五、想彻底消掉 SNAT,改一个字段就把入口改没了

有人提议:把 Service 的 externalTrafficPolicy 从 Cluster 改成 Local,请求就不跨节点转发了,源地址天然保留,连 realip 都不用配。

我在测试窗口试了。改完之后从另一台节点访问入口 VIP,直接 ARP FAILED,不可达。原因是这套集群的负载均衡不是简单的 ARP 宣告模式,而是 BGP 模式:Local 会改变路由宣告的范围,只有本节点有 endpoint 时才宣告,于是从别的节点过去就断。回滚回去,十一条 Ingress 复测全部恢复。

这个字段在 BGP 模式下不是"性能开关",是"路由宣告开关"。在没有把 BGP 宣告策略一起想清楚之前,不要动它。

六、把这件事做成一项例行检查

网络配置最怕的不是写错,而是"以前对、后来漂了":WAF 的 Pod 重建一次,IP 就变,/32 立刻失效,而页面一切正常,没有任何报错。

所以我们加了一段巡检,比较期望值和现值,不一致就告警:

POD_IP=$(kubectl -n safeline get pod -l app=safeline-tengine \
  -o jsonpath='{.items[0].status.podIP}')
CUR=$(kubectl -n ingress-nginx get cm ingress-nginx-controller \
  -o jsonpath='{.data.proxy-real-ip-cidr}')
if [ "$CUR" = "$POD_IP/32" ]; then
  echo "OK $CUR"
else
  echo "DRIFT cur=$CUR want=$POD_IP/32"
fi

另外补一条业务侧回归:伪造五次请求之后,登录失败计数应该正好加五。这条比看日志更直接——它验证的是"整套链路最终有没有把地址用对",而不是"某一层有没有配对"。

小结

  • X-Forwarded-For 是客户端可以随便写的字段,只有在"连接对端可信"的前提下才能采信。
  • realip 的信任范围要写成单个代理地址 /32,写网段等于把整段内部地址都变成可信来源。
  • Calico 跨节点会 SNAT 到 Pod 网段的块网关地址,所以"Pod 网段"并不是一个纯粹的内部集合。
  • 源地址相关的改动必须双向验证:伪造要被挡,正常链路也要仍然采信,缺一半都会静默失效。
  • BGP 模式下的 externalTrafficPolicy 影响的是路由宣告,不是转发性能,改之前先想清楚代价。
  • 代理 Pod 重建会改 IP,/32 需要巡检或自动同步,否则配置会静默漂移。
查看「容器与编排」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。