Essayer mille fois : la limitation de débit et la page 429
Temps 2 · séance 2 · ~20 min
Les slides du chapitre (PDF)PDF
- Expliquer pourquoi un code court se trouve en essayant toutes les valeurs, quand rien ne limite les essais.
- Essayer les codes cadeau du magasin en boucle depuis l'onglet Console.
- Limiter les essais d'une route avec le middleware
throttleet lire la réponse 429. - Calculer le temps d'une attaque avec et sans limite.
1. 23andMe, 2023
D'avril à septembre 2023, un attaquant essaie sur le site de tests génétiques 23andMe des mots de passe déjà fuités ailleurs, et ouvre environ 14 000 comptes. Grâce à la fonction qui montre les parents génétiques d'un client, il accède aux données d'environ 6,9 millions de personnes. Cette attaque s'appelle le bourrage d'identifiants : un programme essaie en masse des couples adresse et mot de passe, et garde ceux qui marchent. En 2025, le régulateur britannique inflige 2,31 millions de livres à l'entreprise et relève que l'authentification à deux facteurs n'y était pas obligatoire.
Un humain essaie trois mots de passe et abandonne. Un programme en essaie des milliers sans se fatiguer. Le site peut compter les essais et les limiter, repérer les tentatives inhabituelles et demander une seconde preuve d'identité. Ce chapitre installe la première de ces protections.
2. Le danger : dix mille codes, aucune limite
En bas du magasin, le champ « Code cadeau » accepte quatre chiffres et crédite le solde. Voici l'action qui le reçoit, dans app/Http/Controllers/GiftCodeController.php :
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'code' => ['required', 'string'],
]);
$giftCode = GiftCode::where('code', $data['code'])
->whereNull('redeemed_at')
->first();
if (! $giftCode) {
return back()->withErrors(['code' => 'Ce code est inconnu ou déjà utilisé.']);
}
// … le code est marqué utilisé, le solde est crédité
}
Un code faux renvoie un message d'erreur, et rien d'autre. La route, dans routes/web.php, ne porte que le middleware auth du groupe :
Route::post('codes-cadeau', [GiftCodeController::class, 'store'])->name('gift-codes.store');
Quatre chiffres font dix mille codes, de 0000 à 9999. Une personne connectée peut donc tous les essayer, l'un après l'autre, et encaisser chaque code qui existe. Le seeder en crée trois, dans database/seeders/DatabaseSeeder.php : 4821 à 5 €, 7305 à 10 € et 1996 à 25 €.
Le développeur ne l'a pas vu, parce qu'il a testé le formulaire comme un humain : un code juste passe, un code faux est refusé. Les deux cas sont corrects. Ce qu'il n'a pas testé, c'est le nombre d'essais.
3. L'attaque, montrée
Mallory se connecte et ouvre le magasin. Elle tape 0000 dans le champ et clique sur « Utiliser » :

Taper dix mille codes à la main prendrait une journée. Mallory ouvre plutôt l'onglet Console des outils de développement et y colle cette boucle, puis Entrée. Elle essaie les codes de 0000 à 9999 et s'arrête au premier qui marche :
const token = decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)[1]);
for (let i = 0; i < 10000; i++) {
const code = String(i).padStart(4, '0');
const response = await fetch('/codes-cadeau', {
method: 'POST',
headers: { 'X-XSRF-TOKEN': token },
body: new URLSearchParams({ code }),
});
if ((await response.text()).includes('votre solde')) {
console.log('Code trouvé :', code);
break;
}
}
Ligne par ligne :
- La première ligne lit le jeton CSRF dans le cookie
XSRF-TOKEN, que Laravel pose à chaque réponse et que le JavaScript de la page peut lire. Surtamagotchi.test, servi enhttp, une requêtePOSTqui ne porte pas ce jeton reçoit le statut 419. Le chapitre 2.11 explique pourquoi. String(i).padStart(4, '0')transforme7en0007: le code a toujours quatre chiffres.fetch()envoie le code comme le formulaire, avec les cookies de la session de Mallory.awaitattend la réponse avant l'essai suivant.response.text()lit la page reçue après la redirection. Le message de succès, « Code accepté, votre solde est crédité. », y est rangé dans le JSON de la page, où chaqueés'écrité, comme au chapitre 2.04. La boucle cherche doncvotre solde, deux mots sans accent qui n'apparaissent que dans ce message.breakarrête la boucle au premier code trouvé.
La boucle tourne en silence, puis la console affiche :
Code trouvé : 1996
Sur la copie qui a servi à écrire ce chapitre, la boucle a essayé 1 997 codes, de 0000 à 1996, en 27 minutes et 41 secondes, environ 72 essais par minute. Mallory recharge la page : son solde est passé de 5,00 € à 30,00 €.

