Un champ de trop dans le formulaire : l'assignation de masse
Temps 2 · séance 1 · ~25 min
Les slides du chapitre (PDF)PDF
- Expliquer pourquoi un champ ajouté dans la page arrive jusqu'à la base.
- Ajouter un champ à un formulaire depuis l'onglet Éléments et lire la requête envoyée.
- Remplacer
$request->all()par$request->validated()et réduire#[Fillable]aux champs d'un formulaire. - Modifier une colonne sensible par une affectation, sans passer par
update(). - Prouver la correction par les tests de la faille et par l'attaque rejouée.
1. Une clé ajoutée au mauvais compte
Le 4 mars 2012, le développeur Egor Homakov modifie sa copie du formulaire où GitHub enregistre les clés SSH, les clés qui autorisent un ordinateur à pousser du code sur un compte. Il y glisse un champ caché, public_key[user_id], qui vaut 4223, le numéro du compte du projet Rails. Le serveur enregistre tout ce qu'il reçoit : sa clé est rattachée au compte de Rails, et il pousse un fichier dans le dépôt de Rails sans en avoir le droit. GitHub corrige la faille un peu plus d'une heure plus tard, relève trois comptes touchés et nomme la cause : des paramètres de formulaire que le serveur ne contrôlait pas.
2. Ce que Mallory peut s'offrir
Chaque joueur a une devise, une courte phrase affichée en tête de son enclos. Il la change dans un formulaire d'un seul champ, « Ma devise ». Voici la méthode qui reçoit ce formulaire, dans app/Http/Controllers/EnclosureController.php :
public function update(UpdateEnclosureRequest $request): RedirectResponse
{
$request->user()->update($request->all());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Enclos mis à jour.']);
return to_route('enclosures.show', $request->user());
}
$request->all() renvoie toutes les données reçues, sous forme de tableau, quelles que soient les règles. update() copie ce tableau dans les colonnes du joueur connecté. Remplir plusieurs colonnes d'un modèle d'un coup, à partir d'un tableau, s'appelle l'assignation de masse. Vous l'utilisez depuis 5XCOS avec create() et update().
Laravel ne recopie pas n'importe quelle clé. Il ne garde que les colonnes listées dans l'attribut #[Fillable] du modèle. Voici celui de app/Models/User.php :
#[Fillable(['name', 'email', 'password', 'motto', 'balance'])]
balance est le solde du joueur, en euros du jeu. La colonne est dans la liste parce que le magasin et les codes cadeau changent le solde avec update(['balance' => …]), et que cet appel ne marche qu'avec une colonne autorisée.
Le développeur a pourtant écrit une Form Request, app/Http/Requests/UpdateEnclosureRequest.php :
public function rules(): array
{
return [
'motto' => ['nullable', 'string', 'max:120'],
];
}
Il a raisonné ainsi : le formulaire n'a qu'un champ, et ce champ est validé. Mais une règle de validation vérifie les clés qu'elle nomme, et laisse les autres passer sans les regarder. La requête peut contenir balance, name ou n'importe quoi d'autre. all() les rend toutes, et #[Fillable] en accepte cinq.
Ce que Mallory obtient : le solde qu'elle veut, donc tout le magasin, et les derniers objets en stock que les autres joueurs ne pourront plus acheter. Elle peut aussi prendre le nom d'un autre joueur, puisque name est dans la liste.
3. L'attaque, pas à pas
Mallory se connecte avec mallory@example.com et le mot de passe password. L'en-tête de la page affiche « SOLDE 5 € ». Elle clique sur « 02 Enclos », qui ouvre son propre enclos, /enclos/2, avec le formulaire « Ma devise » à droite.
Elle ouvre les outils de développement avec F12, puis l'onglet Éléments. Le formulaire de la devise s'y lit ainsi, coupé à l'essentiel :
<form action="/enclos?_method=PATCH" method="post">
<label for="motto">Ma devise</label>
<input id="motto" name="motto">
<button type="submit"> Enregistrer </button>
</form>
Elle fait un clic droit sur la ligne <button type="submit">, choisit « Edit as HTML », et ajoute une ligne juste après </button>, avant </form> :
<button type="submit"> Enregistrer </button>
<input name="balance" value="1000">
Le nouveau champ apparaît aussitôt dans la page, sous le bouton, sans bordure :

