Skip to content
This page has been auto-translated and may contain errors.View in English

识别客户端

"每分钟五次请求"这个限制,在你说清楚"五次针对什么"之前,什么都不意味着。

速率限制只有在你能持续判断出是谁在发起请求时才有效,而你选择的标识符会同时决定四件事:

  • 公平性。 你限制的是不是正确的对象?
  • 有效性。 有没有人能绕开它?
  • 体验。 合法用户会不会被误伤?
  • 安全性。 标识符本身会不会被滥用?

每一条速率限制背后都藏着这个问题:你到底想限制的是谁,或者是什么?

IP 地址

这是默认选项,也是你不用特意去要就会得到的那个。express-rate-limit 默认使用客户端的地址,除非你另行指定——上一章里的限流器一直就是这么做的。

适用于没有登录机制的公开接口、基础的滥用防护,以及简单洪水攻击的钝化处理。

它的吸引力在于它总是存在。不需要身份验证,对匿名流量也有效,实现简单,而且普通用户没法随意更改它。

问题在于地址是共享的,而且会变动。一间办公室里的几百名员工共用一条出口连接,在系统看来就是同一个客户端;图书馆里的所有人、运营商背后大量的移动用户也是同样的情况。

与此同时,一个地址不断变动的用户每换一次地址就会得到一份全新的额度,而 IPv6 又会在一个子网内分配大量地址。

与其被动继承默认行为,不如把这个选择明确写出来:

js
import { rateLimit, ipKeyGenerator } from 'express-rate-limit'

const limiter = rateLimit({
  limit: 5,
  windowMs: 60000,
  keyGenerator: (req, res) => ipKeyGenerator(req.ip),
})

keyGenerator 返回的是请求计数所依据的字符串。下面所有内容都基于这个钩子。

JunoIP 地址 陷阱在于,地址给人的感觉像是"一个人",但其实不是。它标识的是一条网络连接,而一条连接既可能只承载一个人,也可能承载整栋大楼。

这个策略的所有问题,都源于这个落差。

JunoIP 地址 在代理或负载均衡器后面,req.ip 拿到的是代理自己的地址,于是每个请求看起来都来自同一个客户端,你的限制实际上一次性套用在了全世界头上。

Express 需要设置 app.set('trust proxy', ...) 才能改为读取转发过来的地址。要把它设为你实际部署的代理数量,因为盲目信任这个请求头会让客户端随意伪造任意地址,从而完全绕开限制。

JunoIP 地址 如果不加处理直接使用,IPv6 会让按地址限流几乎失去意义,因为运营商通常会给单个用户分配一个 /64 前缀,其地址数量比整个 IPv4 互联网还多。按单个地址限流,等于是在限制他们还没用过的那些地址。

答案是按前缀限流。express-rate-limit 8 为此暴露了 ipv6Subnet 选项,默认值为 56,数值越小分组就越激进。

ipKeyGenerator 这个辅助函数存在的理由也一样:它把地址标准化,让 IPv4 和 IPv6 客户端能用一致的方式生成键。直接把 req.ip 写进键里,等到 IPv6 流量到来的那天就会悄无声息地失效。

已认证用户 ID

一旦有人登录,你就能准确知道他是谁——通过会话、JWT 声明或数据库查询得知。

适用于需要认证的接口、按用户配额限流,以及分级套餐。

每个真实用户都有自己独立的额度,谁都不会影响到别人。这个标识会跟着用户跨设备、跨网络移动,换地址也没用,因为身份认证的是"人",而不是"连接"。

它做不到的是保护登录之前的任何环节。注册、密码重置和公开接口都发生在用户 ID 存在之前,而这些恰恰是滥用最常盯上的地方。

js
const limiter = rateLimit({
  limit: 3,
  windowMs: 10000,
  keyGenerator: (req) => req.user.id,
})

app.get('/api/data', authenticate, limiter, handler)

顺序很关键。Express 的中间件是从左到右执行的,所以身份验证必须先设置好 req.user,限流器才能读到它。如果把限流器放在前面,它读到的会是所有人都一样的 undefined,等于把每一个请求都归到了同一个桶里。

Juno已认证用户 ID 让三个用户一起测试"每十秒三次"的限制,效果立刻就能看出来。每个人都有自己的三次额度,一个人碰壁了对其他人毫无影响。

这就是公平性在起作用。换成按地址限流,同一间办公室的三个人就得共用一份三次的额度了。

Juno已认证用户 ID 中间件顺序在这里是最值得警惕的 bug,而且它失败得很安静。一个在身份验证执行之前就读取 req.user.id 的限流器,要么会因为 undefined 而报错,要么会把所有人都归到同一个值上——等于给你的整个用户群设了一个共享限制。

这两种情况在正常路径的测试中都不会暴露出来,因为测试里只有一个用户,根本看不出差别。

Juno已认证用户 ID 按用户限流会把滥用的战线往前推一步:如果账号免费注册且没有限制,那这个标识符获取起来和地址一样便宜。用基于地址的限制去保护注册环节,正好补上这个漏洞——这也是为什么两者应该同时使用,而不是二选一。

一旦限制按用户区分,它本身也就成了产品的一部分。免费和付费套餐拥有不同额度,就意味着限制值必须是请求的函数而不是常量,而套餐等级的查询也因此落在了每个请求的关键路径上,记得把它缓存起来。

API 密钥与会话 ID

再来两种标识符,各自适合不同类型的调用方。

