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ファーストパターンを使用するように再設計しました。
- フォームデータを収集し、
fetch()を使用してJSONペイロードとして送信する。 - サーバーのレスポンスを処理する。サーバーが決済ゲートウェイの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リダイレクトでフローを完結させることで、セキュリティを維持したままエッジの制限を回避できます。エッジを単なるネットワークの中継点としてではなく、コードとして扱い、それに応じてトランスポートを設計してください。
