> Base64 的分页 cursor 不是"不透明"——没签名的 cursor 是攻击者直接写进你查询的参数
游标分页里最常见的实现是这样的:
// TABLE_OF_CONTENTS
结论先行¶
游标分页里最常见的实现是这样的:
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 一段攻击者可控的字符串,也不用写一堆
"字段存在吗 / 类型对吗 / 范围合理吗"的防御性校验。
那些校验原本就只能拦住手滑,拦不住恶意。
怎么用¶
- 列清楚 cursor 里有什么。 只要有一个字段参与权限判断或指向内部标识, 就必须签名。只有时间戳也建议签——成本是一次 HMAC。
- 验签放在解析之前。 顺序反了就等于还是解析了不可信输入。
- 密钥和 JWT 的密钥分开,并且不要硬编码。 (同一份审计里另一条就是 JWT secret 写死在代码里—— 这两个问题经常同时出现,因为它们出自同一个"先跑起来"的阶段。)
- 换密钥时让旧 cursor 平滑失效:验签带上 key id, 否则轮换密钥会让所有正在翻页的用户同时 400。
- 更一般的判据:任何"服务端发出去、客户端再送回来"的状态,
都要问一句「如果它被改了会怎样」。 session、cursor、
回调里的
state、预签名 URL 的参数,是同一类东西。
出处¶
~/Engineering/Timeline-Feed 的分析报告,5.5「Cursor Can Be Forged」。
同一份报告里的 3.4 是"cursor 缺少校验"——它们被列成了两条,
但其实后者是前者的症状:正因为没签名,才需要一堆字段校验来兜底,
而那些校验又拦不住真正的问题。
相关:把所有拒绝都压成 429,等于让客户端在重试永远不可能成功的时候重试、为噪声写的过滤器会精确杀掉最强的那条输入——显式指令必须走旁路。