Elle clique sur « Enregistrer ». Au chapitre 1.12, vous avez vu que <Form> lit au moment de l'envoi tous les champs du formulaire par leur name. Il ne sait pas quels champs vous avez écrits dans Show.vue : il lit ceux qui sont dans la page à cet instant. Le champ ajouté part donc avec la devise. Dans l'onglet Réseau, la requête enclos?_method=PATCH montre dans son onglet « Charge utile » :
POST /enclos?_method=PATCH
Content-Type: application/json
{"motto":"Je regarde, c'est tout.","balance":"1000"}
Le serveur répond par une redirection vers l'enclos :
Réponse : 303
Location: http://…/enclos/2
Après un PATCH, le middleware d'Inertia change le code 302 de Laravel en 303, pour que la redirection soit suivie avec un GET. Inertia suit la redirection et met à jour les props de la page, et la notification « Enclos mis à jour. » s'affiche. L'en-tête dit maintenant « SOLDE 1000 € ».

La base le confirme :
php artisan tinker --execute="echo App\Models\User::find(2)->balance;"
1000
Aucune console, aucun outil en plus : un clic droit dans l'onglet Éléments a suffi.
4. À vous : refaites l'attaque
Sur votre copie, sur votre machine, et nulle part ailleurs.
- Lancez
php artisan migrate:fresh --seedpour repartir des comptes du seeder. - Connectez-vous comme Mallory,
mallory@example.com, mot de passepassword, et ouvrez « 02 Enclos ». - Dans l'onglet Éléments, ajoutez
<input name="balance" value="1000">juste après le</button>du formulaire de la devise. - Ouvrez l'onglet Réseau, puis cliquez sur « Enregistrer ».
- Lisez la charge utile de la requête
enclos?_method=PATCH: deux clés. - Relevez le solde de l'en-tête. Essayez aussi un champ
namequi vautAlice, et regardez le titre de l'enclos.
Si le solde ne bouge pas, vérifiez que le champ ajouté se trouve bien entre <form …> et </form> : un champ hors de la balise n'est pas lu.
5. La correction
Vous devez obtenir deux choses.
- Le formulaire de la devise ne change que la devise, quoi que la requête contienne d'autre.
- Le solde ne peut plus venir d'aucun formulaire, même par erreur dans un autre contrôleur.
Cherchez dans EnclosureController@update et dans le #[Fillable] de User. Au chapitre 1.12, la méthode store() des projets ne passait jamais all() : elle passait $request->safe()->except('tags'), et $request->validated('tags', []). Une Form Request rend les données validées par validated(), appelée sans argument. Tinker montre ce qu'elle garde, avec le Validator de Laravel, qui applique des règles à un tableau comme le fait une Form Request :
php artisan tinker --execute="echo json_encode(Validator::make(['motto' => 'Bonjour', 'balance' => '1000'], ['motto' => ['nullable', 'string', 'max:120']])->validated());"
{"motto":"Bonjour"}
validated() ne rend que les clés que les règles nomment. balance n'a pas de règle, et il disparaît.
Retirer balance de #[Fillable] a une conséquence. Deux contrôleurs changent le solde avec update(['balance' => …]). Sans la colonne dans la liste, Laravel ignore la clé, sans erreur ni message. Pour le voir, retirez d'abord balance de #[Fillable] dans app/Models/User.php, puis lancez cette commande sur Alice, qui a 120 €. La première ligne affiche ce que renvoie update(), la seconde le solde relu.
php artisan tinker --execute="var_export(App\Models\User::find(1)->update(['balance' => 1000])); echo PHP_EOL, App\Models\User::find(1)->balance;"
true
120
update() répond true, et le solde n'a pas bougé. Un achat ne coûterait plus rien, et un code cadeau ne créditerait plus rien. Les tests du jeu restent verts dans ce cas, car aucun ne relit le solde. Une affectation, $user->balance = …, suivie de save(), ne passe pas par #[Fillable] : c'est ainsi que le code du serveur change une colonne qu'aucun formulaire ne doit remplir.
Cherchez dix minutes, puis ouvrez la correction.
Le schéma ne montre que les deux clés du formulaire. all() contient aussi _method, lu dans l'adresse /enclos?_method=PATCH, que #[Fillable] écarte.
Chacune des deux corrections bloque le solde. Seul validated() arrête aussi le champ name : avec all() et balance retiré de #[Fillable], un champ name qui vaut Alice renomme encore Mallory, et le test de la faille reste rouge. Les deux corrections se complètent. validated() protège ce formulaire-ci. #[Fillable] protège toutes les méthodes qui passeront un jour un tableau reçu à update() ou à create(), y compris celles que vous n'avez pas encore écrites.
6. La preuve
Le test de la faille est dans tests/Feature/Flaws/Flaw03MassAssignmentTest.php. Il envoie la devise avec balance, name et email_verified_at, puis vérifie que seule la devise a changé. Avant la correction :
php artisan test --filter=Flaw03
FAIL Tests\Feature\Flaws\Flaw03MassAssignmentTest
⨯ extra fields sent with the motto are ignored 0.66s
────────────────────────────────…
FAILED Tests\Feature\Flaws\Flaw03MassAssignmentTest > extra fields sent with the motto are ignored
Failed asserting that two arrays are identical.
…
- 'name' => 'Mallory',
+ 'name' => 'Alice',
…
- 'balance' => 50.0,
+ 'balance' => 1000.0,
]
…
Tests: 1 failed (3 assertions)
Le joueur du test a pris le nom d'Alice et un solde de 1000. Après la correction :
PASS Tests\Feature\Flaws\Flaw03MassAssignmentTest
✓ extra fields sent with the motto are ignored
Tests: 1 passed (3 assertions)
Lancez ensuite toute la suite avec php artisan test. Le tableau de bord passe de 19 tests rouges à 18, et les tests de tests/Feature/Game restent verts.
Rejouez enfin l'attaque. La correction ne rend pas à Mallory son nom ni son solde d'avant : repartez du seeder.
- Lancez
php artisan migrate:fresh --seed, puis reconnectez-vous comme Mallory. - Vérifiez que l'enclos s'appelle « Mallory » et que l'en-tête affiche « SOLDE 5 € ».
- Ajoutez de nouveau
<input name="balance" value="1000">après le</button>, puis cliquez sur « Enregistrer ».
La requête porte toujours "balance":"1000", et la notification « Enclos mis à jour. » s'affiche. Mais le solde reste à 5 € :

