> Base64 的分页 cursor 不是"不透明"——没签名的 cursor 是攻击者直接写进你查询的参数

Systems & Infrastructure Intermediate 2026-10-09 00:25 2026-10-09
#pagination #security #hmac #api-design #authorization #nodejs

游标分页里最常见的实现是这样的:

结论先行

游标分页里最常见的实现是这样的:

const encodeCursor = (d: {timestamp: number; postId: string}) =>
  Buffer.from(JSON.stringify(d)).toString('base64');

Base64 不是加密,也不是签名。 它只是让这串东西看起来不像 JSON。 任何人都可以 decode、改掉里面的 timestamp 或 postId、再 encode 回去。

于是这个 cursor 的真实身份是:一个被解析成结构化对象、直接拼进数据库查询、 而且完全由客户端控制的参数。你给它做的所有"校验"(字段在不在、类型对不对) 防的是格式错误,不是伪造。

加 HMAC 就够了,服务端持密钥,一行验签:

const sign = (json: string) =>
  crypto.createHmac('sha256', CURSOR_SECRET).update(json).digest('hex');

const encodeCursor = (d: unknown) => {
  const json = JSON.stringify(d);
  return Buffer.from(`${json}.${sign(json)}`).toString('base64');
};
// decode 时先比签名,不符直接 400,连 JSON.parse 都不要做

为什么

Base64 之所以骗人,是因为它看起来已经处理过了。 一个裸的 ?after=1728345600 没人会觉得它可信; 同一个值写成 eyJ0aW1lc3RhbXAiOjE3Mjgz... 之后, 在 code review 里就会被当成"框架生成的不透明句柄"滑过去。

危害的大小取决于 cursor 里装了什么:

  • 只有一个时间戳 → 用户能翻到任意时间点。通常可接受。
  • 带 userId / tenantId / shardId → 越权读。 cursor 成了一个没人检查的授权凭据。
  • 带偏移量或内部主键 → 枚举。配合可预测的 ID 就是全量导出。

真正的问题不是某一个字段,而是信任的位置错了: 服务端把自己上一次的输出当成可信输入收了回来, 但中间经过了一个它不控制的地方。

顺带一个设计上的收益

签名之后,decodeCursor 的失败模式变干净了:先验签,不符就 400 直接返回, 不用再去 JSON.parse 一段攻击者可控的字符串,也不用写一堆 "字段存在吗 / 类型对吗 / 范围合理吗"的防御性校验。 那些校验原本就只能拦住手滑,拦不住恶意。

怎么用

  1. 列清楚 cursor 里有什么。 只要有一个字段参与权限判断或指向内部标识, 就必须签名。只有时间戳也建议签——成本是一次 HMAC。
  2. 验签放在解析之前。 顺序反了就等于还是解析了不可信输入。
  3. 密钥和 JWT 的密钥分开,并且不要硬编码。 (同一份审计里另一条就是 JWT secret 写死在代码里—— 这两个问题经常同时出现,因为它们出自同一个"先跑起来"的阶段。)
  4. 换密钥时让旧 cursor 平滑失效:验签带上 key id, 否则轮换密钥会让所有正在翻页的用户同时 400。
  5. 更一般的判据:任何"服务端发出去、客户端再送回来"的状态, 都要问一句「如果它被改了会怎样」。 session、cursor、 回调里的 state、预签名 URL 的参数,是同一类东西。

出处

~/Engineering/Timeline-Feed 的分析报告,5.5「Cursor Can Be Forged」。 同一份报告里的 3.4 是"cursor 缺少校验"——它们被列成了两条, 但其实后者是前者的症状:正因为没签名,才需要一堆字段校验来兜底, 而那些校验又拦不住真正的问题。 相关:把所有拒绝都压成 429,等于让客户端在重试永远不可能成功的时候重试、为噪声写的过滤器会精确杀掉最强的那条输入——显式指令必须走旁路。