正确验证 webhook 签名并处理重试

Charles Archibong, 联合创始人
对原始请求字节验证 V2 签名,拒绝重放窗口之外的时间戳,在执行任何工作之前,在唯一约束下记录事件 ID,并且只有在事件被持久存储后才返回 2xx。

要点
- 对原始请求字节计算 HMAC,切勿对重新序列化的 JSON 进行计算。
- 签名时间戳加上重放窗口可以防止旧的、有效签名的交付被重放。
- 按事件 ID 去重,而不是按交付 ID 去重:手动重新发送是同一事件的新交付。
- 只有在事件被持久化存储后才返回 2xx,然后再执行缓慢的工作。
Webhook接收器有三个任务,并且按固定顺序执行。证明请求来自 Toden 通过检查接收到的确切字节的签名来验证。通过将签名时间戳与重放窗口进行比较来证明它是最新的。然后,通过在唯一约束下记录事件 ID 来证明您尚未对其进行过任何操作。只有在完成这三项操作后,您才能返回。2xx只有在那之后,你才能真正开始工作。
顺序出错,每次失败都不会造成影响。解析前未验证,伪造的请求体就会到达业务逻辑。忽略时间戳,捕获的请求可能会无限期地重放。使用错误的键进行去重,会导致客户重复注册。本文将逐步介绍每个步骤。 Toden webhook (在新标签页中打开),带有一个可以自定义的接收器。
每次送货都会附带什么
每次送货都是 HTTP POST与一个 JSON 正文和一组标头。这些标头对于验证至关重要,详情请参阅webhook 身份验证文档 (在新标签页中打开):
标题 | 例子 | 该如何处理它 |
|---|---|---|
|
| 解析时间戳和每个 |
|
| 端点的重放窗口(以秒为单位)。未签名,因此切勿使其扩大您的检查范围。 |
|
| 用于去重的稳定密钥。 |
|
| 发货记录。仅用于追踪,不可用于去重。 |
|
| 用于路由。已签名的主体仍然具有权威性。 |
|
| 仅对主体进行传统兼容性签名。 |
如果你建造时与旧式建筑风格背道而驰X-Myaza-Signature虽然标头仍然有效,但它会在不添加时间戳的情况下对请求体进行签名,这意味着它无法判断请求是否是最新的。请改用 V2 标头。
步骤 1:验证原始字节的签名
签名是小写十六进制 HMAC - SHA256 使用您的端点密钥作为密钥,通过字符串<t>.<rawBody>:标头中的时间戳,一个句号,然后是请求正文,原样返回。
“原样接收”是导致集成失败的原因。大多数 Web 框架都会解析这种做法。 JSON 在你的处理程序运行之前。如果你验证JSON.stringify(req.body)您正在对重新序列化进行签名:空格、密钥顺序和数字格式都可能与签名时的不同,并且验证会间歇性地失败,看起来像是一个错误的密钥。请配置路由以向您提供原始缓冲区。
使用恒定时间函数进行比较,并与每个函数进行比较。v1价值,而不仅仅是第一个。在秘密轮换期间 Toden 同时使用当前密钥和待退役密钥进行签名,因此请求头包含两个候选密钥。如果其中一个匹配,则接受请求;只有在控制面板中旧密钥的宽限期结束后,才将其移除。
步骤二:拒绝过期的时间戳
有效的签名只能证明是谁发送的请求,而不能证明发送时间。任何人(例如从代理日志中)截获了发送记录,都可以重新发送,并且仍然有效。签名时间戳弥补了这一缺陷:因为t如果该字符串位于签名字符串内部,攻击者无法在不破坏签名的情况下对其进行更改。
拒绝任何时间戳缺失、无法解析、早于重放窗口或日期过于遥远的投递。X-Myaza-Replay-Window它会告诉你端点配置的窗口大小,但这个头部信息位于签名字符串之外,因此任何重放请求的人都可以修改它。你可以用它来加强检查,但切勿扩大检查范围:将其限制在你为端点配置的窗口大小之内。确保你的服务器时钟已同步;时钟漂移会导致此检查变成随机拒绝。
返回401如果签名缺失、过期或无效,请勿退回。200停止重试:这告诉发送方事件已被接受。
步骤 3:对事件进行去重,而不是对交付进行去重。
Webhook 至少会发送一次。如果接收者超时、您响应后网络出现故障,或者队友在控制面板中点击“重新发送”,都会导致同一事件再次出现在您面前。
问题在于哪个标识符表示“同一事物”。有两个候选标识符,它们的行为方式不同:
事件ID (
X-Myaza-Event-Id, 和id(在信封中)标识业务事件。它在自动重试和手动重新发送时保持不变。配送ID (
X-Myaza-Delivery)标识一条发货记录。手动重新发送会创建另一条发货记录,因此会获得一个新的发货 ID。
以事件 ID 为键进行去重。Webhook测试指南中 (在新标签页中打开)明确指出这一点,并建议插入一个在发生冲突时不执行任何操作的语句:
insert into received_myaza_events (event_id, event_type, payload)
values (:id, :type, :payload)
on conflict (event_id) do nothing;有一件事需要注意。verification.status_updated平铺包装寄送,不进行包装。{ id, event, createdAt, data }包裹其他事件使用的,因此主体没有id字段。从事件头中获取事件 ID 适用于所有事件类型,这是一条更简单的规则。
第四步:快速确认,然后开展工作
返回2xx事件必须先被永久存储,而不是在此之前。然后在请求之外进行处理:更新客户记录、通知申请人、创建内部工单。在请求内部缓慢处理会导致超时,超时将被视为失败并重试。
未知事件类型应被存储或忽略,但仍需确认。新事件会随时间推移添加到目录中;拒绝一个无法识别的事件会导致重试循环,而这原本并非您打算处理的事件。
一个能完成所有四项任务的接收器
改编自文档中的 Node.js 示例,限制了回放窗口,并添加了去重插入功能。saveEventOnce和queue充当自己的仓库管理员和工作跑腿员。
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post("/webhooks/myaza", express.raw({ type: "application/json" }), async (req, res) => {
const parts = String(req.header("X-Myaza-Signature-V2") || "").split(",");
const timestamp = parts.find((p) => p.startsWith("t="))?.slice(2);
const candidates = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
const MAX_WINDOW = 300; // the replay window configured for this endpoint
const windowSeconds = Math.min(Number(req.header("X-Myaza-Replay-Window")) || MAX_WINDOW, MAX_WINDOW);
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > windowSeconds) {
return res.status(401).send("Expired signature");
}
const expected = crypto
.createHmac("sha256", process.env.MYAZA_WEBHOOK_SECRET)
.update(`${timestamp}.`)
.update(req.body)
.digest("hex");
const valid = candidates.some((c) =>
c.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(c), Buffer.from(expected))
);
if (!valid) return res.status(401).send("Invalid signature");
const eventId = req.header("X-Myaza-Event-Id");
const payload = JSON.parse(req.body.toString("utf8"));
const isNew = await saveEventOnce(eventId, req.header("X-Myaza-Event"), payload);
res.status(200).send("OK");
if (isNew) queue.add("myaza-event", { eventId });
});由于事件是在响应之前存储的,因此响应和事件之间可能会发生崩溃。queue.add不会有任何损失:让一个小型清扫器收集那些从未被处理的已存储事件。在轮换过程中,保留两个秘密并分别计算它们的期望值;如果任何候选值与任何一个秘密匹配,则检查通过。
重试机制如何运作,以及这对你意味着什么
未收到的货物2xx如果超时,则会使用退避机制重试:
试图 | 上次尝试后的延迟 |
|---|---|
1 | 30秒 |
2 | 5分钟 |
3 | 30分钟 |
4 | 2小时 |
5 | 24小时 |
五次投递失败后,投递状态被标记为“已投递失败”。FAILED并且可以从控制面板手动重试。这将导致两个后果。
首先,如果您的服务器出现一两天的故障,只要在最后一次尝试之前修复故障,并在恢复后以幂等方式处理大量重试,就可以在不丢失数据的情况下恢复。对重复的投递失败发出警报,这样您就可以从监控系统中而不是从客户那里得知故障情况。
其次,交付会独立重试,因此可能会出现乱序到达的情况。假设您的工作流程将一项验证请求路由到审核,合规官在一小时后批准了该请求:两个verification.status_updated事件,每个事件都有自己的重试计划。如果第一次投递失败,重试成功,您可能会收到approved前in_review有据可查的规则是,应将最新的changedAt将其作为当前状态保存,并忽略比当前状态更旧的更新。在关键时刻,从当前状态读取数据。 API 行动之前。
生产前先进行测试
创建一个沙盒端点,并在该端点页面上使用模拟器,通过实际的传输路径发送正确签名的示例事件。然后故意重现各种失败情况:
发送模拟事件并确认接收方验证了 V2 签名并返回
2xx迅速地。更改捕获到的物体的一个字节并确认
401。窗口期过后重放捕获的请求并确认
401。从控制面板重新发送一次订单,并确认副作用是否发生一次。
返回非
2xx暂时进行尝试,并确认已安排重试。轮换使用密钥,并在宽限期内确认两个签名均被接受。
一个接球手如果能传球六次,那么在出现问题的那一天,他就能表现得正确,而这才是真正重要的。
参考来源



