Le prix ne vient jamais du navigateur
Temps 2 · séance 2 · ~30 min
- Expliquer pourquoi un champ caché, un attribut
minoumaxne protège rien. - Payer un article un centime et se faire créditer par une quantité négative, sur sa copie.
- Calculer un total avec le prix lu dans la base, jamais avec un prix reçu.
- Borner la quantité par une règle de validation et afficher le message d'erreur.
- Prouver la correction par les tests de la faille et par l'attaque rejouée.
1. Un champ caché n'est caché que pour l'œil
Un champ <input type="hidden"> ne s'affiche pas dans la page. Il est pourtant dans le HTML, l'onglet Éléments le montre, et il part avec le formulaire comme les autres champs. Le navigateur le cache à l'œil, pas à la personne qui ouvre les outils de développement.
Mettre le prix d'un article dans un tel champ est un classique des boutiques en ligne. MITRE tient un catalogue des défauts de logiciel, le CWE. Sa fiche CWE-472 décrit une application qui croit qu'un paramètre ne peut pas changer alors que le visiteur le contrôle. Parmi les failles réelles qu'elle cite, plusieurs paniers d'achat de l'année 2000 laissaient changer le prix par un champ caché. L'OWASP est une fondation qui publie des guides sur la sécurité des applications web. Sa page sur la falsification de paramètres prend le même exemple : un coût de 70.00 dans un champ caché, qu'un attaquant baisse avant d'envoyer le formulaire.
Le magasin du Tamagotchi fait la même chose.
2. Ce que Mallory peut s'offrir
Ouvrez resources/js/pages/shop/Index.vue. Chaque article du magasin a son formulaire d'achat. Il commence ainsi :
<Form
v-bind="PurchaseController.store.form(item.id)"
v-slot="{ errors, processing }"
class="flex flex-col gap-3 border-t-4 border-border bg-muted p-4"
>
<input
type="hidden"
name="price_cents"
:value="item.price_cents"
/>
Viennent ensuite la liste « Pour », qui choisit la créature, et le champ « Quantité » :
<Input
:id="`quantity-${item.id}`"
name="quantity"
type="number"
min="1"
default-value="1"
class="font-mono"
/>
Le formulaire envoie donc trois clés : creature_id, quantity et price_cents, le prix d'un exemplaire en centimes depuis le chapitre 2.07. Voici le début de la méthode qui les reçoit, dans app/Http/Controllers/PurchaseController.php :
public function store(Request $request, Item $item): RedirectResponse
{
$user = $request->user();
$creature = Creature::findOrFail($request->integer('creature_id'));
$quantity = $request->integer('quantity', 1);
$price = $request->integer('price_cents');
$total = $price * $quantity;
if ($item->stock < $quantity) {
return back()->withErrors(['quantity' => 'Il n\'en reste pas assez en stock.']);
}
if ($user->balance_cents < $total) {
return back()->withErrors(['quantity' => 'Votre solde est insuffisant.']);
}
$item->update(['stock' => $item->stock - $quantity]);
$user->balance_cents -= $total;
$user->save();
// … l'achat est écrit, puis la jauge de la créature monte
Le total vient entièrement de la requête : le prix comme la quantité. L'article $item est pourtant chargé depuis la base par la liaison de modèle, avec son vrai prix. Le contrôleur ne le lit pas.
Ce que Mallory obtient tient en deux gestes. Elle choisit le prix qu'elle paie. Elle envoie une quantité négative, et le calcul la paie au lieu de la débiter. Le stock du magasin, partagé par tous les joueurs, se vide ou gonfle à sa guise.
Le développeur ne l'a pas vu, parce que la page ne permet rien de tout cela. Le prix est caché, et le champ « Quantité » refuse un nombre inférieur à 1. En jouant normalement, chaque achat est juste. Mais ces deux protections vivent dans le navigateur de Mallory, et elle peut les modifier.
3. L'attaque, pas à pas
Mallory se connecte avec mallory@example.com et le mot de passe password, sur la base fraîchement repeuplée. L'en-tête affiche « SOLDE 5,00 € ». Elle clique sur « 04 Magasin ».
Un centime le Coussin doré
Le Coussin doré coûte 19,90 €, et il n'en reste qu'un. Mallory ouvre les outils de développement avec F12, puis l'onglet Éléments. Le formulaire du Coussin doré s'y lit ainsi, coupé à l'essentiel :
<form action="/magasin/7/acheter" method="post">
<input type="hidden" name="price_cents" value="1990">
<select id="creature-7" name="creature_id">
<option value="3">Encre</option>
</select>
<input id="quantity-7" name="quantity" type="number" min="1">
<button type="submit"> Acheter </button>
</form>
Elle fait un clic droit sur la ligne du champ caché, choisit « Edit as HTML », comme au chapitre 2.03, et remplace 1990 par 1 :
<input type="hidden" name="price_cents" value="1">
Rien ne change à l'écran : l'étiquette affiche toujours 19,90 €. Elle clique sur « Acheter ». Dans l'onglet Réseau, la requête acheter montre dans son onglet « Charge utile » :
POST /magasin/7/acheter
Content-Type: application/json
{"price_cents":"1","creature_id":"3","quantity":"1"}
Le serveur répond par une redirection vers le magasin :
Réponse : 302
Location: http://…/magasin
La notification « Achat effectué pour Encre. » s'affiche. L'en-tête dit « SOLDE 4,99 € », et le Coussin doré est marqué « Épuisé ».

Moins cinq croquettes
Le champ « Quantité » porte min="1". Mallory y tape -5 et clique sur « Acheter ». Le navigateur bloque l'envoi et affiche une bulle sous le champ : « Cette valeur doit être supérieure ou égale à 1. ». Aucune requête ne part.
Cette vérification est faite par le navigateur, à partir de l'attribut. Dans l'onglet Éléments, Mallory fait un clic droit sur la ligne du champ des Croquettes, choisit « Edit as HTML » et efface min="1" :
<input id="quantity-2" name="quantity" type="number">
Elle tape -5, puis clique sur « Acheter ». Cette fois, la requête part :
POST /magasin/2/acheter
Content-Type: application/json
{"price_cents":"250","creature_id":"3","quantity":"-5"}
La réponse est la même redirection, avec la même notification. L'en-tête affiche « SOLDE 17,49 € », et les Croquettes passent de « 50 en stock » à « 55 en stock ».

La base garde les deux achats. Tinker les montre, sans les dates :
php artisan tinker --execute="echo App\Models\Purchase::all(['item_id', 'quantity', 'total_cents'])->toJson();"
[{"item_id":7,"quantity":1,"total_cents":1},{"item_id":2,"quantity":-5,"total_cents":-1250}]
Le premier achat a coûté 1 centime, le second a rapporté 12,50 €. Le solde de Mallory vaut 1749 centimes, et la jauge de nourriture d'Encre est descendue sous zéro :
php artisan tinker --execute="echo App\Models\Creature::find(3)->food;"
-70
Une jauge va de 0 à 100. Le contrôleur d'achat la plafonne à 100 avec min(), mais rien ne l'empêche de descendre sous zéro.
Le schéma refait le calcul du contrôleur avec ces nombres. Après le Coussin doré, Mallory avait 499 centimes, et Encre 30 points de nourriture.
Une quantité énorme
Avec 1000000 dans le champ, le serveur répond « Il n'en reste pas assez en stock. » sous le champ. Le contrôle du stock a arrêté l'achat, mais par chance : il compare le stock à la quantité, pas la quantité à une limite. Avec -1000000, ce même contrôle laisse passer. L'en-tête affiche alors « SOLDE 2 500 017,49 € », et les Croquettes « 1000055 en stock ».
Aucune console, aucun outil en plus : l'onglet Éléments a suffi pour les deux attaques.
4. À vous : refaites l'attaque (5 min)
Sur votre copie, sur votre machine, et nulle part ailleurs.
- Lancez
php artisan migrate:fresh --seed, connectez-vous comme Mallory et ouvrez « 04 Magasin ». - Ouvrez l'onglet Réseau. Dans l'onglet Éléments, changez la valeur du champ
price_centsdu Coussin doré en1, puis achetez-le. Relevez la charge utile et le solde de l'en-tête. - Tapez
-5dans la quantité des Croquettes et cliquez sur « Acheter » : notez ce que fait le navigateur. - Retirez
min="1"de ce champ dans l'onglet Éléments, puis achetez −5 croquettes. Relevez le solde et le stock. - Lancez la commande Tinker de la section 3 et lisez les deux lignes d'achat.
Relancez php artisan migrate:fresh --seed à la fin.
5. La correction
Vous devez obtenir quatre choses.
- Le total se calcule avec le prix de l'article lu dans la base. Un
price_centsenvoyé par la requête n'est plus lu. - La quantité est un entier de 1 à 10. Toute autre valeur est refusée avec un message sous le champ, sans rien débiter.
- Le formulaire n'envoie plus
price_cents. La page reçoit seulementprice, le texte formaté pour l'affichage. - Le champ « Quantité » porte
max="10", pour que le navigateur aide le joueur honnête.
Un dernier test de la faille vérifie aussi que Mallory ne peut acheter que pour ses propres créatures. Avant la correction, une requête avec creature_id à 1, Braise, est acceptée. Mallory paie ses croquettes, la notification dit « Achat effectué pour Braise. », et la nourriture de la créature d'Alice monte à 100.
Cherchez d'abord dans PurchaseController@store. La méthode reçoit une Request ordinaire, pas une Form Request. $request->validate() y applique des règles directement : elle prend un tableau de règles, comme rules(), renvoie les données validées, et redirige avec les erreurs si une règle échoue. Les règles integer, min et max existent dans la documentation de Laravel, page « Validation ». Pour la créature, cherchez la règle exists dans la même page, et la façon d'y ajouter une condition where.
Cherchez ensuite dans ShopController@index, qui envoie price_cents à la page, et dans shop/Index.vue. <InputError> affiche déjà errors.quantity. Une erreur sur creature_id doit s'afficher au même endroit.
Lancez les tests de la faille après le premier changement. Quand le contrôleur lit le prix de l'article dans la base, le premier test passe au vert et les quatre autres restent rouges :
Tests: 4 failed, 2 passed (12 assertions)
Cherchez dix minutes, puis ouvrez la correction.
Rule::exists('creatures', 'id') vérifie qu'une ligne de la table creatures porte cet id. ->where('user_id', $user->id) ajoute une condition : la ligne doit appartenir au joueur connecté. Le contrôle d'accès du chapitre 2.02 se retrouve ici dans une règle de validation.
La règle integer passe avant, car une valeur reçue peut être un tableau. Avec {"creature_id":["3"]}, exists vérifie Encre, puis $request->integer() convertit le tableau en 1, l'identifiant de Braise. Sans integer, l'achat est donc payé par Mallory et profite à Braise. Avec la règle, la requête est refusée par « Le champ créature doit être un entier. ».
Les attributs de la page sont du confort
Après la correction, la page porte min="1" et max="10". Ils servent le joueur honnête : il voit son erreur avant d'envoyer quoi que ce soit. Ils ne protègent rien, car un clic droit dans l'onglet Éléments les retire. hidden, disabled et readonly sont dans le même cas. C'est l'idée du lien masqué du chapitre 2.02, appliquée à un champ.
La protection est la règle 'min:1', 'max:10' dans le contrôleur. Elle tourne sur le serveur, à chaque requête, quelle que soit la page qui l'a envoyée. Voici les messages relevés sous le champ, attributs retirés :
| Quantité envoyée | Message affiché |
|---|---|
-5 |
La valeur de quantité doit être supérieure ou égale à 1. |
0 |
La valeur de quantité doit être supérieure ou égale à 1. |
11 |
La valeur de quantité ne peut pas être supérieure à 10. |
6. La preuve
Le test de la faille est dans tests/Feature/Flaws/Flaw08ClientPriceTest.php. Avant la correction :
php artisan test --filter=Flaw08
FAIL Tests\Feature\Flaws\Flaw08ClientPriceTest
⨯ a price sent by the browser is ignored 0.74s
⨯ the quantity is a whole number from 1 to 10 with (-5) 0.04s
⨯ the quantity is a whole number from 1 to 10 with (0) 0.05s
✓ the quantity is a whole number from 1 to 10 with (11) 0.06s
⨯ the quantity is a whole number from 1 to 10 with ('beaucoup') 0.05s
⨯ a player only buys for their own creature 0.05s
────────────────────────────────…
FAILED Tests\Feature\Flaws\Flaw08ClientPriceTest > a price sent by the browser is ignored
Failed asserting that 4999 is identical to 3010.
…
Tests: 5 failed, 1 passed (11 assertions)
Le joueur du test a 50 € et achète un article à 19,90 € en envoyant un prix de 1 centime. Il lui reste 49,99 € au lieu de 30,10 €. Le cas 11 passe au vert sans correction : l'article du test n'a que 5 exemplaires en stock, et le contrôle du stock refuse l'achat par chance, comme la quantité énorme de la section 3. Après la correction :
PASS Tests\Feature\Flaws\Flaw08ClientPriceTest
✓ a price sent by the browser is ignored 0.87s
✓ the quantity is a whole number from 1 to 10 with (-5) 0.06s
✓ the quantity is a whole number from 1 to 10 with (0) 0.08s
✓ the quantity is a whole number from 1 to 10 with (11) 0.08s
✓ the quantity is a whole number from 1 to 10 with ('beaucoup') 0.07s
✓ a player only buys for their own creature 0.05s
Tests: 6 passed (27 assertions)
Lancez ensuite toute la suite avec php artisan test. Le tableau de bord passe de 8 tests rouges à 2, et les tests de tests/Feature/Game restent verts :
Tests: 2 failed, 87 passed (365 assertions)
Six tests sont passés au vert, et Flaw08 n'en compte que cinq. Le sixième est « a player cannot spend more than their balance », dans Flaw09AtomicPurchaseTest.php. Il n'envoie aucun prix : avant la correction, le contrôleur calculait donc un total nul et laissait passer l'achat. Avec le prix lu dans la base, le solde insuffisant est refusé.
Rejouez enfin l'attaque. Le champ caché du prix n'existe plus : ajoutez-le vous-même dans le formulaire du Coussin doré, par « Edit as HTML », juste après la balise <form …> :
<input type="hidden" name="price_cents" value="1">
La requête porte toujours le prix trafiqué :
POST /magasin/7/acheter
Content-Type: application/json
{"price_cents":"1","creature_id":"3","quantity":"1"}
Le serveur ne lit plus cette clé. Il calcule 1990 centimes, et Mallory n'a que 5 € :

Retirez ensuite min et max du champ des Croquettes, puis envoyez -5. La requête part avec {"creature_id":"3","quantity":"-5"}, et la règle la refuse. Le solde reste à 5,00 €, et le stock à 50 :

7. La règle
Recopiez cette phrase dans votre carnet des failles, avec l'endroit de votre portfolio où elle s'applique :
Le navigateur dit quel article et combien, jamais ce que ça coûte : le serveur relit le prix dans sa base et borne chaque valeur reçue par une règle de validation.
Le chapitre 2.09 regarde le même achat sous un autre angle : deux achats en même temps, pour un seul solde.
À retenir
- Un champ
hiddenpart avec le formulaire, et sa valeur se change dans l'onglet Éléments. - Le contrôleur lit le prix sur le modèle chargé depuis la base,
$item->price_cents, et jamais dans la requête. - Une quantité négative transforme un débit en crédit, d'où la règle
'integer', 'min:1', 'max:10'sur le serveur. - Les attributs
min,max,hidden,disabledetreadonlyaident le joueur honnête et ne protègent rien. Rule::exists()avec une conditionwherevérifie qu'un identifiant reçu appartient bien au joueur connecté.
Pour aller plus loin
- « CWE-472: External Control of Assumed-Immutable Web Parameter », MITRE, CWE version 5.0 : https://cwe.mitre.org/data/definitions/472.html
- « Web Parameter Tampering », OWASP Foundation, page de la communauté OWASP, sans date : https://community.owasp.org/attacks/Web_Parameter_Tampering
- « Validation », documentation de Laravel 13.x : https://laravel.com/docs/13.x/validation