我们上线的每一个按 IP 的防护,都能用一个 header 绕过
我们的账户锁定计数器把失败登录上限设为五次。我们的速率限制器会限流滥用的客户端。两者都以客户端的 IP 地址为键,这也是最顺理成章的做法。两者都能被完全绕过,而绕过手段只是一个你自己就能设置的 HTTP header。
原因是这样的。当你的应用跑在反向代理后面(我们的就跑在 Kubernetes 上的 nginx 后面),应用看到的 TCP 连接并不来自用户,而是来自代理。每个请求到达时的 socket 对端地址都是同一个:ingress。所以真正的客户端 IP 必须放在一个 header 里传递,也就是 X-Forwarded-For,由代理设置、应用读取。ASP.NET Core 正好有为此准备的中间件:它读取 X-Forwarded-For,把连接的远端 IP 改写成对应的值,这样后面的代码(包括你的速率限制器)看到的就是真实客户端。
问题在于,X-Forwarded-For 终究只是一个 header。谁都可以发。如果应用无条件信任它(我们当时就是,不管来自哪里都信),那么这个值就是攻击者可控的。所以这个攻击一点也不高明,就一行:
curl -H "X-Forwarded-For: 10.0.0.1" https://.../login
curl -H "X-Forwarded-For: 10.0.0.2" https://.../login
curl -H "X-Forwarded-For: 10.0.0.3" https://.../login
每个请求换一个 header 值,应用就以为每次尝试都来自一个全新的客户端。以那个 IP 为键的锁定计数器,对任何单个值都永远数不到五。速率限制器的桶每次都是崭新的。你可以整天暴力破解密码,而我们所有按 IP 的防线都开开心心地数到一。一个以攻击者可控的值为键的防护,根本不算防护。
修复听起来很简单:只信任来自你自己代理的 X-Forwarded-For。原则上确实简单,实践中却很琐碎,而琐碎的部分全在 Kubernetes 上。
你不能干脆不读这个 header。真那样做,每个请求看起来都来自 ingress,于是所有客户端共享一个 IP,按 IP 的防护就朝另一个方向崩掉:一个滥用者触发锁定,把所有人一起锁住。你必须读 X-Forwarded-For,但只有当请求确实来自你信任的代理时才相信它。
ASP.NET 的 forwarded-headers 中间件按来源建立信任:一个已知代理 IP 的列表,或者以 CIDR 给出的已知代理网段。最直觉的做法是把 ingress pod 的 IP 钉死。但在 AKS 上这个 IP 并不稳定。ingress pod 会被重新调度、重新伸缩,地址也跟着变,钉死的 IP 一旦过期,要么打断正常流量,要么(如果你留了个旧的兜底值)重新把洞打开。稳定的是这些 pod 取地址的那个子网。所以要信任的是节点子网的 CIDR,而不是任何单个地址。所有能合法设置这个 header 的跳都在这个网段里,网段之外的一律不信。
然后是数量问题。中间件从右往左遍历 X-Forwarded-For 列表,每次剥掉一个受信任的跳,受信任的跳剥完之后剩下的就当作客户端。多走一跳,你就越过了自己的代理,踩进了 header 里攻击者写的那一段。所以你剥掉的跳数必须严格等于实际会往这个 header 追加内容的代理数量。在我们的链路里只有一个:nginx。它前面的 Azure 负载均衡器是四层的,只转发 TCP,不解析 HTTP,也不会添加 X-Forwarded-For 条目。所以正确的 forward limit 是一。不是默认值,也不是"多留几个保险",就是一,因为只有一个代理会碰这个 header。
这一对设置,信任节点子网加上恰好剥一跳,就是修复的全部。中间件交给你的客户端 IP,现在就是 nginx 根据真实 socket 写下的那个值,而攻击者注入的条目排在它左边,被直接忽略,因为我们在走到它们之前就停了。
可迁移的经验不在于这些数字本身,而在于它们是关于你自己具体拓扑的数字。X-Forwarded-For 不是数据,而是信任委托:读它就等于在说"我相信设置它的那一方"。这份相信必须精确钉在你请求路径上实际存在的那几跳,不能更宽,也不能更窄。信得太宽就能被伪造,信得太窄就把所有客户端压缩成代理本身。而且这个数量和你的基础设施耦合在一起:哪天你在前面加了个 CDN,就多了一跳,昨天还正确的 forward limit 今天就少了一。修复不是某个万能常数,而是:数清楚你链路上的代理,只信任它们,且仅信任它们,路径每变一次就重新数一遍。
正确地数代理跳数这件事毫不起眼,容易出错,却决定了你的速率限制到底有没有意义。Authagonal 替你做好这笔账,让你的锁定计数针对的是客户端,而不是一个 header。