Un champ name qui vaut Alice ne renomme plus Mallory. Vérifiez enfin les deux affectations, puisque les tests du jeu ne relisent pas le solde. Au magasin, achetez un « Bonbon » pour Encre : l'en-tête passe de « SOLDE 5 € » à « SOLDE 4.9 € ». Utilisez ensuite le code cadeau 4821 : il passe à « SOLDE 9.9 € ». Le point au lieu de la virgule sera corrigé au chapitre 2.07.
7. La règle
Un contrôleur ne passe à create() ou à update() que des données validées, jamais $request->all(), et #[Fillable] ne liste que des champs qu'un formulaire a le droit de remplir.
Les valeurs que le serveur calcule lui-même s'ajoutent à ces données, comme la position d'un projet au chapitre 1.12.
Recopiez-la dans votre carnet des failles. Puis ouvrez le ProjectController de votre portfolio : cherchez all(), et relisez le #[Fillable] de Project.
Le chapitre 2.04 regarde l'autre sens du trajet : ce que le serveur envoie au navigateur.
À retenir
$request->all()rend tout ce que la requête contient, y compris un champ ajouté dans l'onglet Éléments.<Form>envoie tous les champs présents dans le formulaire au moment du clic, pas seulement ceux du fichier.vue.- Une règle de validation vérifie les clés qu'elle nomme, et
validated()ne rend que celles-là. #[Fillable]ne liste que les colonnes qu'un formulaire peut remplir, et une colonne sensible se change par affectation suivie desave().update()ignore sans erreur une clé absente de#[Fillable], donc retirer une colonne de la liste oblige à relire chaqueupdate()qui l'utilisait.
Pour aller plus loin
- « Public Key Security Vulnerability and Mitigation », GitHub Blog, 4 mars 2012 : https://github.blog/news-insights/public-key-security-vulnerability-and-mitigation/
- « Hacking Rails (and GitHub) », Chris Shiflett, 5 mars 2012 : https://shiflett.org/blog/2012/hacking-rails-and-github
- « API3:2023 Broken Object Property Level Authorization », OWASP API Security Top 10, édition 2023 : https://api-security.owasp.org/editions/2023/en/0xa3-broken-object-property-level-authorization