Chaque code faux a reçu la même redirection et le même message d'erreur. Aucun essai n'a été bloqué à cause du nombre de requêtes. Sans break, la boucle continuerait et trouverait aussi 4821 puis 7305. Au même rythme, essayer les dix mille codes prendrait environ 140 minutes, deux heures et vingt minutes : c'est un calcul, 10 000 divisé par 72, pas une mesure. Un serveur plus rapide, ou plusieurs boucles lancées ensemble, raccourcissent ce temps.
4. À vous : la boucle sur votre copie (5 min)
On n'attaque que sa propre copie, sur sa propre machine.
- Lancez
php artisan migrate:fresh --seed, puis connectez-vous comme Mallory,mallory@example.com, mot de passepassword. - Ouvrez
tamagotchi.test/magasin, puis l'onglet Console des outils de développement. - Collez la boucle de la section 3, en remplaçant
let i = 0; i < 10000parlet i = 1990; i < 2000. La boucle s'arrête au septième essai, sur1996, en quelques secondes. - Relevez la ligne
Code trouvé, puis rechargez la page et lisez le solde de Mallory.
Si Chrome refuse le collage et vous demande de taper allow pasting, tapez-le, puis collez à nouveau.
5. La correction : compter les essais
Au-delà de cinq essais dans la fenêtre d'une minute, le serveur doit répondre 429 sans appeler le contrôleur. Vous avez vu l'outil au chapitre 1.20, en bonus du formulaire de contact. Cherchez deux minutes avant d'ouvrir la correction.
Ce que compte throttle
Le middleware tient un compteur de tentatives, rangé dans le cache de Laravel. Le premier chiffre de throttle:5,1 est le nombre de requêtes permises, le second la durée de la fenêtre en minutes. La fenêtre commence au premier essai, pas au début de la minute de l'horloge.
Le compteur appartient à quelqu'un. Le code du middleware, ThrottleRequests::resolveRequestSignature() dans vendor/laravel/framework, choisit la clé ainsi :
if ($user = $request->user()) {
return $this->formatIdentifier($user->getAuthIdentifier());
} elseif ($route = $request->route()) {
return $this->formatIdentifier($route->getDomain().'|'.$request->ip());
}
Pour une personne connectée, la clé est son identifiant. Pour un visiteur, c'est l'adresse IP, comme pour le formulaire de contact du chapitre 1.20. Les codes cadeau demandent une connexion, donc Mallory a son propre compteur, et Alice le sien. Le Tamagotchi range son cache dans la base, CACHE_STORE=database dans .env. Après cinq essais de Mallory, la table cache contient une ligne dont la clé se termine par da4b9237bacccdf19c0760cab7aec4a8359010b0, l'empreinte sha1 de son identifiant 2, avec la valeur 5.
La clé ne contient ni le nom ni l'adresse de la route : le middleware y ajoute seulement un préfixe, vide pour throttle:5,1. Deux routes qui porteraient chacune throttle:5,1 partageraient donc le même compteur pour une même personne. Ici, seule la route des codes cadeau l'utilise.
Au sixième essai
Quand le compteur a atteint la limite, le middleware répond lui-même par le statut 429 Too Many Requests, sans appeler le contrôleur. Le sixième code n'est même pas lu. Sur chaque réponse acceptée, il ajoute deux en-têtes. Dans l'onglet Réseau, sélectionnez la requête codes-cadeau, de méthode POST et de statut 302, puis ouvrez ses en-têtes de réponse. La requête magasin qui la suit est la redirection, et ne les porte pas. Relevés sur les cinq premiers essais :
x-ratelimit-limit: 5
x-ratelimit-remaining: 4 puis 3, 2, 1, 0
X-RateLimit-Remaining dit combien d'essais il reste dans la fenêtre. Sur la réponse 429, Laravel ajoute Retry-After, le nombre de secondes à attendre, et X-RateLimit-Reset, l'heure de fin de la fenêtre. Ces deux en-têtes se voient sur la page d'erreur de Laravel. Le Tamagotchi, lui, rend sa propre page 429 par Inertia::handleExceptionsUsing(), comme votre portfolio depuis le chapitre 1.20, et cette page ne les reprend pas.