API 密钥会话 ID
适用于第三方集成、面向企业的 API、分级套餐有会话机制的 Web 应用、购物车、结账和表单流程
优势绑定到具体应用、支持分级、滥用时可撤销、易于追踪对登录用户和访客都适用、比地址更持久、能跟踪完整的操作流程
劣势密钥容易被共享和泄露、一个密钥可能服务成千上万的终端用户、需要密钥管理清除 cookie 就会重置、存在会话固定攻击的风险、无状态 API 根本没有会话

用 API 密钥做分级很自然,因为 limit 可以是请求的函数:

js
import { getUserTier } from './services/billing.js'

const limiter = rateLimit({
  windowMs: 60000,
  keyGenerator: (req) => req.headers['x-api-key'],
  limit: (req) => (getUserTier(req.headers['x-api-key']) === 'pro' ? 1000 : 100),
})

会话 ID 的读取方式相同,来自 req.session.id

JunoAPI 密钥与会话 ID 四种标识符,没有哪一个是"正确答案"。每一种标识的都是不同类型的调用方。

API 密钥标识的是一个应用。用户 ID 标识的是一个人。会话标识的是一次访问。地址标识的是一条连接。选那个和你想限制的对象相匹配的。

JunoAPI 密钥与会话 ID API 密钥的弱点常常让人意外:密钥标识的是集成方,而不是使用它的具体个人。

一个拥有五万终端用户的合作伙伴只有一个密钥,于是一个用户捣乱,就会消耗掉大家共用的额度,导致整个集成都被限流。

如果这个问题要紧,密钥就需要同时拥有一个按密钥计算的总额度,以及内部再按终端用户细分的额度——这意味着需要在集成中传递一个终端用户标识符。

JunoAPI 密钥与会话 ID 客户端能控制的东西都能被更换,会话 ID 是其中最糟糕的一个:清除 cookie 毫无成本,还能换来一份全新的额度,所以基于会话的限制只能挡住误操作,挡不住真正的攻击者。

无论按什么生成键,都有两件事值得做。永远不要把原始密钥直接放进键里,因为限流器的键最终会出现在日志和存储数据的转储中,所以要先对 API 密钥做哈希处理。

还要想清楚标识符缺失时会发生什么,因为 keyGenerator 返回 undefined 会把所有这类请求都归到同一个桶里。这可能是个有用的兜底方案,也可能是意外造成的共享限制——关键在于这应该是你主动做出的选择。

组合使用

实际系统很少只选一种。常见的做法是一条带回退的链条,优先使用最精确的:

  1. 已认证用户 ID,用户已登录时使用。
  2. API 密钥,用于集成场景。
  3. 会话 ID,用于有会话的访客。
  4. IP 地址,作为最后的兜底手段。

每往下一步都精确度更低、可用性更高,所以每个请求总能用它实际拥有的最佳标识符来计数。

Juno组合使用 这个顺序是从最精确到最普遍排列的,值得按这个方向去理解。

已登录的用户是你能识别到的最清晰的对象。地址是最模糊的,也是唯一始终存在的那个。

Juno组合使用 一个限流器里塞进整条回退链,有个值得留意的缺陷:所有落到地址这一级的请求会共用同一份额度,于是匿名流量之间会互相竞争。

通常更好的做法是运行多个各自拥有独立额度的限流器:给匿名流量一个严格的限制,给已认证用户一个宽松的限制,而不是用一个限流器去切换不同的键。

Juno组合使用 给键加上生成它的策略作为前缀:写成 user:4821,而不是裸的 4821。不这样做的话,用户 ID 和会话 ID 可能撞上同一个字符串,导致两个毫不相关的客户端共用了一份额度。

这条链条还掩盖了另一件事:它本质上是一条升级路径——攻击者会挑成本最低的那一级下手。

所以这条链条的强度取决于最薄弱的那一环,限制应该随着标识符越来越匿名而收得更紧,而不是从头到尾保持一致。

动手试试

某个 API 按每个 IP 地址每分钟 100 次请求做限流。三条投诉找上门来。

  1. 一家有 400 名员工的公司说应用每天下午都会失灵。
  2. 一个用移动网络的用户说这个限制对他好像从来都不起作用。
  3. 有人正在以每小时约一万次请求的速度爬取公开目录,而且没有任何东西能拦住他。
对照一下你的答案
#实际发生了什么解决方法
1400 人共用一个出口地址,于是 400 人共用每分钟 100 次请求的额度改为按已认证用户 ID 计数,把按地址限流仅保留给匿名流量
2运营商不断把他在不同地址之间切换,每换一次都会得到一份全新的额度同样的解决方法。跟随用户本人的身份不会因为重新连接而被甩掉
3目录是公开的,没有用户可供计数,而地址对爬虫来说获取成本几乎为零收紧匿名限制,按 IPv6 前缀而不是单个地址来限流,再加上前置的其他手段。这个问题只能被缓解,无法被彻底解决

第一条和第二条其实是同一个缺陷从两个角度看到的结果:地址对办公室来说太粗糙,对移动用户来说又太精细。

第三条则是这整套技术诚实的天花板。速率限制让滥用行为付出代价,而面对一个公开接口,这个代价的上限永远只能等于你最便宜的那个标识符的获取成本。

接下来往哪走

限流器现在能正确计数,也计到了正确的对象上。它还剩下的问题是固定窗口边界处的突发流量,会在窗口重置前后放行两倍于限制的请求量。

滑动窗口算法 解决了这个问题,它衡量的窗口会随请求移动,而不是死死卡在时钟的某个区块上。