← SaaS 安全:从零到企业级
全栈开发SaaS 安全:从零到企业级

账单安全:任何人都能 POST 的端点

SaaS 安全系列第 4 篇:webhook 签名校验与重放防御、权益(entitlement)写入路径、PCI DSS 4.0.1 的支付页面脚本规则、VAMP 时代的测卡攻击,以及 merchant of record 究竟帮你转移了什么。

SaaSSecurityBillingWebhooksPCI DSSFraud

你的路由表里某个地方躺着一个像 /webhooks/stripe 这样的端点。它按设计是公开的——互联网必须能访问到它——而且它是你整个系统里唯一一个这样的端点:一个格式规范的 POST 就能把账号升级到企业版。任何猜到 URL 的人都可以给它发一个内含客户 ID 的 invoice.paid 事件。如果你的 handler 信任请求体,你就给整个互联网建了一个自助升级套餐的 API,还不需要登录。

第 1 篇界定了谁的数据归谁,第 2 篇界定了谁的身份归谁,第 3 篇讲如何证明发生过什么。本篇讲的是钱——而钱会改变攻击者的动机。没有人伪造 webhook 是为了破坏你的审计日志;他们伪造它是因为载荷本身值美元。账单也是唯一一个“以后再加校验”不会变成技术债的地方——它会变成欺诈、拒付,以及一个写着你名字的卡组织监控计划。

签名就是认证

你的 webhook 端点没有会话、没有 SSO、没有 SCIM——Stripe-Signature 头里的 HMAC 签名就是认证,跳过校验等于给管理后台关掉登录。方案本身很简单:服务商用你的端点密钥对原始请求体加时间戳做签名,你的工作是重新计算并比对。所有翻车点都在细节里:

  • 校验原始字节,而不是解析后的 JSON。 急于解析请求体的框架——express.json() 是经典惯犯——会把载荷重新序列化,在你的校验代码运行之前就破坏 HMAC。这是团队“临时”关掉校验的最常见原因,而其中一些再也没打开过。
  • 保留时间戳检查。 带五分钟容差的签名时间戳,阻止的是别人把截获的、签名合法的载荷留到下周重放。把容差设成 0 并不是收紧窗口——而是彻底关掉了这个检查。对一个名叫“tolerance”的参数来说,这是一把壮烈的走火枪。
  • 一个目标地址一个密钥。 Stripe 较新的 thin events——2025 年陆续推出的 v2.* 家族——不能和经典快照事件共享目标地址,所以你现在持有两个签名密钥,每条路由必须对各自的密钥校验。更广泛的生态正通过 Standard Webhooks 规范(webhook-idwebhook-timestampwebhook-signature 头)收敛到同一个形状,OpenAI 等在 2025 年已经采用——这套模式学一次,到处用。
  • IP 白名单是调味料,不是主菜。 把入口限制在服务商公布的 IP 段可以挡住随手扫描,但签名才是控制措施。两个都做;只依赖第二个。

先校验,后干活

端点的工作在校验那一刻就结束了。其他一切——更新订阅、发放席位、寄出收据——都发生在快速返回 200 之后、在 worker 上,因为服务商的重试策略没什么耐心:超过几秒,这次投递就被标记为失败,并以指数退避重试最长三天。一个慢的 handler 不只是有超时风险;它在制造重复。

反正重复是必然的——at-least-once 投递意味着同一事件会到达两次、乱序到达,偶尔在你已经处理完它的后继事件之后才到。消费者必须按事件 ID 幂等,权益写入必须扛得住重放:处理一个重放的 customer.subscription.deleted,结果应该是订阅保持已删除,而不是某个开关被再翻一次。

更深一层的规则:从服务商的 API 授权,而不是从载荷授权。 事件告诉你有事发生;API 告诉你当前的事实。Stripe 的 thin events 把这点变成了官方姿势——载荷里几乎只有一个 ID,对象要你自己去拉——但这套纪律比它们更早存在:载荷是一个声明(claim),第 2 篇教过你该怎么对待未经验证的声明。而且因为事件可能整体丢失(你的端点挂了、目标地址被禁用),一个定期轮询 API 并比对订阅状态的对账任务,就是把“我们觉得账单是对的”变成“我们查过了”的兜底。

第 1 篇还有一条边界在这里完全适用:权益记录是租户数据。它落在哪个租户,来自你在结账时建立的映射——client_reference_id、你自己设置的 metadata——绝不来自用户在付款和 webhook 之间可以编辑的字段。

你拥有和不拥有的 PCI 边界

2026 年的好消息:你永远不应该碰到卡号。托管字段(hosted fields)和 iframe 结账页把 PAN 直接发给服务商,只还给你一个 token——这正是你能待在轻量的 SAQ A 档、而不是掉进 SAQ D 深渊的原因。坏消息在 2025 年 3 月 31 日到来:PCI DSS 4.0.1 的五十一条缓期要求全面生效——其中两条正对着你以为已经外包出去的那个结账页面:

  • 要求 6.4.3——支付页面上的每一个脚本都必须登记在册、获得授权、有书面的业务理由,并做完整性校验。这包括你的 tag manager、分析像素,以及某个市场同事在 2024 年加的 A/B 测试片段。
  • 要求 11.6.1——你必须运行一套机制,检测这些脚本和安全相关 HTTP 头的未授权变更,至少每周告警一次。

