下面是一个 Webhook 签名测试:它能通过,看起来很负责,却几乎什么也证明不了:
const ts = Math.floor(Date.now() / 1000).toString();
const sig = await hmacHex(`${ts}.${body}`, secret, "SHA-256");
const headers = new Headers({ "stripe-signature": `t=${ts},v1=${sig}` });
expect(await verifySignature("stripe", headers, body, secret)).toBe(true);
这个测试夹具是用验证函数所检查的同一个公式构造的。如果你在写验证函数时读错了 Stripe 的文档(签错了字符串、用错了分隔符、该用 base64 的地方用了 hex),你的测试会把同样的误读写进去,两者都保持绿色。你测试的不是“我们能验证 Stripe 的签名”。你测试的是“我们和自己意见一致”。
我们曾经有 70 个这样的测试,覆盖 20 个平台。每一个都通过了。然后我们问了唯一重要的问题:由平台自己的代码生成的签名,能通过我们的验证函数吗?
拿平台的 SDK 交叉验证
有些平台在官方 SDK 里附带了签名函数,通常正是为了这个用途。Stripe 的是 generateTestHeaderString;Svix 的 Webhook 类有 .sign()。如果它们本来就是你的依赖,交叉验证不花任何成本:
import Stripe from "stripe";
import { Webhook } from "svix";
import { verifySignature } from "./index";
it("accepts a header generated by stripe-node", async () => {
const stripe = new Stripe("sk_test_" + "0".repeat(24));
const payload = JSON.stringify({ id: "evt_1", type: "charge.succeeded" });
const secret = "whsec_" + "s".repeat(32);
const header = stripe.webhooks.generateTestHeaderString({ payload, secret });
expect(
await verifySignature("stripe", new Headers({ "stripe-signature": header }), payload, secret)
).toBe(true);
});
it("accepts a signature generated by the svix SDK", async () => {
const secret = "whsec_" + Buffer.from("k".repeat(24)).toString("base64");
const payload = JSON.stringify({ type: "email.received" });
const msgId = "msg_crosscheck";
const ts = new Date();
const signature = new Webhook(secret).sign(msgId, ts, payload);
const headers = new Headers({
"svix-id": msgId,
"svix-timestamp": Math.floor(ts.getTime() / 1000).toString(),
"svix-signature": signature,
});
expect(await verifySignature("svix", headers, payload, secret)).toBe(true);
});
现在断言的是我们和 Stripe 一致,而不是我们和自己一致。再加上反向用例(对一个请求体签名,然后去验证另一个不同的请求体),这样你才知道这个测试真的会失败:
const header = stripe.webhooks.generateTestHeaderString({ payload, secret });
const tampered = JSON.stringify({ id: "evt_1", amount: 999999 });
expect(
await verifySignature("stripe", new Headers({ "stripe-signature": header }), tampered, secret)
).toBe(false);
因为 Clerk 和 Resend 都通过 Svix 的基础设施投递,一个 Svix 交叉验证就覆盖了三个平台。我们的二十个验证函数里,现在有四个有 SDK 背书的证明,而同样一个下午的工作,适用于任何技术栈。
没有公开签名函数的平台怎么办?
Shopify、Discord、PayPal:大多数平台都不提供签名函数。对它们来说,唯一诚实的证据是一次捕获下来的真实投递。把一个 Webhook 指向你能控制的端点,录下一个签过名的请求,然后把 (headers, body, secret) 这个三元组固定成测试夹具。拿真实签名做重放,得到的是任何自己构造的夹具都给不了的基准事实。
在做到这一点之前,对自己的测试到底证明了什么,要心里有数。我们在测试文件底部的注释里记了一本账:哪些平台有 SDK 验证,哪些只是自洽,哪些根本没有正向测试。一份诚实、可以据此行动的清单,胜过一套你无法信任的全绿测试。
这种思路在同一天发现的 bug
同样的怀疑也适用于单元测试之外。在对生产环境端到端验证我们的 Resend 路径时(不是单元测试套件,而是真正部署的系统),我们向一个配置了签名密钥的端点发了三个请求:
| 请求 | 预期 | 实际 |
|---|---|---|
| 正确的签名 | 200 | 200 |
| 错误的签名 | 401 | 401 |
| 完全没有签名 | 401 | 200 |
一个没有签名的请求,顺利穿过了一个看起来受保护的端点。原因是策略上的漏洞,而不是加密方面的 bug。来源为 generic 的 App 按设计会跳过验证(“我只想要一个 URL”),而配置一个密钥并不会让这个 App 脱离 generic。这个密钥就待在那里,什么也不验证,而 API 在保存它时却返回了成功。
再多的自洽单元测试也发现不了这个问题,因为每个单元都是对的。修复办法是直接拒绝这种误导性的配置:现在给 generic App 设置签名密钥会返回 400,并告诉你应该改设哪个字段。
检查清单
- 对每个 SDK 附带签名函数的平台:做双向交叉验证(有效的能通过,被篡改的会失败)
- 对其余的平台:捕获一次真实的签名投递,并把它固定成测试夹具
- 维护一本看得见的账,记录哪些平台有哪种级别的证明
- 拿缺少签名的请求去测试已部署的系统,而不只是测试错误的签名。绕过漏洞藏在策略里,不在数学里