排查 AI 生成支付代码中的鉴权漏洞
文章指出 AI 生成的 Stripe 集成可能只通过支付成功路径测试,却留下付费权限绕过漏洞。摘录具体展示先解析 JSON 导致 Webhook 验签失败、随后删除验签的错误链路,并介绍 MIT 许可的检查脚本。
AI 深度解读
AI 帮你接好 Stripe,点「升级」、用 4242 4242 4242 4242 付款、跳到 /success,套餐也变成 Pro,这只能证明正常流程跑通了。作者反复看到的三个漏洞,藏在演示之外:伪造支付通知、绕过付款直接开通、密钥进入浏览器。
这篇的价值,是把付费系统里几条不能交给页面决定的边界讲清楚,并提供一个约一分钟完成的检查脚本。不过,脚本通过距离支付系统可靠,还差数据库核验和异常流程测试。
支付成功,究竟该信谁?
第一个问题出在 webhook,也就是 Stripe 向你的服务端发送的事件通知。Stripe 会给通知签名,服务端必须先验证签名,再相信里面的支付状态。
作者观察到,AI 编程代理容易先执行 await req.json(),再把解析结果交给 stripe.webhooks.constructEvent(rawBody, signature, secret)。但签名针对的是原始请求字节,解析后的 JSON 对象无法替代原始内容。验证报错后,代理又可能通过删除校验让流程恢复运行。
这样一来,攻击者就能向接口提交伪造的 checkout.session.completed,把 payment_status 写成 paid。如果后续直接据此开通权限,支付入口就变成了免费开通入口。
下面是原文给出的 Next.js App Router 修复方式,用原始请求内容完成验签:
export async function POST(req: Request) {
const body = await req.text(); // raw body, not req.json()
const sig = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("bad signature", { status: 400 });
}
// handle event...
}
关键是 req.text() 这一行,以及验签失败就返回 400。原文还特别说明,Supabase Edge Functions 的 Deno 环境要使用 constructEventAsync,配合 Stripe.createSubtleCryptoProvider()。
第二个漏洞与此相连:让 /success 页面读取 URL 中的 session_id,然后把用户改成 Pro。任何人都能输入 /success?session_id=anything;真正付款的人,也可能在跳转完成前关掉页面,结果付了钱却没有权限。
付费权限应由通过验签的 webhook 写入数据库。
成功页负责展示感谢和开通进度。跳转、查询参数和客户端回调,都不应该拥有开通权限的能力。
为了消除报错,别把密钥送进浏览器
第三个漏洞常见于环境变量配置。作者发现,代理有时会给 Stripe secret key、webhook secret,甚至 Supabase 的 service_role key 加上 NEXT_PUBLIC_ 或 VITE_ 前缀,让客户端能够读取它们。
报错可能消失了,密钥也随构建产物发给了访客。
扫描时还要认准前缀:Stripe secret key 是 sk_live_、sk_test_,webhook secret 是 whsec_。作者提醒,搜索 sk- 找的是 OpenAI key 的形式,不能替代 Stripe 密钥检查。
这对前端和全栈开发者尤其直接:浏览器需要某个值,不代表应该把现有服务端密钥暴露给它。遇到客户端缺配置的错误,先检查调用是否放错了位置。
一分钟脚本,具体能检查到哪一步?
作者提供了 MIT 许可的 PayWallKit/paywall-check,运行方式如下:
git clone https://github.com/PayWallKit/paywall-check
cd paywall-check
./paywall-check.sh https://yourapp.com /api/webhooks/stripe /success ./.next/static
四个参数依次是站点地址、webhook 路径、成功页路径,以及可选的客户端构建目录。注意最后的 ./.next/static:只有提供构建目录,脚本才能扫描对应产物中的密钥特征。
它做三件事:发送一次未签名的伪造 checkout.session.completed,收到 2xx 就判失败;携带虚构的 session_id 请求成功页,页面表示已经升级就判失败;搜索构建产物里的 sk_live_、sk_test_、whsec_ 和 service_role。
任一检查失败,脚本都会以非零状态退出,方便接入 CI。原文要求只对自己拥有的应用运行。
但它只是冒烟测试,即快速筛查明显故障。成功页没有显示「已升级」,不代表数据库没被修改;反过来,接口返回 200 能说明它没有拒绝该请求,却不能单凭状态码证明权限已经开通。
退款、取消订阅、重复事件,以及客户端自行指定价格,都不在检查范围内。作者建议继续用 Stripe CLI 的 stripe listen 和 stripe trigger 测试,并查看数据库实际变化。
我的判断
作者最有用的建议,是提前把权限边界写进 AGENTS.md、CLAUDE.md 或 .cursor/rules。除了验签和限制权限写入入口,还包括:客户端只提交套餐标识,不提交价格、金额、数量或用户 ID;权限检查放在服务端;处理重复事件。
幂等处理尤其不能漏。Stripe 对未及时收到 2xx 的 webhook 会重试,因此同一事件出现两次属于正常情况。作者建议记录 event.id,加唯一约束,重复事件直接跳过。
不过,「先记录,再处理」还需要考虑中途失败后的恢复。原文没有展开事务和重试细节,不能把这条规则直接当作完整实现。作者还要求在 customer.subscription.deleted、charge.refunded、charge.dispute.created 时撤销权限,但部分退款、争议处理等具体业务策略也没有说明。
这套检查适合挡住明显错误,不能替代支付状态与数据库结果的核对。
作者没有提供样本数量、漏洞发生率或工具对照数据,所以「代理反复写出这三个漏洞」应当视为经验观察。文章同时推广售价 $19 的 Paywall Kit,并提供截至周五、使用 HN5 减 $5 的优惠,具体日期未说明;这层商业背景也应保留在阅读判断里。
能拿来做什么
- 给 AI 布置 Stripe 接入任务时,把原始请求验签、服务端鉴权和 webhook 写入权限写入项目规则,并在验收时检查这些约束是否落实。
- 在 Next.js 或 Vite 项目构建后扫描客户端产物,确认
NEXT_PUBLIC_、VITE_没有把服务端密钥带进浏览器。 - 把脚本用于自己的测试环境或 CI,同时核对数据库,验证伪造通知和虚构
session_id都无法改变付费权限。 - 用
stripe listen和stripe trigger补测重复通知、退款和取消订阅,检查事件处理结果,而不仅是接口是否返回成功。