为什么登录接口必须在网关层限流
给一个管理后台做安全评审时,登录接口被标了一个不那么直观的问题: 它是一个 CPU 放大器,而且这是防用户名枚举的必要设计的副作用。
恒定时间对比带来的副作用
登录验证用 bcrypt(cost 10)比对密码。如果用户不存在就立刻返回 「用户名或密码错误」,而用户存在才跑那约 76ms 的哈希计算,攻击者就能 通过响应时间的差异枚举出哪些用户名是存在的。标准解法:用户不存在时 也对一个假哈希跑一次完整的 bcrypt,让两条路径的耗时一致。
副作用随之而来:这个接口对任何未认证请求都要烧 76ms 的 CPU。 几百个并发请求就能把全部核心吃满。而这个后台只有一个管理员账号、 没有锁定机制、没有验证码——也就是说,接口面本身就这么小,没必要为 「体验」保留更大的口子。
为什么放在网关层
限流当然也能在应用里做,但放在网关层有两个实际的好处:
- 省下的就是赚到的。被限掉的请求在网关直接 429, 连后端容器都到不了,bcrypt 一次都不会跑。应用内限流只能阻止 「处理逻辑」,请求本身的解析、路由、中间件开销已经付出了。
- 策略集中。所有暴露面的限流规则在一个配置文件里, 评审和审计只看一处。
nginx limit_req 与 Caddy rate_limit 的语义差异
这套部署从 nginx 迁到 Caddy 时,限流换了个实现。两者语义不同:
- nginx 的
limit_req是「速率 + burst 队列」: 超出的请求排队等待,队列满了才拒绝。它平滑,但排队的请求会 占用连接和等待时间。 - Caddy 官方镜像没有限流,
rate_limit来自第三方插件 (构建镜像时编入)。它是「窗口内事件数」的令牌桶:窗口内超过 N 个事件就直接拒绝,没有排队。
换算时只能近似:nginx 的 5r/m + burst=5 大致对应
Caddy 的「1 分钟窗口内 5 个事件」。对登录这种真人一天点个位数的场景,
语义差异没有实际影响;真正要防的是脚本化的集中打点,两种实现都拦得住。
额度怎么定
原则是「按真实使用的量级留余量,而不是按想象的压力留余量」:
- 登录:5 次 / 分钟。真人管理员一天登录个位数次,这个额度连 输错密码重试都绰绰有余。
- 设备注册:20 次 / 20 秒。一台机器生命周期里只注册一次,burst 余量是为了「一次性上架一批机器」不被卡住。
- 普通 API:40 次 / 2 秒。单台客户端真实节奏远低于 1 次/秒, 额度是为「几十台机器共用一个公网出口 IP」(网吧、机房)留的。
按 IP 计数在 NAT 出口场景下会误伤,所以普通 API 的额度必须按 共享出口的最坏情况估算,而不是按单客户端的正常用量。
如实记录失去的能力
迁移不是没有代价。nginx 侧的每 IP 并发连接上限(slowloris 防护) 在 Caddy 没有等价物——Go 运行时对慢连接的抵抗力更好一些,但那道闸 确实不在了。把「失去了什么」和「为什么可以接受」写进配置注释里, 比假装能力无损重要得多。