Webhook 详解:签名负载与重试机制
为什么 Webhook 很棘手
Webhook 的想法很简单:与其轮询 API 获取变更,不如让提供商通过 HTTP POST 将事件推送到你的端点。但在实践中,Webhook 引入了一系列让许多开发者头疼的挑战:验证请求确实来自提供商、处理重复投递以及应对网络故障。如果你忽视这些,就可能面临安全漏洞、数据损坏和用户不满。
本文深入探讨生产级 Webhook 最关键的两个方面:签名负载和重试。我们将介绍如何验证签名、如何实现带指数退避的重试,以及如何使处理过程幂等。
签名负载:证明真实性
任何人都可以向你的端点发送 HTTP POST。为了确保请求来自你的提供商,提供商用密钥对负载进行签名,并将签名放在请求头中发送。你的任务是重新计算签名并进行比较。
大多数提供商使用 HMAC-SHA256。签名通常基于原始请求体(有时包含时间戳)计算,并以十六进制编码。例如,Stripe 使用类似 Stripe-Signature 的请求头,其中包含时间戳和签名。
HMAC 如何工作
HMAC(基于哈希的消息认证码)将密钥与哈希函数结合。给定相同的密钥和消息,输出是确定性的。没有密钥,攻击者无法伪造有效的签名。
以下是 Node.js 中的一个简单示例:
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)
);
}
在 Python 中,同样简单:
import hmac
import hashlib
def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
签名验证的最佳实践
- 使用恒定时间比较以防止时序攻击。
crypto.timingSafeEqual和hmac.compare_digest都是安全的。 - 验证原始请求体,而不是解析后的 JSON 对象。解析并重新序列化可能会改变空白字符并破坏签名。
- 在签名数据中包含时间戳以防止重放攻击。拒绝超过几分钟的请求。
- 安全存储密钥——使用环境变量或密钥管理器,切勿硬编码。
处理时间戳和重放攻击
许多提供商在签名头中包含时间戳。例如,头可能是 t=1614556800,v1=abc123...。你应该:
- 提取时间戳和签名。
- 检查时间戳是否在可接受的窗口内(例如 5 分钟)。
- 使用时间戳和负载重新计算签名。
- 安全地比较签名。
这可以防止攻击者重放旧的合法请求。
重试:优雅地处理失败
Webhook 通过公共互联网投递,因此失败不可避免。你的端点可能宕机、缓慢或返回 500 错误。健壮的提供商会重试失败的投递,但如果你是提供商,也需要在自己的端处理重试。
带抖动的指数退避
重试时,避免立即重试对端点造成冲击。使用指数退避:每次失败后等待更长时间。添加抖动(随机性)以避免当许多 Webhook 同时失败时出现惊群问题。
典型的重试计划可能如下所示:
| 尝试次数 | 延迟(大约) |
|---|---|
| 1 | 立即 |
| 2 | 30 秒 |
| 3 | 5 分钟 |
| 4 | 30 分钟 |
| 5 | 2 小时 |
在达到最大尝试次数后,将投递标记为失败并发出警报。
幂等性:安全重试的关键
由于重试可能导致重复投递,你的 Webhook 处理器必须是幂等的。这意味着多次处理同一事件应与处理一次具有相同的效果。
如何实现幂等性:
- 使用负载中提供的唯一事件 ID(例如
id字段)。将已处理的 ID 存储在具有唯一约束的数据库中。 - 将操作设计为幂等的——例如,使用 upsert 而不是 insert,或者在创建资源之前检查它是否已存在。
- 快速返回 2xx,如果工作繁重则异步处理。这减少了触发重试的超时机会。
在提供商端实现重试
如果你正在构建 Webhook 系统,你需要一个可靠的投递机制。以下是一个简化的方法:
- 当事件发生时,将带有负载和目标 URL 的投递作业加入队列。
- 工作进程获取作业并发送 HTTP POST,设置超时(例如 10 秒)。
- 如果响应状态不是 2xx,则使用指数退避安排重试。
- 跟踪尝试次数,并在达到最大值(例如 5 次)后放弃。
- 记录失败并提供用户手动重放的方式。
使用持久化队列(例如 Redis、RabbitMQ 或数据库表),以便重试在重启后仍然存在。
整合所有内容:Webhook 接收器检查清单
构建 Webhook 接收器时,请遵循以下检查清单:
- 验证签名,使用提供商的密钥和恒定时间比较。
- 检查时间戳以防止重放攻击。
- 快速响应 2xx——在进行繁重处理之前确认收到。
- 如有必要,异步处理,使用队列。
- 通过跟踪事件 ID 确保幂等性。
- 记录所有内容以便调试和审计。
- 监控失败并为重复重试设置警报。
常见问题
什么是签名 Webhook 负载?
签名负载包含使用共享密钥生成的加密签名(通常是 HMAC-SHA256)。接收方重新计算签名以验证请求来自预期的发送方且未被篡改。
为什么 Webhook 需要重试?
网络问题、服务器停机或临时错误可能导致 Webhook 投递失败。带指数退避的重试确保事件最终被投递,同时不会压垮接收方。
如何防止重复处理 Webhook?
使用幂等性:在负载中包含唯一事件 ID,存储已处理的 ID,并将操作设计为幂等的(例如 upsert)。这样,即使 Webhook 被多次投递,效果也是相同的。
如果你需要检查或调试 Webhook 负载,JSON Formatter 可以帮助你快速美化打印和验证 JSON 数据。