Le système de défi anti-bot de Cloudflare peut silencieusement interrompre les soumissions de formulaires HTML classiques, transformant un simple clic de paiement en une impasse pour les utilisateurs réels. Passer d'un POST de navigation natif à un flux « fetch-first » restaure l'expérience sans compromettre la sécurité.

Pourquoi ce problème est important

Un développeur a publié un formulaire de paiement qui fonctionnait dans toutes les suites de tests, avec curl, et sur le serveur local. Ce même formulaire, lorsqu'un client utilisait Chrome, renvoyait une erreur de sécurité après le premier clic et un message « timeout-or-duplicate » au second. Cet échec a nécessité trois déploiements de correctifs d'urgence et une journée entière de débogage.

Le piège caché de l'edge

Le formulaire fait partie d'un package Astro open-source qui repose sur un élément HTML <form> classique. Lorsque l'utilisateur clique sur Pay, le serveur répond par une redirection 303 vers Stripe, et le navigateur suit la redirection sans aucun JavaScript. Les sites utilisent ce modèle comme solution de repli lorsque les scripts sont désactivés.

Cloudflare se place devant le site et exécute un moteur de détection de bots. Pour les requêtes GET ordinaires, il peut afficher un défi interstitiel (un CAPTCHA ou une vérification JavaScript). Une fois que le navigateur a passé le défi, la requête se poursuit.

Cependant, un POST de navigation ne peut pas être mis en pause pour un défi, puis repris avec son corps de requête intact. L'edge rejette la requête et renvoie un statut 503, laissant le navigateur face à une page blanche ou une erreur générique. Les navigateurs de tests automatisés, qui possèdent la même empreinte (fingerprint) que celle de confiance pour Cloudflare, ne déclenchent jamais le défi, ce qui rend le problème invisible jusqu'à ce qu'un utilisateur réel accède au site.

Ce que les logs ont révélé

Une trace réseau en direct d'une session Chrome d'un utilisateur a montré deux requêtes contrastées vers le même endpoint :

  • Navigation POST → réponse 503, l'onglet est resté figé.
  • fetch() POST → requête terminée.

Les deux requêtes provenaient de la même origine, transportaient les mêmes identifiants et se sont produites au même moment. La seule différence résidait dans la méthode de transport. La requête fetch a contourné le flux interstitiel qui bloque les POST de navigation.

Des pistes qui n'ont mené nulle part

Le développeur a tenté une série de correctifs qui passaient à côté de la cause profonde :

  • Renouvellement des jetons Turnstile, supposant qu'ils avaient expiré.
  • Mise en liste blanche de plages d'IP, pensant que le blocage était lié à la localisation.
  • Désactivation d'extensions, nettoyage des service workers et suppression des cookies.

Chaque modification laissait l'erreur inchangée car l'échec provenait de l'amont, au niveau de l'edge, et non du code client ou serveur.

La solution pragmatique

Au lieu de désactiver la protection de Cloudflare, le formulaire a été repensé pour utiliser un modèle « fetch-first » :

  1. Collecter les données du formulaire et les envoyer avec fetch() sous forme de charge utile JSON.
  2. Gérer la réponse du serveur. Si le serveur renvoie une URL pour la passerelle de paiement, appeler location.assign() pour y naviguer via une simple requête GET.

Les requêtes fetch ne déclenchent pas le défi interstitiel, de sorte que le POST atteint le serveur d'origine. La redirection GET suivante peut traverser n'importe quel défi en toute sécurité, car les corps de requête GET sont vides et peuvent être rejoués une fois que l'utilisateur a passé le défi.

Les enjeux pour les développeurs

  • Confiance des utilisateurs : Un formulaire de paiement qui échoue silencieusement érode la confiance et peut entraîner une perte de revenus.
  • Surcharge de maintenance : L'incident a nécessité trois déploiements de correctifs et une journée entière d'investigation.
  • Angles morts des tests : Se fier uniquement aux environnements de test internes peut faire manquer des échecs de cas limites qui n'apparaissent que dans la réalité.

Leçons pour la communauté au sens large

  • Instrumenter les navigateurs réels. Lorsqu'un problème n'apparaît que pour les utilisateurs réels, capturez les logs réseau de ces sessions au lieu de vous fier aux exécutions de tests automatisés.
  • Considérer l'edge comme faisant partie de la stack. Cloudflare se situe entre le client et le serveur ; son comportement influence la manière dont les requêtes doivent être structurées.
  • Choisir le bon transport. Les POST de navigation et les POST via fetch empruntent des chemins différents au niveau de l'edge. Concevez vos API en tenant compte de cette distinction.
  • Exposer les détails de l'erreur. Affichez les messages 503 ou « timeout-or-duplicate » dans l'interface utilisateur afin que les développeurs puissent voir le mode d'échec exact sans avoir à fouiller dans les logs.

Ce qu'il faut surveiller ensuite

Les développeurs devraient auditer tous les flux de travail basés sur des formulaires qui reposent sur la navigation POST native, en particulier lorsque Cloudflare ou des services de sécurité CDN similaires sont placés devant le site. L'ajout d'un wrapper fetch léger peut prévenir des échecs similaires. Les outils de surveillance qui capturent les codes d'état générés par l'edge signaleront le problème avant qu'il n'atteigne les clients.

À retenir : Lorsque les défis de bot de Cloudflare sont actifs, une simple soumission de formulaire HTML est vulnérable à un échec silencieux. Réacheminer le POST via fetch() et compléter le flux par une redirection GET permet de contourner la limitation de l'edge tout en maintenant la sécurité intacte. Considérez l'edge comme du code, et non comme un simple saut réseau, et concevez vos transports en conséquence.