Cloudflareのボットチャレンジシステムは、通常のHTMLフォーム送信を密かに停止させ、単純な決済クリックを実際のユーザーにとっての行き止まりに変えてしまうことがあります。リクエストをネイティブなナビゲーションPOSTからfetchファーストのフローに切り替えることで、セキュリティを損なうことなく体験を復元できます。

なぜこの問題が重要なのか

ある開発者が、すべてのテストスイート、curl、およびローカルサーバーで動作する決済フォームをリリースしました。しかし、同じフォームを顧客がChromeで使用すると、最初のクリック後にセキュリティエラーが発生し、2回目のクリックでは「timeout-or-duplicate」というメッセージが表示されました。この失敗により、3回のホットフィックスリリースと丸一日のデバッグを余儀なくされました。

隠れたエッジの挙動

このフォームは、プレーンなHTML <form> 要素に依存するオープンソースのAstroパッケージに含まれています。ユーザーが Pay をクリックすると、サーバーはStripeへの303リダイレクトを返し、ブラウザはJavaScriptなしでそのリダイレクトに従います。サイトは、スクリプトが無効化されている場合のフォールバックとしてこのパターンを使用しています。

Cloudflareはサイトの前面に位置し、ボット検出エンジンを実行しています。通常のGETリクエストに対しては、中間チャレンジ(CAPTCHAやJavaScriptチェック)を表示できます。ブラウザがチャレンジを通過した後、リクエストが進行します。

しかし、ナビゲーションPOSTは、チャレンジのために一時停止し、そのボディを保持したまま再開することができません。エッジはリクエストを破棄して503ステータスを返し、ブラウザには空白のページまたは汎用的なエラーが残されます。Cloudflareが信頼するのと同じフィンガープリントを持つ自動テストブラウザは、チャレンジをトリガーしないため、実際のユーザーがサイトにアクセスするまで問題は目に見えないままとなります。

ログが明らかにしたこと

ユーザーのChromeセッションからのライブネットワークトレースにより、同じエンドポイントへの2つの対照的なリクエストが示されました。

  • Navigation POST → 503レスポンス、タブが停止。
  • fetch() POST → リクエスト完了。

両方のリクエストは同じオリジンから発生し、同じ認証情報(credentials)を持ち、同じ瞬間に発生しました。唯一の違いは転送方法でした。fetchリクエストは、ナビゲーションPOSTをブロックする中間フローを回避しました。

解決に至らなかった試み

開発者は、根本的な原因を見逃した一連の修正を試みました。

  • Turnstileトークンが期限切れになったと想定し、更新した。
  • ブロックが場所に基づいていると考え、IP範囲をホワイトリストに登録した。
  • 拡張機能を無効化し、サービスワーカーをクリアし、クッキーを削除した。

失敗の原因はクライアントやサーバーのコードではなく、上流のエッジにあったため、どの変更を行ってもエラーは変わりませんでした。

実践的な修正方法

Cloudflareの保護をオフにする代わりに、フォームをfetchファーストパターンを使用するように再設計しました。

  1. フォームデータを収集し、fetch() を使用してJSONペイロードとして送信する。
  2. サーバーのレスポンスを処理する。サーバーが決済ゲートウェイのURLを返す場合は、location.assign() を呼び出して、単純なGETリクエストでそこに遷移する。

Fetchリクエストは中間チャレンジをトリガーしないため、POSTはオリジンサーバーに到達します。その後のGETリダイレクトは、GETボディが空であり、ユーザーがチャレンジをクリアした後に再試行できるため、安全にチャレンジを通過できます。

開発者にとってのリスク

  • ユーザーの信頼: サイレントに失敗する決済フォームは信頼を損ない、収益の損失につながる可能性があります。
  • メンテナンスのオーバーヘッド: この事象により、3回のパッチリリースと丸一日の調査が必要となりました。
  • テストの死角: 内部テスト環境のみに依存していると、実環境でのみ発生するエッジケースの失敗を見逃す可能性があります。

コミュニティ全体への教訓

  • 実際のブラウザを計測する: 問題が実際のユーザーにのみ発生する場合、自動テストの実行結果を信じるのではなく、それらのセッションからネットワークログをキャプチャしてください。
  • エッジをスタックの一部として扱う: Cloudflareはクライアントとサーバーの間に位置しています。その挙動は、リクエストをどのように構成すべきかに影響を与えます。
  • 適切な転送方法を選択する: ナビゲーションPOSTとfetch POSTは、エッジにおいて異なる経路を通過します。その違いを念頭に置いてAPIを設計してください。
  • エラーの詳細を公開する: 503や「timeout-or-duplicate」メッセージをUIに表示し、開発者がログを掘り起こさなくても正確な失敗モードを確認できるようにします。

次に注意すべきこと

開発者は、特にCloudflareや同様のCDNセキュリティサービスがサイトの前面にある場合、ネイティブのPOSTナビゲーションに依存するフォームベースのワークフローを監査すべきです。軽量なfetchラッパーを追加することで、同様の失敗を未然に防ぐことができます。エッジが生成したステータスコードをキャプチャするモニタリングツールを使用すれば、顧客に届く前に問題を検知できます。

要点: Cloudflareのボットチャレンジが有効な場合、通常のHTMLフォーム送信はサイレントな失敗(エラーに気づかない失敗)に対して脆弱です。POSTリクエストをfetch()経由に切り替え、GETリダイレクトでフローを完結させることで、セキュリティを維持したままエッジの制限を回避できます。エッジを単なるネットワークの中継点としてではなく、コードとして扱い、それに応じてトランスポートを設計してください。