Aller au contenu

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 :

Le cadre Ma devise de l'enclos de Mallory : le champ de la devise avec Je regarde, c'est tout., le bouton Enregistrer, et dessous un champ sans bordure qui contient 1000

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"}

En haut, le formulaire de la devise affiche un seul champ, et Mallory ajoute input name balance value 1000 dans l'onglet Éléments. En bas, la requête envoyée, POST /enclos?_method=PATCH en JSON, porte deux clés, motto et balance

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 € ».

L'enclos de Mallory après l'envoi : l'en-tête affiche SOLDE 1000 €, le champ ajouté contient toujours 1000 sous le bouton Enregistrer, et la notification Enclos mis à jour. apparaît en bas à droite

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.

  1. Lancez php artisan migrate:fresh --seed pour repartir des comptes du seeder.
  2. Connectez-vous comme Mallory, mallory@example.com, mot de passe password, et ouvrez « 02 Enclos ».
  3. Dans l'onglet Éléments, ajoutez <input name="balance" value="1000"> juste après le </button> du formulaire de la devise.
  4. Ouvrez l'onglet Réseau, puis cliquez sur « Enregistrer ».
  5. Lisez la charge utile de la requête enclos?_method=PATCH : deux clés.
  6. Relevez le solde de l'en-tête. Essayez aussi un champ name qui vaut Alice, 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.

Dans app/Http/Controllers/EnclosureController.php, la méthode update() :

$request->user()->update($request->validated());

Dans app/Models/User.php, balance sort de la liste :

#[Fillable(['name', 'email', 'password', 'motto'])]

Dans app/Http/Controllers/GiftCodeController.php, la ligne $user->update(['balance' => …]) devient :

$user->balance += $giftCode->amount;
$user->save();

Dans app/Http/Controllers/PurchaseController.php, de même :

$user->balance -= $total;
$user->save();

La même requête, avec motto et balance, traitée deux fois. Avant : all() rend les deux clés, l'attribut Fillable accepte balance, et la table users reçoit un solde de 1000. Après : validated() ne rend que motto, l'attribut Fillable ne contient plus balance, et le solde reste à 5

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.

  1. Lancez php artisan migrate:fresh --seed, puis reconnectez-vous comme Mallory.
  2. Vérifiez que l'enclos s'appelle « Mallory » et que l'en-tête affiche « SOLDE 5 € ».
  3. 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 € :

L'enclos de Mallory après la même attaque, une fois la faille corrigée : le champ ajouté contient toujours 1000 sous le bouton Enregistrer, la notification Enclos mis à jour. s'affiche, mais l'en-tête dit SOLDE 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 de save().
  • update() ignore sans erreur une clé absente de #[Fillable], donc retirer une colonne de la liste oblige à relire chaque update() qui l'utilisait.

Pour aller plus loin