Webhook 详解:签名负载与重试机制
你已经构建了一个 API,现在需要在事件发生时通知其他服务。你设置了一个 webhook 端点,但很快发现任何人都可以发送伪造的负载,而且当你的服务器宕机时,事件会永远丢失。这是一个常见的陷阱:webhook 概念简单,但为了安全和可靠性需要精心设计。
在本指南中,我们将介绍 webhook 的基本要素:如何对负载进行签名以防止篡改,如何在接收端验证签名,以及如何实现带指数退避的重试逻辑以处理失败。我们还会涵盖幂等性以避免重复处理。最终,你将拥有一个可以信赖的健壮 webhook 系统。
什么是 Webhook?
Webhook 是用户定义的 HTTP 回调。当源系统中发生事件(例如,支付处理完成)时,它会向你指定的 URL 发送一个 HTTP POST 请求,其中包含事件详情的负载。这允许服务之间进行实时通信,而无需轮询。
然而,webhook 引入了两个挑战:
- 安全性:接收方如何知道请求确实来自发送方?
- 可靠性:如果接收方暂时不可用怎么办?我们如何确保事件最终被送达?
使用签名负载保护 Webhook
为了防止伪造请求,发送方应使用共享密钥对负载进行签名。接收方随后可以验证签名以认证发送方。最常见的方法是使用 SHA-256 的 HMAC(基于哈希的消息认证码)。
HMAC 签名的工作原理
- 发送方和接收方共享一个密钥(理想情况下是一个长随机字符串)。
- 对于每个 webhook,发送方使用密钥计算请求体的 HMAC,并将其包含在头部(例如
X-Signature)中。 - 接收方对收到的请求体计算相同的 HMAC,并与头部值进行比较。如果匹配,则请求是真实的。
关键是要对原始请求体进行签名,而不是解析后的版本,以避免因 JSON 序列化差异而导致的不一致。
示例:在 Node.js 中签名负载
const crypto = require('crypto');
function signPayload(payload, secret) {
return crypto.createHmac('sha256', secret).update(payload).digest('hex');
}
// 发送方:
const payload = JSON.stringify({ event: 'payment.success', amount: 100 });
const signature = signPayload(payload, 'your-secret-key');
// 将签名包含在头部:X-Signature: signature
在接收方验证签名
在接收端,你必须在处理负载之前验证签名。使用恒定时间比较以防止时序攻击。
const crypto = require('crypto');
function verifySignature(payload, signature, secret) {
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
// 在你的 webhook 端点中:
app.post('/webhook', (req, res) => {
const signature = req.headers['x-signature'];
const payload = req.rawBody; // 原始请求体作为字符串
if (!verifySignature(payload, signature, 'your-secret-key')) {
return res.status(401).send('Invalid signature');
}
// 处理事件
res.status(200).send('OK');
});
注意:始终使用 HTTPS 以防止中间人攻击。此外,考虑在签名负载中包含时间戳以防止重放攻击。
使用指数退避实现重试
即使接收方可靠,网络问题或临时中断也可能导致 webhook 传递失败。健壮的发送方应重试失败的传递。然而,简单的重试可能会使接收方不堪重负或导致重复处理。解决方案:带抖动的指数退避和最大重试限制。
重试策略
- 失败时重试:当接收方返回非 2xx 状态码或超时时重试。
- 指数退避:以指数方式增加重试之间的延迟(例如,1秒、2秒、4秒、8秒...)。
- 抖动:在延迟中添加随机变化以避免惊群问题。
- 最大尝试次数:设置一个限制(例如,5 次重试),然后将 webhook 标记为失败(可能通知管理员)。
示例:Node.js 中的重试逻辑
async function deliverWebhook(url, payload, secret, maxRetries = 5) {
const signature = signPayload(payload, secret);
let attempt = 0;
while (attempt < maxRetries) {
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Signature': signature },
body: payload,
});
if (response.ok) return;
throw new Error(`HTTP ${response.status}`);
} catch (error) {
attempt++;
if (attempt >= maxRetries) throw error;
const delay = Math.min(1000 * 2 ** attempt + Math.random() * 1000, 30000);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
此函数最多重试 5 次,采用指数退避和抖动,将延迟上限设为 30 秒。根据你的需求调整参数。
确保幂等性
重试可能导致重复传递,因此你的 webhook 处理程序必须是幂等的。在负载中包含唯一的事件 ID,并让接收方跟踪已处理的 ID 以避免重复处理。
// 在接收方:
const processedEvents = new Set();
app.post('/webhook', (req, res) => {
const { id } = req.body;
if (processedEvents.has(id)) {
return res.status(200).send('Already processed');
}
// 处理事件
processedEvents.add(id);
res.status(200).send('OK');
});
在生产环境中,使用持久化存储(例如 Redis 或数据库)来跟踪重启后的已处理 ID。
比较:签名与未签名的 Webhook
| 方面 | 未签名 | 签名(HMAC) |
|---|---|---|
| 安全性 | 任何人都可以伪造请求 | 只有密钥持有者才能创建有效签名 |
| 完整性 | 负载可能被篡改 | 篡改会使签名无效 |
| 复杂性 | 低 | 中等(密钥管理、验证) |
| 使用场景 | 内部、可信网络 | 任何公共 webhook |
常见问题
什么是 webhook 签名?
Webhook 签名是使用共享密钥创建的负载的加密哈希(通常是 HMAC)。它允许接收方验证请求来自预期的发送方,并且负载未被更改。
我应该重试失败的 webhook 多少次?
没有固定的次数,但常见做法是使用指数退避重试 3 到 5 次。之后,将传递标记为失败并提醒你的团队。考虑事件的关键性:支付通知可能比非关键更新需要更多重试。
我可以不使用 HTTPS 使用 webhook 吗?
在生产环境中,你绝对不应该通过纯 HTTP 使用 webhook。HTTPS 在传输过程中加密负载,防止窃听和中间人攻击。即使有签名,HTTP 也会暴露敏感数据。
准备好测试你的 webhook 负载了吗?使用我们的 JSON 格式化工具 在发送或调试之前美化并验证 JSON 负载。