Cloudflare 的机器人挑战(bot-challenge)系统可能会在无声无息中拦截普通的 HTML 表单提交,将简单的支付点击变成真实用户的死胡同。将请求从原生的导航 POST(navigation POST)切换为“fetch 优先”流程,可以在不牺牲安全性的情况下恢复用户体验。
为什么这个问题很重要
一位开发者发布了一个支付表单,它在每个测试套件、curl 以及本地服务器上都能正常工作。然而,当客户使用 Chrome 浏览器时,同一个表单在第一次点击后会抛出安全错误,第二次点击则显示“超时或重复(timeout-or-duplicate)”消息。这次失败导致了三次热修复发布和整整一天的调试工作。
隐藏的边缘情况
该表单存在于一个开源的 Astro 包中,它依赖于一个普通的 HTML <form> 元素。当用户点击 Pay 时,服务器会返回一个指向 Stripe 的 303 重定向,浏览器会在没有任何 JavaScript 的情况下跟随该重定向。网站通常将此模式作为脚本被禁用时的回退方案。
Cloudflare 位于网站前端并运行着机器人检测引擎。对于普通的 GET 请求,它可以显示一个中间挑战页面(如 CAPTCHA 或 JavaScript 检查)。在浏览器通过挑战后,请求才会继续进行。
然而,导航 POST(navigation POST)无法为了应对挑战而暂停,也无法在保持请求体完整的情况下恢复。边缘节点会丢弃该请求并返回 503 状态码,导致浏览器显示空白页或通用错误。自动化测试浏览器携带的指纹与 Cloudflare 信任的指纹一致,因此永远不会触发挑战,导致问题在真实用户访问网站之前一直处于隐形状态。
日志揭示了什么
从用户 Chrome 会话中获取的实时网络追踪显示了对同一端点的两个截然不同的请求:
- Navigation POST → 503 响应,标签页卡死。
- fetch() POST → 请求完成。
两个请求都源自同一个源(origin),携带相同的凭据(credentials),且发生在同一时刻。唯一的区别在于传输方式。fetch 请求绕过了拦截导航 POST 的中间流程。
徒劳的尝试
开发者尝试了一系列未能触及根本原因的修复方法:
- 重新获取 Turnstile token,以为它们已过期。
- 将 IP 范围加入白名单,以为拦截是基于地理位置的。
- 禁用扩展程序、清除 service workers 并删除 cookie。
每项更改都无法解决错误,因为失败源于上游的边缘节点,而非客户端或服务器代码。
切实的解决方案
该表单并没有关闭 Cloudflare 的保护,而是通过重新设计采用了“fetch 优先”模式:
- 收集表单数据,并使用
fetch()以 JSON 负载(payload)的形式发送。 - 处理服务器响应。如果服务器返回支付网关的 URL,则调用
location.assign()通过简单的 GET 请求进行跳转。
fetch 请求不会触发中间挑战,因此 POST 请求可以到达源服务器。随后的 GET 重定向可以安全地通过任何挑战,因为 GET 请求体为空,可以在用户通过挑战后重新发送。
开发者的风险
- 用户信任:支付表单的无声失败会削弱用户信心,并可能导致收入损失。
- 维护成本:此次事件需要三次补丁发布和一整天的调查。
- 测试盲点:仅依赖内部测试环境可能会错过仅在实际运行环境中才会出现的边缘情况故障。
给广大社区的启示
- 使用真实浏览器进行监测。当问题仅在实际用户身上出现时,请捕获这些会话的网络日志,而不是仅仅信任自动化测试运行。
- 将边缘节点视为技术栈的一部分。Cloudflare 位于客户端和服务器之间;它的行为会影响请求必须如何构建。
- 选择正确的传输方式。导航 POST 和
fetchPOST 在边缘节点通过不同的路径传输。在设计 API 时应考虑到这种区别。 - 暴露错误详情。将 503 或“timeout-or-duplicate”消息呈现在 UI 上,以便开发者无需深入挖掘日志即可看到确切的失败模式。
后续关注点
开发者应对任何依赖原生 POST 导航的表单工作流进行审计,特别是当 Cloudflare 或类似的 CDN 安全服务位于网站前端时。添加一个轻量级的 fetch 封装器可以预先防止类似的失败。能够捕获边缘节点生成的状态码的监控工具可以在问题影响客户之前发出警报。
核心结论: 当 Cloudflare 的机器人挑战处于激活状态时,普通的 HTML 表单提交容易发生静默失败。通过 fetch() 重新路由 POST 请求,并使用 GET 重定向来完成整个流程,可以在保持安全性完好的同时,绕过边缘(edge)的这一限制。应将边缘视为代码,而不仅仅是一个网络跳转节点,并据此设计你的传输方式。
