工程2026年7月30日7 分钟阅读

你的 Webhook 签名测试什么也证明不了

如果你的验签测试用验证函数检查的同一个公式来生成签名,两者可能一起错,测试却一直是绿的。我们曾有 70 个这样的测试。拿平台的 SDK 交叉验证。

通过的签名测试实际覆盖了什么你的测试数据 → 你的验证器两者基于同一个假设构建真实请求解析过的请求体 · 代理改写 · 重试时间戳 · 轮换的密钥测试从不触及在你的代码运行前就消费掉数据流的那一层唯一能证明问题的测试,是让一次真实签名的投递走完真实的整套技术栈
测试和被测代码通常共享同一个错误的假设。

下面是一个 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 个平台。每一个都通过了。然后我们问了唯一重要的问题:由平台自己的代码生成的签名,能通过我们的验证函数吗?

HMAC 算的是字节,不是含义原始字节{"id":"evt_1","n":2}req.text()hmac匹配解析后重新序列化{ "n": 2, "id": "evt_1" }req.json()hmac不匹配数据相同数据相同。键的顺序不同,空白不同,而请求体在你的日志里看起来完全正常。
测试夹具复现不了的失败:你的框架已经消费了请求流,所以你做哈希的字节,并不是被签名的那些字节。

拿平台的 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 路径时(不是单元测试套件,而是真正部署的系统),我们向一个配置了签名密钥的端点发了三个请求:

请求预期实际
正确的签名200200
错误的签名401401
完全没有签名401200

一个没有签名的请求,顺利穿过了一个看起来受保护的端点。原因是策略上的漏洞,而不是加密方面的 bug。来源为 generic 的 App 按设计会跳过验证(“我只想要一个 URL”),而配置一个密钥并不会让这个 App 脱离 generic。这个密钥就待在那里,什么也不验证,而 API 在保存它时却返回了成功。

再多的自洽单元测试也发现不了这个问题,因为每个单元都是对的。修复办法是直接拒绝这种误导性的配置:现在给 generic App 设置签名密钥会返回 400,并告诉你应该改设哪个字段。

检查清单

  1. 对每个 SDK 附带签名函数的平台:做双向交叉验证(有效的能通过,被篡改的会失败)
  2. 对其余的平台:捕获一次真实的签名投递,并把它固定成测试夹具
  3. 维护一本看得见的账,记录哪些平台有哪种级别的证明
  4. 拿缺少签名的请求去测试已部署的系统,而不只是测试错误的签名。绕过漏洞藏在策略里,不在数学里
全部文章2026年7月30日 · 7 分钟

别再丢 Webhook 了。

只改一个 URL。获得重试、事件日志和一键重放。也可以先亲眼看看:注册后跑一次 30 秒的故障演练,看一个事件如何挺过你的服务器宕机。不需要服务器。