Stripe Webhook 安全性
本技能执行 vibe-preflight 扫描器的 Stripe 模块(位于本文件旁边的 scripts/preflight.mjs,需要 Node 20+,无依赖,无需网络)。它会检测 Next.js 路由处理器、pages/api、Express 和 Supabase Edge Functions 中的 webhook 处理器,并检查两件事。页面其余部分是需要你通过阅读处理器代码进行的人工审查,因为模式扫描无法完全证明安全性。
1. 运行扫描
在项目根目录执行:
node ${CLAUDE_SKILL_DIR}/scripts/preflight.mjs . --only stripe --fail-on warning --format json
Claude Code 会自动填充 ${CLAUDE_SKILL_DIR}。其他代理请使用此技能的文件夹路径,例如 .agents/skills/stripe-webhook-security 或 .claude/skills/stripe-webhook-security。
| 规则 | 级别 | 含义 |
|---|---|---|
stripe-webhook-unverified | 错误 | 处理器处理了 Stripe 事件,但从未调用 constructEvent / constructEventAsync。任何知道该 URL 的人都可以 POST 伪造的 checkout.session.completed 事件。 |
stripe-webhook-parsed-body | 警告 | 签名已校验,但校验的是已经解析过的请求体。Stripe 对原始字节进行签名,因此真实事件会以 400 错误失败,导致有人最终删除签名校验。 |
--only stripe 会通过 Stripe 事件名称、stripe-signature 标头的读取或与 Stripe 相关的 webhook 路径来查找处理器。如果报告为 0 errors 但用户确认存在 webhook,请自行找到处理器并按以下指南进行审查。
2. 修复签名验证
路由处理器(Next.js App Router、Remix、Hono 及其他 fetch 风格的处理器):
export async function POST(request: Request) {
const body = await request.text(); // 使用原始请求体,切勿使用 request.json()
const signature = request.headers.get("stripe-signature");
if (!signature) return new Response("缺少签名", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("签名无效", { status: 400 });
}
// 处理事件
return Response.json({ received: true });
}
Express:在 app.use(express.json()) 之前 使用 express.raw({ type: "application/json" }) 注册 webhook 路由,然后将 req.body(此时为 Buffer)传递给 constructEvent。
Next.js pages/api:设置 export const config = { api: { bodyParser: false } };,并在验证前将原始请求读取为 Buffer。
Supabase Edge Functions 及其他 Deno / Edge 运行时:使用 await stripe.webhooks.constructEventAsync(body, signature, secret, undefined, Stripe.createSubtleCryptoProvider())。
3. 通过阅读处理器进行其余审查
扫描器不会检查以下内容。请查看代码,并告诉用户每项检查的结果。
- 密钥。
STRIPE_WEBHOOK_SECRET(whsec_...)来自服务器环境变量,切勿来自NEXT_PUBLIC_/VITE_类型的变量或源代码。测试模式和生产模式有不同的密钥,每个端点和stripe listenCLI 也是如此。 - 重放攻击。
constructEvent会拒绝早于其容忍期限(默认 300 秒)的签名。请省略容忍期限参数,或保持较小值。切勿捕获异常后继续执行。 - 幂等性。 Stripe 可能会多次投递同一事件。要么让写入操作天然可重复(使用 Stripe ID 作为键的 upsert,设置状态而非增加积分),要么将
event.id存储在具有唯一约束的表中,并跳过已处理过的 ID。特别注意涉及增加积分、余额、库存的操作。 - 顺序。 事件可能乱序到达。对于订阅相关操作,从 API 获取当前对象(
stripe.subscriptions.retrieve)或比较时间戳,不要依赖事件的到达顺序。 - 信任边界。 用户和计划信息应从已验证的事件中获取(
client_reference_id、metadata、结账时存储的客户 ID),切勿来自查询字符串或第二次未经签名的请求。成功页面重定向不足为凭:应从 webhook 授予访问权限。 - 响应。 事件存储或处理完成后尽快返回 2xx。耗时操作放入队列。仅在签名错误时返回 400,这样 Stripe 的重试才有意义。
- 事件列表。 端点只订阅代码实际处理的事件,对其他类型的事件忽略而不报错。
4. 报告
列出扫描器的发现结果,然后对每个审查项输出一行:正常、已修复或向用户提出疑问(例如“每次投递都会增加积分:重复事件是否应该再次增加?”)。修复后再次运行扫描并显示其最后一行。要真正测试,建议使用 Stripe CLI 运行 stripe listen --forward-to localhost:3000/api/webhooks 和 stripe trigger checkout.session.completed。
确认的误报可以通过在该行或上一行添加 // vibe-preflight-ignore <rule-id> 来静默,注释中需说明理由。