原因是 Magecart:电子窃读(e-skimming)攻击往你的页面里注入脚本,在客户输入时直接读取卡号,iframe 的保护根本来不及生效——对你的服务器不可见,对服务商也不可见。iframe 边界只在你的页面没被污染时成立,而 PCI 现在也正式同意了这一点:页面是管控区。整页重定向到服务商托管页面可以进一步缩小管控区;而让卡号经过你自己服务器的直接 API 集成,会给你带来 SAQ D,并且应该附上一份书面检讨说明为什么。

两条零成本的规则:永远不要让 PAN 碰到你的基础设施(不在日志里,不在分析里,不在错误报告里);任何情况下绝不存储 CVV、磁道数据或 PIN——即使加密,PCI 也禁止。你不存的东西,没人能从你这偷走。

欺诈比客户更早找到你的结账页

你的 5 美元试用结账不只是一个收入入口——对测卡者来说,它是一个免费的卡片验证预言机,能回答“这张偷来的卡号还能用吗?”。Stripe 的 Radar 团队在 2025 年初记录了当下的这一波:攻击者已经从盲目枚举转向使用高质量钓鱼卡库的验证攻击,于是授权通过率——过去那个明显信号——不再显眼。规模是工业级的:在 Stripe 拦截的大型攻击中,四分之一涉及针对单一商户的超过一百万次交易尝试。Stripe 的答案是一个支付基础模型,把对其最大用户的此类攻击检出率从 59% 提升到 97%——这恰恰说明:这类防御的大头是服务商的工作,选择支付处理商,一定程度上就是在选择让谁的模型站在你和卡库之间。

留给你的,是爆炸半径。测卡成为你的问题,是在卡组织层面,而不是处理商层面:Visa 的 VAMP 计划——2025 年整合而成——把拒付和欺诈报告合成一个比率,并设了明确的枚举攻击阈值,Mastercard 也有自己的 ECP/EFM 等价物。越过阈值,你就进入按月罚款的监控计划;赖在里面不走,你会彻底失去受理卡支付的资格。你手里的控制措施不光鲜:给结账尝试端点加限流和 bot 检测、Radar 式的拦截规则、拒绝自建卡表单,以及把被拒小额交易的突然飙升当作事故来处理,而不是当成趣闻。

Merchant of record 之问

确实有一条路能把其中的大部分从你桌上挪走:别当卖方。Merchant of record(MoR)——Paddle、Lemon Squeezy,或 Stripe 在 2024 年收购 Lemon Squeezy 后推出的 Managed Payments——在法律上就是商户:账单描述上是他们的名字,税籍是他们的,拒付责任是他们的,欺诈敞口也是他们的。你向他们开票;他们向你的客户开票。费率的差别是实打实的(大约 5% + $0.50,对比 2.9% + $0.30),买来的是覆盖 150–200 多个司法辖区的税务合规,加上上面那整套欺诈防御栈。

把它当作一个附带费率的风险转移决策,而不是一个附带风险的费率决策。与安全相关的注意事项:不管是不是 MoR,他们打进你系统的 webhook 仍然需要同样的签名校验和幂等写入路径——信任边界移动了,但没有消失。集中度本身也是风险:2026 年 1 月发布的 Lemon Squeezy 迁移到 Stripe Managed Payments 的路径提醒你,你的账单服务商的路线图,如今是你收入管道的依赖项。无论你选哪条路,前面讲的权益写入路径都是任何厂商卖不了你的部分,因为它住在你的数据库里。

系列的下一站

下一扇门目前的想法是:SOC 2 报告本身——问卷反复索要的那份文件、审计师到底测什么,以及为什么“我们有控制措施”和“我们能证明这些措施运转了六个月”是两句话。事故响应——当这些控制措施中的某一个真的触发那天会发生什么——是另一个有力候选。和之前一样:顺序到了那里再定。

练习

  1. 攻击你自己的 webhook 端点:构造一个带着像样 invoice.paid 请求体、但没有合法签名的 POST。如果它返回的不是 400——或者更糟,你不确定——说明校验关着,或者解析请求体的那个 bug 已经把它废了。然后检查容差值;0 算一个发现。
  2. 拿上周的真实事件对 staging 重放两次。第二次投递后的权益状态必须等于第一次之后的状态,而且你的去重存储里必须能看到事件 ID。如果重放改变了任何东西,服务商的下一次重试风暴也会。
  3. 对你的结账页做一次 6.4.3 演练:列出 DOM 里的每一个脚本,各配一个负责人和一句理由。没人能给出理由的,从页面上拿掉——而让一个没有理由的脚本出现在页面上的那个流程,才是真正的审计发现。
guest@swangnice:~$