Webhook 接收端实战:原始请求验签、幂等收件箱与可靠处理
本文目录
Webhook 是外部系统主动调用你的 HTTP endpoint。可靠接收端不是“收到 JSON 就执行业务”:网络超时会诱发重复投递,提供方可能重放历史请求,代理或 JSON 中间件可能改变字节,业务处理又可能超过对方等待窗口。把它拆成四层:原始字节验签、协议级重放控制、持久化接收、可重试的幂等业务处理。
本文用 GitHub 的 X-Hub-Signature-256 说明 HMAC-SHA256。不同提供方的签名头、签名串、时间戳和 SDK 都不同;切换到 Stripe 或 Standard Webhooks 时必须按其协议与官方 SDK 验证,不能复用 GitHub 算法。
1. 签名针对原始字节,不是解析后的对象
发送方对请求体字节计算 HMAC。若服务端先 JSON parse,再 stringify,空白、转义、数字格式或键顺序可能改变,计算出的摘要就不同。应在任何会消费/改写 body 的中间件之前保留 Buffer,并限制请求体大小。校验失败返回 4xx;不要把未经验证的数据放入队列或触发副作用。
GitHub 示例:
const { createHmac, timingSafeEqual } = require('node:crypto');
function verifyGitHubSignature(rawBody, header, secret) {
if (!Buffer.isBuffer(rawBody) || typeof header !== 'string' || typeof secret !== 'string' || secret.length === 0) return false;
const match = /^sha256=([0-9a-f]{64})$/i.exec(header);
if (!match) return false;
const expected = createHmac('sha256', secret).update(rawBody).digest();
const supplied = Buffer.from(match[1], 'hex');
return supplied.length === expected.length && timingSafeEqual(expected, supplied);
}
module.exports = { verifyGitHubSignature };
timingSafeEqual 两边长度必须相同,因此先校验严格的 64 位十六进制摘要和 Buffer 长度。比较结果只是验签;密钥为空、头缺失或格式不对都必须失败关闭。密钥应来自 secret manager/受限环境变量,不能写入源码、数据库明文、错误响应或日志。
GitHub 文档提供的测试向量是 secret It's a Secret to Everybody、payload Hello, World!,对应摘要 757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17。把它放进自动化测试,并验证改一个 body 字节、摘要大小写/长度错误、缺少头和错误密钥均被拒绝。
2. 框架必须暴露 raw body
NestJS Express adapter 可在创建应用时启用 { rawBody: true },随后从 RawBodyRequest<Request>.rawBody 读取 Buffer。不要为了取 raw body 在路由里重复消费 stream,也不要同时启用会改变读取时机的自定义 body parser。确认真实入口、adapter、代理和测试环境全部保留同一字节;仅在单元测试中直接传 Buffer 不能证明线上中间件配置正确。
路由还要限制 Content-Type 与最大 body 大小。网关/Nginx、框架和应用限额应一致;拒绝压缩炸弹或意外超大 JSON。请求日志只记录 request ID、提供方、字节数、验签结果类别和处理耗时,不记录签名头、secret 或完整 payload。
3. 不要混淆认证、防重放与幂等
HMAC 证明请求体由持有共享密钥的一方生成且未被篡改,但协议细节决定重放保护能力。GitHub 的 X-GitHub-Delivery 可用于标识投递并去重;GitHub 文档指出手动重投时该 ID 保持不变。该头不在 HMAC 签名覆盖范围内,所以不能把它当作已认证字段,也不能把它当成带时间戳的防重放机制。
对带有已签名时间戳和事件 ID 的协议,应在验证签名后再检查允许的时钟偏差,并在唯一约束中原子登记事件 ID。只接受协议实际签名的字段。对没有签名时间戳的协议,采用提供方保证的稳定 delivery ID 做业务去重,并结合事件当前状态/版本约束;不要编造一个不受签名保护的时间戳校验。若攻击者可把同一有效 body 配上另一个未签名 ID 重放,ID 去重本身挡不住它,业务操作必须保持幂等,并按提供方能力使用撤销/状态查询或额外认证边界。
密钥用于证明请求来源;唯一键用于阻止重复处理;两者解决不同问题。相同 ID、不同 payload hash 是冲突/安全告警,不应静默覆盖已有事件。
4. Inbox:先可靠接住,再异步处理
Webhook endpoint 应在短时间内返回 2xx。GitHub 建议在 10 秒内响应并把较慢工作放入队列,而且失败的 webhook 不会自动重投;需要的历史投递应由运维使用 delivery 管理/重投能力处理。快速响应不表示业务成功,只表示系统已可靠接收。
典型收件箱表:
CREATE TABLE webhook_inbox (
provider text NOT NULL,
delivery_id text NOT NULL,
payload_sha256 bytea NOT NULL,
event_type text NOT NULL DEFAULT '',
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'processed', 'failed')),
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
attempts integer NOT NULL DEFAULT 0,
last_error_code text,
PRIMARY KEY (provider, delivery_id)
);
收到有效签名后,在一个数据库事务里插入 inbox 记录并创建 outbox/job 记录;只有事务提交成功才返回 2xx。冲突时读取旧 hash:相同 hash 视为重复投递,可返回 2xx;不同 hash 则写安全告警并进入人工检查,避免覆盖。具体保存原始文本还是 jsonb 取决于审计/隐私要求,必须有数据最小化、保留期限和删除策略。
避免“先写数据库再向队列发消息”的双写丢失:数据库提交后、队列发送前进程崩溃会留下永远无人处理的 pending 行。可以使用 transactional outbox,由 worker 扫描/投递;或让 worker 直接从带 lease 的 inbox 表领取工作。领取使用短事务和 FOR UPDATE SKIP LOCKED 等数据库能力,提交 lease 后再做慢操作,避免持锁调用外部服务。
5. Worker 的重试边界
- 用状态机和条件更新控制并发领取;租约应可过期恢复,worker 以 lease/version 条件提交结果,旧 worker 不能覆盖新租约。
- 区分可重试错误(网络超时、上游 5xx、临时限流)与永久错误(业务对象不存在、schema 不支持);指数退避加入随机抖动,并设最大次数与总时限。
- 超过上限进入 dead-letter/failed 队列,保留安全诊断、告警与受控人工重放入口;重放必须再次幂等,且不绕过当前授权。
- 为每个外部副作用生成稳定的幂等键,并传给下游 API;如果下游不支持幂等,应在本地事务/唯一约束中建立业务操作记录。不能假设网络超时代表对方没有执行。
- 数据库内状态变化与 inbox 完成标记尽可能处于同一事务;跨服务发送使用 outbox。Webhook 的顺序可能不可靠,事件含资源版本时拒绝旧版本覆盖新状态,必要时重新向权威 API 查询当前状态。
- 对 GitHub,及时 2xx 尤其重要,因为失败不代表会自动重试;本地队列也需要自己的告警、积压监控和补偿流程。
6. 密钥轮换、隔离与 SSRF
轮换应有短暂双密钥验证窗口:先部署可验证 current/next 两把密钥的代码,再更新提供方 secret,观察新旧签名与失败率,最后移除旧密钥。若协议或产品不支持双密钥窗口,则安排明确切换时刻和重试/回补步骤。日志只写密钥版本标签,不写密钥值。
公开 webhook URL 会被扫描。除 HMAC 外还要有 WAF/速率限制、严格路由、body 上限、异常隔离、依赖超时和最小权限。若 endpoint 接收外部提供的回调 URL 并主动访问它,就引入 SSRF:限制协议/端口,阻止 loopback、私网、链路本地与云 metadata 地址,处理 DNS rebinding/重定向,并在连接层执行 egress policy。只校验 URL 字符串或域名后缀不够。
7. 测试与上线清单
- 单元:官方向量、正确/错误密钥、body 单字节变化、空 body、缺失/格式错误签名、错误长度、异常类型输入。
- 集成:通过真实 Nest bootstrap 和代理配置发请求,确认 raw bytes 未被 JSON parser、字符集转换或换行归一化改变;超限 body 被拒绝。
- 并发:同一 delivery 并发到达只产生一条 inbox 和一次业务效果;相同 ID 不同 hash 告警;worker 崩溃后 lease 可恢复。
- 故障:数据库提交前后断网、队列不可用、下游超时/429/5xx、毒性事件、密钥轮换期间旧新签名都能安全收敛。
- 运营:监控验签失败率、接收延迟、pending 年龄、重试/死信数、重复率和补偿任务;指标标签低基数且不含用户/事件秘密。
官方资料
- GitHub:验证 Webhook 投递:HMAC-SHA256、raw payload、签名头与测试向量。
- GitHub:Webhook 最佳实践:快速 ACK、异步处理、失败不会自动重投与安全建议。
- NestJS Raw Body:保留 Express/Fastify 请求原始 Buffer 的配置方式。
- Node.js
crypto.timingSafeEqual:固定长度的常数时间比较 API。 - OWASP Webhook Security Cheat Sheet:签名、重放、速率限制、SSRF 与轮换的通用检查项。
协议快照:按 2026-10-14 核对上述官方资料。第三方的 header、canonicalization、时间戳容差和重试策略会变化;实施时以当前提供方文档和 SDK 为准,并记录实际投递 ID、签名覆盖范围和失败补偿策略。
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。