Ce que le kit limite déjà
Vous n'avez rien écrit pour la page de connexion, et pourtant elle est protégée. Fortify déclare ses limites dans config/fortify.php :
'limiters' => [
'login' => 'login',
'two-factor' => 'two-factor',
'passkeys' => 'passkeys',
],
Chaque nom renvoie à un limiteur défini dans app/Providers/FortifyServiceProvider.php. Voici celui de la connexion :
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
Limit::perMinute(5) permet cinq essais par minute. by() choisit la clé du compteur : l'adresse e-mail tapée et l'adresse IP. Fortify place ensuite throttle:login sur sa route POST /login. Rejoué avec cinq mauvais mots de passe pour mallory@example.com, la page répond « Ces identifiants ne correspondent pas à nos enregistrements. » cinq fois, puis le sixième essai reçoit 429. Le code de double authentification a le même plafond, cinq par minute, et les clés d'accès dix.
6. La preuve
Le fichier de la faille est tests/Feature/Flaws/Flaw10ThrottleTest.php. Son test envoie cinq codes faux, puis un sixième qui doit recevoir 429. Avant la correction :
php artisan test --filter=Flaw10
FAIL Tests\Feature\Flaws\Flaw10ThrottleTest
⨯ the sixth gift code tried within a minute is refused 0.51s
──────────────────────────────────────────────────────────────────────────────
FAILED Tests\Feature\Flaws\Flaw10ThrottleTest > the sixth gift code tried within a minute is refused
Expected response status code [429] but received 302.
Failed asserting that 302 is identical to 429.
The following errors occurred during the last request:
Ce code est inconnu ou déjà utilisé.
…
Tests: 1 failed (11 assertions)
Le sixième essai a été traité comme les autres : redirection, et message d'erreur. Après la correction :
PASS Tests\Feature\Flaws\Flaw10ThrottleTest
✓ the sixth gift code tried within a minute is refused 0.71s
Tests: 1 passed (11 assertions)
C'était la dernière faille. php artisan test est entièrement vert :
Tests: 89 passed (368 assertions)
Et pour la première fois, composer test passe de bout en bout : Pint, PHPStan, puis les 89 tests.
composer test
INFO Configuration cache cleared successfully.
…
PASS ........................................................... 100 files
…
[OK] No errors
…
Tests: 89 passed (368 assertions)
Duration: 10.81s
Rejouez enfin l'attaque. L'exercice de la section 4 a crédité le solde et utilisé le code 1996 : lancez d'abord php artisan migrate:fresh --seed, reconnectez-vous comme Mallory et vérifiez que son solde affiche 5,00 €. Collez ensuite la boucle courte, de 1990 à 2000, qui relit le jeton de la nouvelle session. Les cinq premiers essais reçoivent leur réponse habituelle. À partir de 1995, chaque requête reçoit 429, et la console de Chrome affiche Failed to load resource: the server responded with a status of 429 (Too Many Requests). Le code 1996 arrive au septième essai : il est refusé sans être lu, et le solde de Mallory reste à 5,00 €.
Refaire le calcul
La limite n'empêche pas d'essayer. Elle ralentit. La boucle de la section 3 ne suffit plus : après un 429, elle passe au code suivant sans l'avoir essayé. Une attaquante patiente l'adapterait pour attendre la fin de chaque fenêtre et réessayer le code refusé. Le calcul suppose cette boucle-là. À cinq essais par minute, 1996, au 1 997e essai, arrive après 400 minutes, six heures et quarante minutes. Les dix mille codes demandent deux mille minutes, 33 heures et 20 minutes.
Trouver 1996 prenait 28 minutes sans limite, et demande 6 h 40 avec elle. Essayer les dix mille codes passe d'environ 2 h 20 à 33 h 20. La limitation rend l'attaque lente. Elle a une limite, que le schéma montre aussi : le compteur suit le compte. Une attaquante qui ouvre dix comptes a dix compteurs. Un vrai code cadeau compte donc plus de quatre chiffres, et un vrai site ajoute d'autres freins, comme la seconde preuve que 23andMe n'exigeait pas.
7. La règle
Recopiez cette phrase dans votre carnet des failles, avec l'endroit de votre portfolio où elle s'applique, par exemple le formulaire de contact du chapitre 1.20.
Toute route qui vérifie un secret ou qui envoie quelque chose reçoit un
throttle. Une limite ralentit les essais, et son efficacité dépend de la longueur du secret et des moyens de l'attaquant.
À retenir
- Un code de quatre chiffres n'a que dix mille valeurs, et une boucle de
fetchdans la console les essaie toutes. throttle:5,1sur une route accepte cinq requêtes par fenêtre d'une minute, puis répond 429 sans appeler le contrôleur.- Le compteur suit l'identifiant de la personne connectée, ou l'adresse IP d'un visiteur.
- Fortify limite déjà la connexion à cinq essais par minute pour une même adresse e-mail et une même adresse IP.
- La limitation ne bloque pas l'attaque, elle la ralentit, et un secret plus long ou une seconde preuve d'identité la freinent encore.
Pour aller plus loin
- Connor Jones, « 23andMe hit with £2.3M fine after exposing genetic data of millions », The Register, 17 juin 2025, https://www.theregister.com/2025/06/17/23andme_ico_fine/
- « Data breach at 23andMe is a reminder to all organizations to prioritize privacy », Commissariat à la protection de la vie privée du Canada, 17 juin 2025, https://www.priv.gc.ca/en/opc-news/news-and-announcements/2025/nr-c_250617/
- « Rate Limiting », documentation de Laravel 13.x : https://laravel.com/docs/13.x/routing#rate-limiting