Webhooks 详解:签名载荷与重试机制
你刚刚集成了一个第三方支付提供商,它承诺在支付成功时通过 webhook 通知你的应用。但你怎么知道请求确实来自他们,而不是攻击者?如果 webhook 触发时你的服务器短暂宕机,会发生什么?如果没有适当的保护措施,你可能会处理虚假事件或丢失关键更新。本文介绍如何使用签名载荷保护 webhook,并实现健壮的重试机制。
什么是 Webhook?
Webhook 是一种用户定义的 HTTP 回调。当源系统发生事件(例如支付完成)时,它会向你提供的 URL 发送一个 HTTP POST 请求。与轮询(你反复请求更新)不同,webhook 近乎实时地将数据推送给您。它们广泛用于事件通知,例如 CI/CD 触发、聊天消息或支付状态变更。
为什么用签名保护 Webhook?
Webhook 端点可公开访问,因此任何人都可以向它们发送 POST 请求。如果没有验证,攻击者可以伪造事件,导致未经授权的操作,例如将订单标记为已支付。使用共享密钥对载荷进行签名,可以让你验证真实性和完整性。
签名载荷如何工作
发送方和接收方共享一个密钥。对于每个 webhook,发送方基于请求体计算加密签名(通常是 HMAC),并将其包含在头部中。接收方使用相同的密钥重新计算签名并进行比较。如果匹配,则请求是真实的。
选择合适的算法
HMAC-SHA256 是事实上的标准。它快速、安全,并且在所有主流语言中可用。避免使用被认为较弱的 MD5 或 SHA1。
逐步:验证签名载荷
- 从头部提取签名(例如
X-Signature)。 - 获取原始请求体作为字符串或字节数组。不要先解析它;任何转换都会改变签名。
- 计算 HMAC,使用你的密钥和原始请求体。
- 比较计算出的签名与接收到的签名,使用恒定时间比较以防止时序攻击。
以下是使用 Express 的 Node.js 示例:
const crypto = require('crypto');
const express = require('express');
const app = express();
// Use raw body parser for webhook route
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature'];
const secret = process.env.WEBHOOK_SECRET;
const hmac = crypto.createHmac('sha256', secret);
const digest = hmac.update(req.body).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest))) {
return res.status(401).send('Invalid signature');
}
// Process the event
const event = JSON.parse(req.body);
// ... handle event
res.status(200).send('OK');
});
常见陷阱:在验证之前使用 JSON 正文解析器。Express 的 express.json() 会解析正文,因此原始字节丢失。始终为 webhook 路由使用 express.raw() 或单独捕获原始正文。
设计重试策略
即使有签名,webhook 也可能失败:你的服务器可能宕机,或者请求可能超时。可靠的发送方会重试失败的传递。作为接收方,你也应该优雅地处理重试。
发送方重试
如果你正在构建发送方,请实现带抖动的指数退避。例如,在 1 分钟后重试,然后 5 分钟,然后 30 分钟,直到最大尝试次数(例如 5 次)。在载荷中包含唯一的事件 ID,以便接收方可以去重。
接收方幂等性
由于重试可能导致重复传递,你的端点必须是幂等的。使用事件 ID 跟踪已处理的事件。将其存储在具有唯一约束的数据库中,或使用带 TTL 的 Redis 等缓存。如果事件 ID 已见过,立即返回 200 OK 而不重新处理。
使用数据库唯一索引的示例:
async function handleWebhook(event) {
try {
await db.query('INSERT INTO processed_events (event_id) VALUES ($1)', [event.id]);
} catch (err) {
if (err.code === '23505') { // unique violation
return; // already processed
}
throw err;
}
// process event
}
Webhook 端点的最佳实践
- 快速响应:在几秒内返回 2xx 状态。将繁重的处理卸载到后台任务。
- 验证时间戳:在签名中包含时间戳以防止重放攻击。拒绝超过几分钟的请求。
- 使用 HTTPS:始终使用 TLS 保护传输中的载荷。
- 记录一切:记录传入的 webhook,包括头部和正文,以便调试。
- 监控失败:为重复失败或签名不匹配设置警报。
比较:签名与未签名 Webhook
| 方面 | 签名 Webhook | 未签名 Webhook |
|---|---|---|
| 安全性 | 高 – 验证发送方和完整性 | 低 – 任何人都可以伪造请求 |
| 实现复杂度 | 中等 – 需要 HMAC 和密钥管理 | 低 – 只需接受 POST |
| 使用场景 | 支付、敏感数据 | 非关键通知 |
常见问题
如何在 Python 中验证 webhook 签名?
使用 hmac 模块和 SHA256。使用 hmac.compare_digest 将计算出的摘要与头部进行比较,以实现恒定时间比较。
如果我的 webhook 端点收到重复事件,我该怎么办?
对照已处理 ID 的存储检查事件 ID。如果是重复的,返回 200 OK 并跳过处理。这确保了幂等性。
webhook 发送方应执行多少次重试?
没有固定次数,但通常使用指数退避进行 3–5 次重试(例如 1 分钟、5 分钟、30 分钟)。之后,将传递标记为失败并发出警报。
准备好测试你的 webhook 端点了吗?在开发过程中,使用我们的 JSON Formatter 来美化打印和验证载荷。