Dev.to · 11 min read

AWS WAF Challenge : bloquer les bots avant qu’ils n’atteignent l’application

AWS WAF Challenge : bloquer les bots avant qu’ils n’atteignent l’application

Quand on m’a appelé, l’attaque durait depuis environ une semaine. Elle visait la page de connexion d’une application historique qui générait son HTML côté serveur. Les requêtes se comptaient en millions et provenaient d’un très grand nombre d’adresses IP, ce qui rendait un blocage par IP peu efficace. Contrairement à d’autres campagnes que j’avais rencontrées, l’assaillant faisait également tourner ses empreintes JA3 et JA4. Même un rate limiting agrégé sur ces signaux ne pouvait donc apporter qu’une réponse partielle. Quelques jours plus tard, une attaque similaire a visé une deuxième application chez mon client. Cette fois, il ne s’agissait plus de l’ancienne application server-side, mais d’une SPA (single page app) qui appelait une API JSON pour créer les comptes. Ces deux incidents m’ont permis d’utiliser les deux modes d’intégration de la fonctionnalité Challenge d’AWS WAF : le challenge directement renvoyé par le WAF pour une page HTML ; le challenge résolu en amont par challenge.js, puis transmis à une API appelée avec fetch. Pourquoi placer le challenge côté infrastructure ? Si mon client m'a appelé au bout de 7 jours, c'est que l'équipe a d'abord essayé de traiter l'attaque au niveau applicatif, via l'intégration de Cloudflare Turnstile. L’intégration était sérieuse. Un module PrestaShop gérait les clés, l’activation globale et des configurations distinctes selon le tenant. Lors de la soumission, l’application récupérait le jeton du formulaire et le validait elle-même auprès de Cloudflare : $turnstileToken = Tools::getValue('cf-turnstile-response'); if (empty($turnstileToken)) { $turnstileValid = false; } elseif (!$this->verifyTurnstileToken($turnstileToken)) { $turnstileValid = false; } La validation nécessitait ensuite un appel serveur vers siteverify : $response = Tools::file_get_contents( 'https://challenges.cloudflare.com/turnstile/v0/siteverify', false, stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => 'Content-Type: application/x-www-form-urlencoded', 'content' => http_build_query([ 'secret' => $secretKey, 'response' => $token, 'remoteip' => Tools::getRemoteAddr(), ]), 'timeout' => 5, ], ]) ); Cette approche fonctionnait, mais seulement partiellement : une proportion significative du trafic obtenait malgré tout des jetons valides. Nous avons notamment observé des jetons générés depuis un groupe d’adresses IP néerlandaises, puis présentés à notre infrastructure depuis l'Espagne : l’attaquant avait donc industrialisé l’acquisition et la circulation des jetons. Le jeton prouvait qu’un challenge avait été résolu, mais il ne constituait ni une identité durable ni, dans ce flux, une liaison stricte avec l’adresse IP qui l’avait obtenu. Mais surtout, cette protection avait un défaut important : avant de rejeter une requête, il fallait mobiliser tout la chaine : le CDN, le load balancer, le serveur web, PHP, le framework, et notre code de validation : même quand l’authentification échouait, une partie significative du coût technique avait déjà été payée. Et avec des millions d'appels, ça avait un impact sur les performances de l'application (impact limité par l'autoscaling) et sur la facture. Avec une règle Challenge placée dans AWS WAF (qui était déjà utilisé par ailleurs par l'application), la requête sans jeton valide est arrêtée à la périphérie. Elle ne consomme ni worker PHP, ni connexion à la base de données, ni capacité de calcul applicative. C’était exactement ce dont nous avions besoin face à plusieurs millions de requêtes. Il y a aussi un bénéfice de maintenance propre au déplacement du contrôle. Toute validation gérée par l’application implique nécessairement du code dans le formulaire, le contrôleur, la gestion des secrets, l’appel distant et les messages d’erreur. Avec un challenge géré par le WAF, le backend ne connaît même pas son existence. Il ne reçoit que les requêtes qui ont déjà passé le contrôle. Le fournisseur du challenge applicatif (Cloudflare vs AWS) n’est pas le sujet de cette comparaison. La même distinction existerait avec n’importe quel mécanisme validé dans le backend. Ce que je veux relever ici (même si j'avoue avoir été lassé que le dev succombe à l'« évidence Cloudflare » liée à sa position dominante, plutôt que de m'appeler au premier de l'attaque ^^), c'est la différence architecturale entre un contrôle géré par l’application et un contrôle placé à la périphérie, en amont de l’origine. Première implémentation : une application qui génère son HTML côté serveur Sur l’application historique, l'authentification se fait par un formulaire HTML natif en POST. Ce détail est important. Lorsqu’un utilisateur ouvre une page HTML, la réponse HTTP devient un document que le navigateur doit afficher. AWS WAF peut donc intercepter cette navigation et envoyer en réponse sa demande de Challenge. Le navigateur exécute le JavaScript fourni par AWS WAF, réalise le proof of work silencieux, obtient un jeton, puis reprend le script interstitiel relance de façon transparente l'appel au backend, appel que WAF laisse donc passer. Dans ce cas-là, l'implémentation du challenge par AWS WAF est ultra simple. J’ai commencé par poser un label sur les requêtes qui correspondaient au parcours de connexion. La règle de challenge tient en 15 lignes de code déclaratif, observabilité comprise ! rule { name = "ChallengeLoginNoToken" priority = 13 action { challenge {} } statement { label_match_statement { key = "login" scope = "LABEL" } } visibility_config { cloudwatch_metrics_enabled = true metric_name = "ChallengeLoginNoToken" sampled_requests_enabled = true } } Le label login était attribué par une règle précédente aux requêtes POST portant sur /login (cette séparation entre détection et action permet de réutiliser le même périmètre pour d’autres protections, par exemple une rate limit par IP ou par empreinte JA4). Dans le cas d’un site server-side, il n’y a rien à ajouter dans le code PHP. Il n’y a pas de SDK à intégrer, pas de jeton à valider dans le contrôleur applicatif. C’est la configuration la plus simple : le navigateur demande un document, le WAF peut répondre par un document exécutable, et l’application n’est appelée qu’après résolution du challenge. Implémentation 2 : Challenge AWS WAF sur une SPA La deuxième application utilisait une SPA (appli React) pour son interface et une API sur un autre sous-domaine. La création de compte ressemble conceptuellement à ceci : await fetch("https://api.example.com/account-registration", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(payload), }); Si AWS WAF renvoie directement son challenge à cette requête, la réponse arrive dans fetch. Le navigateur ne l’interprète pas comme un nouveau document et n’exécute pas automatiquement le JavaScript contenu dans la réponse. La SPA reçoit donc un statut 202, éventuellement avec un corps qu’elle ne sait pas traiter, et le parcours d’inscription échoue. C’est une propriété du modèle d’exécution du navigateur, pas une limitation spécifique à AWS. Du JavaScript renvoyé comme donnée par une requête XHR ou fetch n’est pas exécuté spontanément. La solution consiste à inverser l’ordre des opérations : la SPA charge le SDK challenge.js d’AWS WAF ; le SDK résout silencieusement le challenge dans la page ; la SPA récupère un jeton ; elle joint ce jeton à la requête API ; le WAF vérifie le jeton avant de laisser passer la requête. Configurer le WAF pour une SPA La Web ACL doit d’abord connaître les domaines sur lesquels ses jetons sont valables. Dans mon cas, le frontend et l’API utilisaient deux sous-domaines du même domaine racine : resource "aws_wafv2_web_acl" "acl_cloudfront" { provider = aws.us-east-1 name = "cloudfront-api-acl" scope = "CLOUDFRONT" token_domains = ["example.com"] default_action { allow {} } # ... } Je pose ensuite un label sur les requêtes POST vers l’endpoint sensible, puis je vérifie le jeton avec une action Challenge : rule { name = "ChallengeRegister" priority = 15 action { challenge {} } statement { label_match_statement { key = "register" scope = "LABEL" } } visibility_config { cloudwatch_metrics_enabled = true metric_name = "ChallengeRegister" sampled_requests_enabled = true } } On retrouve donc jusqu'ici la même implémentation qu'en v1.. par contre, il nous faut envoyer un token valide "du premier coup" vu qu'on ne saurait pas traiter le challenge de façon synchrone. L’URL du SDK js et la secret key à injecter dans notre SPA sont fournies dans la section d’intégration de la console AWS WAF. Je l’injecte dans la SPA à partir d’une variable d’environnement. Un petit détail d'implémentation qui peut être bloquant La première version de mon chargeur attendait simplement l’événement load du script. Ce n’était pas suffisant. Le script initial jsapi.js charge à son tour challenge.js, puis initialise window.AwsWafIntegration de manière asynchrone. Le chargement du premier script peut donc être terminé alors que getToken() n’est pas encore disponible. Le symptôme était particulièrement trompeur : le premier submit partait sans jeton, celui-ci étant obtenu quelques milli-secondes plus tard. J’ai résolu ce problème avec une attente bornée : function waitForIntegration(timeoutMs = 3000): Promise { return new Promise((resolve) => { const start = Date.now(); const tick = () => { if (typeof window.AwsWafIntegration?.getToken === "function") { resolve(true); return; } if (Date.now() - start >= timeoutMs) { resolve(false); return; } window.setTimeout(tick, 50); }; tick(); }); } export async function getWafToken(): Promise { await injectWafScript(); if (!(await waitForIntegration())) { return undefined; } return window.AwsWafIntegration!.getToken(); } Le jeton est ensuite placé dans le header prévu par AWS : export async function register(payload: RegisterPayload) { const wafToken = await getWafToken(); return publicClient.post("/account-registration", payload, { headers: { "x-aws-waf-token": wafToken, }, }); } Deuxième détail qui peut bloquer Dans une architecture avec plusieurs sous-domaines, il faut penser au CORS (et notamment au preflight OPTIONS puisque l'ajout du token rend la requête 'complexe'). Le header doit être autorisé par la réponse à la requête OPTIONS : Access-Control-Allow-Headers: Authorization, Content-Type, x-aws-waf-token Un petit ajout à faire au niveau de la configuration Cloudfront, donc. Combien coûte cette protection ? Aux tarifs publics AWS consultés en août 2026, le socle AWS WAF est facturé : 5 dollars par Web ACL et par mois ; 1 dollar par règle et par mois ; 0,60 dollar par million de requêtes traitées, dans l’allocation WCU standard. Les actions Allow, Block et Count n’ajoutent pas de coût par action. Les réponses Challenge sont facturées séparément, à hauteur de 0,40 dollar par million de réponses Challenge. Ce coût additionnel reste faible, surtout si la règle est limitée à quelques points d’entrée à forte valeur : connexion, inscription, récupération de mot de passe, validation d’une commande ou opération métier sensible. La différence avec les règles spécialisées Fraud Control est considérable. AWS WAF Account Takeover Prevention et Account Creation Fraud Prevention ajoutent un abonnement mensuel, puis une facturation par requête analysée avec des tarifs dégressifs mais élevés. Dans l’exemple public d’AWS, 15 millions de requêtes analysées par ATP produisent plus de 8 000 dollars de frais Fraud Control. ATP apporte des capacités beaucoup plus avancées. Il ne répond simplement pas au même besoin économique. Quand une action Challenge ciblée, un rate limiting et quelques signaux réseau suffisent, le rapport efficacité-prix est difficile à battre. En résumé : une même solution avec deux implémentations La première implémentation tient dans 15 liines Terraform parce que le navigateur navigue vers un document HTML. AWS WAF peut lui répondre directement avec le JavaScript du challenge. La seconde demande un petit service frontend parce que l’appel protégé est réalisé avec fetch. Le challenge doit être exécuté avant la requête, puis son jeton doit être transmis dans un header ou un cookie. Dans les deux cas, le backend n’implémente aucune validation de challenge. Il ne stocke pas de clé secrète supplémentaire, n’appelle pas un service externe et ne consomme pas de ressources pour les requêtes rejetées. La fonctionnalité AWS WAF Challenge ne remplace pas une stratégie anti-abus complète. Elle permet en revanche de déplacer une opération coûteuse au bon endroit : avant l’application.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News