site logo

Marico's space

为什么 Stripe Webhook 签名验证在重放时会失败(以及如何修复)

前端技术 2026-10-01 20:55:55 5

最近折腾 Stripe Webhook 的重放机制,踩了一个不大不小的坑——签名验证超时。团队接入了死信队列(DLQ),处理支付事件的容错能力是有了,但每次重放积压的事件,SDK 就会抛出一个冷冰冰的错误:

Error: Webhook signature verification failed: Timestamp outside the tolerance zone (1800s > 300s)

查了一圈,发现这是个挺经典的问题,但网上能找到的"解决方案"大多会引入更严重的安全漏洞。这篇把根因说清楚,再给一个靠谱的架构思路。

1. 300 秒签名窗口是怎么工作的

Stripe 发送 Webhook 时,会在请求头里塞一个 Stripe-Signature,格式大概是:

Stripe-Signature: t=1711234567,v1=5257a869e7eceeda32a1a243a0b7...

这里的 v1 是用你的 Webhook 密钥对时间戳和原始请求体做 HMAC-SHA256 运算的结果:

v1 = HMAC-SHA256(webhook_secret, `${t}.${rawBody}`);

调用 stripe.webhooks.constructEvent(body, sig, secret) 时,SDK 做了两件事:

  1. 密码学校验:验证 v1 是否匹配
  2. 重放防护:检查 Math.abs(Date.now() / 1000 - t) <= 300,也就是必须在 5 分钟窗口内

问题来了:重放攻击和合法的 DLQ 重放在数学上长得一模一样。只要你队列里积压超过 5 分钟,标准验证就会无情拒绝。

2. 那些常见"修复"为什么行不通

方案一:收到重放请求直接跳过验证

有人会加个自定义头 x-replayed: true,有这个头就跳过 constructEvent()。

这个做法等于把大门敞开——任何知道 Webhook 地址的人都可以伪造 customer.subscription.created 事件,配合 x-replayed: true 直接绕过验证,零成本薅你的服务。

方案二:把 SDK 的容差窗口调大

// 危险操作:开了 3 天的重放漏洞
stripe.webhooks.constructEvent(body, sig, secret, 86400 * 3);

改成 72 小时确实能让重放通过,但代价是整个支付系统的重放保护全部失效。攻击者可以截获一个支付成功的请求,三天内随时重放,每次都能通过验证。

3. 正确思路:入口验证 + 出口重签

核心原则是把入口处的验证和内部转发解耦:

[Stripe] │ ▼ (原始 Stripe 签名,时间戳 t=now)
[入口代理] ── 在 300s 窗口内完成签名验证 │ ├─► 立即返回 200 OK 给 Stripe(避免触发重试) │ ▼ (持久化到本地 SQLite / WAL 存储)
[下游调度器] │ ▼ (用新时间戳 t=now + 新 HMAC 重新签名)
[你的业务服务] ── constructEvent() 顺利通过!

具体流程:

  1. 入口处(<10ms):代理收到 Stripe 请求,用 Webhook 密钥在 300s 容差窗口内完成验证。假请求直接拒绝,真请求立即 ACK。
  2. 持久化存储:把原始请求体和元数据写入本地 SQLite(WAL 模式),不怕丢。
  3. 转发 / 重放时:无论是 5ms 后还是 3 天后从 DLQ 重放,代理都重新生成时间戳 t = Math.floor(Date.now() / 1000) 和对应的 v1 签名。

重签函数长这样:

const crypto = require('crypto'); function signStripeHeaders(rawBody, headers, secret) { const freshTimestamp = Math.floor(Date.now() / 1000); const freshSignature = crypto .createHmac('sha256', secret) .update(`${freshTimestamp}.${rawBody}`) .digest('hex'); return { ...headers, 'stripe-signature': `t=${freshTimestamp},v1=${freshSignature}` };
}

4. 防止乱序覆盖

重放还有个隐患:比如 2 天前的 customer.subscription.deleted 可能晚于今天的 customer.subscription.created 到达,如果直接覆盖状态就乱了。

入口代理可以附加来源元数据头:

x-hookarmor-is-replay: true
x-hookarmor-original-timestamp: 1711234567
x-hookarmor-delivery-id: del_8f92b1c
x-hookarmor-original-stripe-signature: t=1711234567,v1=...

下游服务可以对比 x-hookarmor-original-timestamp 和数据库里这条记录的 updated_at,避免用旧数据覆盖新数据。

5. 业务代码零改动

由于重放出去的请求带着新鲜的时间戳和有效签名,你的业务 handler 完全是标准写法:

// Next.js / Express 路由里:
export async function POST(req) { const body = await req.text(); const sig = req.headers.get('stripe-signature'); // 首次投递和几天后的重放都能通过: const event = stripe.webhooks.constructEvent( body, sig, process.env.STRIPE_WEBHOOK_SECRET ); await handleBilling(event); return Response.json({ received: true });
}

不需要改一行业务代码,签名验证逻辑全在代理层处理。

开源方案:HookArmor

如果你想直接用,不想自己造轮子,我把我们这套架构做成了一个开源项目 HookArmor(MIT 协议,单二进制 / Docker 部署):

  • 上游瞬时 200 OK + 本地 SQLite(WAL)缓冲
  • 自动指数退避重试
  • 内置 DLQ,带 Web UI,一键手动重放
  • 透明自动重新签名 Stripe 请求,附加来源头
  • 零外部数据库依赖

用 Docker Compose 跑起来:

version: '3.8'
services: hookarmor: image: node:20-alpine working_dir: /app command: sh -c "npm install -g hookarmor && hookarmor start --port 8080 --target http://my-app:3000/webhooks" ports: - "8080:8080" volumes: - ./data:/app/data environment: - HOOKARMOR_TARGET_URL=http://my-app:3000/webhooks - HOOKARMOR_UI_PASSWORD=admin

GitHub 上搜 hookarmor 或者 npm 上直接 npm install -g hookarmor 就能用。

你们的团队现在怎么处理 Webhook 重放和签名过期的问题?评论区聊聊,还有什么其他架构思路。