
最近踩了一个Webhook签名验证的坑,排查了半小时才搞清楚根因。这篇把问题说清楚,重点是:签名检查必须在任何JSON解析中间件运行之前完成,要用接收到的原始字节来校验签名。
部署后签名检查失败的常见原因很明确:发送方对一组字节序列做了签名,而服务端校验的是另一组字节序列。express.json()会消费掉请求流,把req.body替换成一个JavaScript值。再把这个值序列化回去不是补救方案——即使JSON语义相同,空格、转义、重复键或数字表示方式都可能不同。
中间件顺序就是可执行的安全策略。本地环境可能收到一个Buffer,到了生产环境却失败了,因为应用层的express.json()被先注册了。等验证处理器运行时,原始字节早就没了。换个哈希库解决不了这个问题。
这容易让人困惑,因为常规JSON测试用的都是紧凑的测试数据。陷阱出现在这些场景:发送方改了无关紧要的格式、代理路径到达了不同的Express栈、或者重构把全局解析器移到了Webhook路由之前。想象一个合法的支付事件以格式化后的JSON到达:解析器把它转成和紧凑测试数据相同的对象,所以业务断言都通过了,但重新构建JSON时会去掉原始空格,产生不同的HMAC输入。Payload依然能解析,但MAC不匹配。这个分离的结果本身就是有用的证据:解析成功但字节级认证失败。
不要对JSON.stringify(req.body)做签名。要保留原始body。
下面的物业管理示例中,lease.payment_recorded事件会更新住户账本。这足够敏感,验证不确定时就拒绝:没有有效签名就不修改账本、不返回暗示成功的确认、也不返回verbose响应来泄露哪个候选密钥差点匹配。
示例定义了一个小型签名契约:发送方在x-webhook-timestamp中放置Unix时间、在x-webhook-key-id中放置密钥标识符、在x-webhook-signature中放置小写十六进制HMAC-SHA-256。被签名的消息是ASCII时间戳、一个句点、然后是未处理的请求字节。你的实际发送方的规范是权威的;头部名称和消息构造是协议细节,不是可互换的约定。
import express, { Request, Response } from "express";
import { createHmac, timingSafeEqual } from "node:crypto"; const app = express();
const MAX_AGE_SECONDS = 300; type SigningKey = { id: string; secret: Buffer }; // 在启动或轮换时从密钥管理器加载。
const signingKeys: SigningKey[] = [ { id: "primary-42", secret: Buffer.from(process.env.WEBHOOK_SECRET!, "utf8") },
]; function verify(req: Request): { ok: boolean; keyId?: string; reason?: string } { if (!Buffer.isBuffer(req.body)) return { ok: false, reason: "body_not_raw" }; const timestampText = req.header("x-webhook-timestamp"); const suppliedHex = req.header("x-webhook-signature"); const requestedKeyId = req.header("x-webhook-key-id"); if (!timestampText || !suppliedHex || !requestedKeyId) { return { ok: false, reason: "missing_header" }; } const timestamp = Number(timestampText); const now = Math.floor(Date.now() / 1000); if (!Number.isInteger(timestamp) || Math.abs(now - timestamp) > MAX_AGE_SECONDS) { return { ok: false, reason: "stale_timestamp" }; } if (!/^[0-9a-f]{64}$/.test(suppliedHex)) { return { ok: false, reason: "malformed_signature" }; } const key = signingKeys.find((candidate) => candidate.id === requestedKeyId); if (!key) return { ok: false, reason: "unknown_key_id" }; const signed = Buffer.concat([ Buffer.from(`${timestampText}.`, "ascii"), req.body, ]); const expected = createHmac("sha256", key.secret).update(signed).digest(); const supplied = Buffer.from(suppliedHex, "hex"); return timingSafeEqual(expected, supplied) ? { ok: true, keyId: key.id } : { ok: false, reason: "signature_mismatch" };
} app.post( "/webhooks/lease-events", express.raw({ type: "application/json", limit: "256kb" }), (req: Request, res: Response) => { const result = verify(req); const receivedAt = new Date().toISOString(); if (!result.ok) { console.warn(JSON.stringify({ action: "webhook.verify", outcome: "rejected", reason: result.reason, receivedAt, })); res.sendStatus(401); return; } let event: { id?: string; type?: string; propertyId?: string }; try { event = JSON.parse(req.body.toString("utf8")); } catch { res.sendStatus(400); return; } console.info(JSON.stringify({ action: "webhook.verify", outcome: "accepted", keyId: result.keyId, eventId: event.id, eventType: event.type, propertyId: event.propertyId, receivedAt, })); res.sendStatus(204); },
); // 其他JSON端点可以在原始Webhook路由之后使用解析后的body。
app.use(express.json());
app.listen(3000);
长度和十六进制检查在timingSafeEqual之前执行,因为Node要求缓冲区长度相等。五分钟的新鲜度窗口限制了旧签名消息的接受,但这不能提供完整的重放保护。存储每个已接受事件的ID并设置过期时间,在应用账本变更前拒绝重复事件。也要让账本写入具备幂等性;即使没有攻击者存在,重复投递也是正常现象。
256 KB的路由限制是一个有意的资源边界,不是通用建议。设置为刚好超过契约测试中观察到的最大合法事件。全局多兆字节限制会在一个应该携带小事件封装的路径上浪费内存。
这种方法有局限性。原始body路由消耗的内存与接受的payload成正比,不适合大型流式投递;对于那些场景,选择一个能增量认证流的协议和框架路径。如果一个应用的框架拥有所有body解析权,路由顺序的吸引力也会降低。这种情况下,使用框架文档中记录的原始body捕获钩子,并用集成测试证明捕获的缓冲区是解析前的输入。权衡很明确:路由特定的原始解析易于审计,而全局捕获钩子减少了路由约束,但会保留每个匹配请求的副本,除非其作用域保持紧凑。
我会把那个作用域保持得很窄。
当事件负责人标记primary-42泄露时启动计时器。通过审批的密钥系统创建替换密钥,分配新的密钥ID,配置发送方用它签名。在一个简短的声明重叠期内,验证器可以持有两个密钥并按密钥ID选择。这避免了尝试每个密钥,也让审计记录清晰无歧义。
然后发送三个受控事件:一个用新密钥签名,一个用退役密钥签名,一个用无效签名。预期证据是具体的。新密钥成功,退役密钥仅在重叠期内成功,无效事件收到401且不触碰住户账本。发送方切换后,移除退役密钥并重复其测试;现在必须失败。
保持密钥轮换的无聊(可预测)。
审计跟踪应该回答:谁授权了轮换、每个密钥何时生效或失效、哪个密钥ID验证了事件、下游处理是否提交。它不应包含密钥、签名值或原始住户payload。OWASP(开放式Web应用安全项目)建议对密钥进行生命周期管理、最小权限、轮换、吊销和审计;演练将这些控制作为一个链条来执行,而不是把轮换当作一个控制台操作。
一个没有标识符的单一密钥容易上手,但在响应时很弱:一个已接受的事件无法归因于旧凭证还是新凭证。带有显式ID的密钥环需要一点配置成本,但带来确定性选择、有界重叠和有用的证据。对于访问审计,这个权衡是值得的。
同样,记录每个body让调试感觉更快,但创建了第二个敏感数据存储。结构化元数据足以用于验证决策。关联eventId、propertyId、keyId、结果、原因和接收时间,然后按照与其他安全记录相同的访问策略保护和保留这些日志。
测试应该覆盖字节级行为,而不仅仅是解析后的对象。对包含空格、转义Unicode和交替键顺序的测试数据签名;断言原始字节通过而一个字节的改变失败。添加一个启动生产中间件顺序的集成测试。最后,覆盖过期时间戳、格式错误的十六进制、未知密钥ID、重复事件ID、超大body和对领域无效但真实的JSON。认证在解析之前发生,而模式验证和授权在解析之后发生。
对于部署,暴露已接受和已拒绝验证结果的计数,按低基数原因和密钥ID分组。在发布或轮换后对突然的拒绝增加发出警报,但不要将事件ID放入指标标签。还要关注延迟:密钥查找不应在每次投递时都变成一次网络往返。通过受控刷新路径加载活动环,限制对Webhook进程的访问,并在重叠关闭时从内存中擦除退役材料。
在宣布演练完成之前,确认路由接收到一个Buffer、生产解析器顺序与测试匹配、替换密钥验证了一个已知测试数据。确认旧密钥在重叠后已被移除、重放的事件无法重复账本变更、拒绝日志包含稳定的原因但不包含payload或凭证材料。一个没有执行轮换的操作员应该能够仅从记录中重建其授权、时间、密钥转换、测试结果和下游影响。
这就是决策规则:先保留字节、通过显式密钥ID轮换、使每个安全转换独立可审计。签名检查只是一条防线。演练证明了整条路径。