登录页写着每分钟允许输错5次密码,超过就封。我试了9次,一次都没被拦。

去查应用里其他限流规则,同样形同虚设。修的过程中撞见第二个问题:生产环境里,所有用户在我的应用看来都是同一个IP。没人发现,因为限流器压根没在工作。要是只修好第一个bug,限流器会开始运转——用一个计数器,统计整个互联网。

打开网易新闻 查看精彩图片

限流器为什么什么都没做

在ASP.NET Core里,每个请求都要穿过一条中间件链,一步接一步,顺序决定结果。

其中一步是路由,它负责判断"这个请求是发给登录接口的"。限流器需要这个答案,因为每个接口有自己的限额。

我的应用把限流器放在了路由前面。它太早去问"这是哪个接口",没拿到答案,就放行了。没有报错,日志里也什么都没有。

修复只需要挪一行:

  • app.UseRouting();
  • app.UseRateLimiter(); // 必须放在路由之后

如果你从不自己调用 UseRouting(),ASP.NET 会在最前面替你加上,那你就碰不到这个bug。我是在链路靠后的位置自己调用的,限流器就排到了它上面。

改完之后限额开始生效,另外三个问题跟着冒了出来。它们一直都在,只是限流器不工作时伤不到任何人。

每个用户都是同一个IP

我的限额按IP地址计数。但应用躲在 Cloudflare 和 nginx 后面,从不直接和用户对话。用户的真实IP是通过 X-Forwarded-For 这个请求头传进来的。

nginx 这样构造这个头:

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

$proxy_add_x_forwarded_for 会把收到的头拿过来,再加上连到 nginx 的那个地址。在我的部署里,那是跑在同一台机器上的 cloudflared。于是每个请求到达时都长这样(用户IP仅为示例):

  • X-Forwarded-For: 203.0.113.42, 127.0.0.1
  • 203.0.113.42 是真实用户,127.0.0.1 是 nginx 加上的 cloudflared
  • 修复前,我的应用取的是:127.0.0.1
  • 修复后,我的应用取的是:203.0.113.42

ASP.NET 的转发头中间件默认只读一项,也就是最后一项。而最后一项永远是 127.0.0.1。所以我那条"每个IP允许3次访客注册"的限额,实际会变成"所有人加起来3次"。

同一个地方还藏着第二个问题。我的配置信任任何人发来的这个头。生产环境里第一个问题把它盖住了,但如果没有那层额外的 nginx 跳转,客户端可以往头里写任意IP,换一个就拿到一个全新的计数器。我在测试环境里验证过,确实可行。

两个问题的修复大致如下:

  • o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
  • o.ForwardLimit = null; // 读整个列表,而不是只读最后一项
  • 只信任自己的跳转:回环地址和私有网段
  • KnownIPNetworks 清空后,依次加入 127.0.0.0/8、::1/128、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16

现在中间件从列表末尾往前读,跳过它信任的地址(也就是我自己的代理),停在第一个不信任的地址上。那就是真实用户。如果客户端往头里塞了假IP,它会排在更靠左的位置,中间件根本读不到。

十次错误密码能锁死所有人

我的登录限额根本不是按IP算的。它是全站一个计数器,每分钟10次。

限流器不工作时,这无所谓。限流器一开,任何人发十次错误密码,就能把所有人的登录关掉,密码重置也一样,因为用的是同一个限额。我把它改成了每个IP一个计数器。攻击者仍然有10次机会,其他人照样能登录。

我的测试之所以全绿,正是因为那个bug。限额开始生效后,集成测试开始失败。它们会快速发大量请求,之前能过,只是因为从来没有东西被拦过。我把限额做成可配置的,这样 CI 可以用更高的数字。

我还加了两个测试,检查限流器本身,而不是接口:

  • 发一个超过限额的请求,期望返回 429 Too Many Requests
  • 通过同一个代理,以两个不同用户的身份发请求,期望得到两个独立的计数器

在这之前,我从没见过自己的限流器返回 429。我觉得那才是真正的警告,而我错过了。

检查你自己的应用

发出超过限额的请求数。当然,只对自己的应用发:

for i in $(seq 1 11); do curl -s -o /dev/null -w "%{http_code}\n" -X POST https://your-app/login done

最后你应该看到 429。如果只看到 200,你的限额没在工作。然后每次换一个伪造的 X-Forwarded-For 头再试。如果 429 消失了,任何人都能绕过你的限额。

你有没有在生产环境里验证过,你的限流器真的会返回 429?我很好奇,是不是只有我一个人没查过。