Aller au contenu

Changer le numéro dans l'adresse : le contrôle d'accès et la policy

Temps 2 · séance 1 · ~55 min

Les slides du chapitre (PDF)PDF

  • Distinguer l'authentification, qui établit qui vous êtes, de l'autorisation, qui décide de ce que vous avez le droit de faire.
  • Ouvrir, modifier et relâcher la créature d'un autre joueur en changeant un numéro dans l'adresse.
  • Écrire une policy et l'appeler avec Gate::authorize() dans chaque action d'un contrôleur.
  • Envoyer une permission en prop pour n'afficher un lien qu'au propriétaire.
  • Prouver la correction par les tests de la faille et par l'attaque rejouée.

1. Optus, 2022

En septembre 2022, un pirate a récupéré les données de clients de l'opérateur téléphonique australien Optus par une API. Une API est une interface qui permet à des programmes d'échanger des données : celle d'Optus répondait à des requêtes HTTP qui demandaient des dossiers de clients. Selon les analyses publiées, les numéros de clients se suivaient, et rien ne vérifiait que la personne qui demandait un dossier avait le droit de le lire. Le régulateur australien des communications a mis en cause une modification du code qui avait rendu un contrôle d'accès contournable. Le commissaire australien à l'information parle d'environ 9,5 millions de personnes touchées, dont 2,1 millions exposées au vol d'identité par leur permis de conduire ou leur passeport.

Dans le Tamagotchi, chaque créature a aussi un numéro. Ces numéros se suivent, et ils apparaissent dans l'adresse.

2. Deux questions, une seule posée

Au chapitre 1.10, le middleware auth a fermé le back-office de votre portfolio aux visiteurs. Le Tamagotchi l'utilise de la même façon. Toutes les routes du jeu sont dans ce groupe de routes/web.php :

Route::middleware(['auth'])->group(function () {
    Route::get('dashboard', [CreatureController::class, 'index'])->name('dashboard');

    Route::resource('creatures', CreatureController::class)->except(['index', 'show']);
    Route::post('creatures/{creature}/jouer', [CreatureController::class, 'play'])->name('creatures.play');
    Route::post('creatures/{creature}/dormir', [CreatureController::class, 'sleep'])->name('creatures.sleep');
    // … enclos, classement, magasin
});

Le middleware auth répond à une question : qui êtes-vous ? Il cherche un utilisateur dans la session et renvoie un visiteur inconnu vers /login. Cette vérification s'appelle l'authentification, et elle fonctionne.

Ouvrez maintenant app/Http/Controllers/CreatureController.php. Voici la méthode qui affiche la page de modification d'une créature :

public function edit(Creature $creature): Response
{
    return Inertia::render('creatures/Edit', [
        'creature' => $creature->only('id', 'name', 'species', 'food', 'mood', 'energy'),
    ]);
}

La liaison de modèle charge la créature dont le numéro est dans l'adresse, et la méthode l'envoie à la page. Rien ne compare le propriétaire de la créature avec la personne connectée. Une seconde question n'est jamais posée : avez-vous le droit ? Vérifier qu'une personne connectée peut faire une action précise sur une donnée précise s'appelle l'autorisation.

La même requête de Mallory, GET /creatures/1/modifier, pose deux questions. Qui êtes-vous ? L'authentification, par le middleware auth, répond Mallory : elle existe depuis le chapitre 1.10. Avez-vous le droit ? L'autorisation, par une policy, répondrait non, car Braise est à Alice : elle manque et s'écrit dans ce chapitre

Ce que Mallory obtient

update(), play(), sleep() et destroy() ont le même défaut que edit(). Mallory, connectée avec son propre compte, peut donc renommer la créature de n'importe quel joueur, jouer avec, la faire dormir et la relâcher pour de bon. Chaque joueur est touché, et la suppression ne se rattrape pas.

Le défaut ne se voit pas en jouant. L'interface ne montre jamais à Mallory de lien vers la page de modification d'une créature d'Alice. En testant avec son propre compte, le développeur ne voit que ses créatures et ses liens, et tout fonctionne. Mais un lien absent n'empêche personne de taper l'adresse.

3. L'attaque, pas à pas

Le chapitre 2.01 a présenté les trois comptes du seeder. Mallory est l'attaquante, Alice la victime. Les captures qui suivent montrent le compte de Mallory, sur la base fraîchement repeuplée.

Trouver un numéro

Sur sa page « Mon tamagotchi », Mallory clique sur « Modifier la fiche » de sa créature, Encre. L'adresse devient /creatures/3/modifier. Le numéro de la créature est donc dans l'adresse.

Depuis le classement, le nom d'Alice mène à son enclos, /enclos/1. Chaque vignette y porte le numéro de la créature, en haut à gauche de l'écran : Braise est la créature n° 001, Moustache la n° 002. Aucun lien « Modifier » n'apparaît sous les vignettes.

La section Pensionnaires de l'enclos d'Alice, vue par Mallory : deux vignettes, Braise n° 001, dragon joyeux, et Moustache n° 002, chat joyeux, chacune avec ses trois jauges, sans aucun lien sous les jauges

Changer le numéro

Dans la barre d'adresse, Mallory remplace 3 par 1 :

tamagotchi.test/creatures/3/modifier    Encre, la créature de Mallory
tamagotchi.test/creatures/1/modifier    Braise, la créature d'Alice

Le serveur répond 200, et la page de Braise s'affiche, avec « Renommer », la touche « Jouer » et « Relâcher Braise ». L'en-tête montre pourtant le solde de Mallory, 5 €, et son initiale.

La page de modification de Braise, fiche n° 001, ouverte par Mallory : son solde de 5 € et son initiale M dans l'en-tête, le boîtier avec la touche Jouer, le formulaire Renommer et le bouton Relâcher Braise

Renommer et jouer

Mallory tape « À Mallory » dans le champ « Nom » et clique sur « Enregistrer ». Dans l'onglet Réseau des outils de développement, la requête et la réponse sont celles d'une modification ordinaire :

POST /creatures/1?_method=PUT
{"name":"À Mallory"}
Réponse : 303, puis la page de modification

Le message « Créature renommée. » s'affiche, et le grand titre de la page devient « À MALLORY ».

La même page après l'envoi : le titre À MALLORY, le champ Nom qui contient À Mallory, et le message Créature renommée. en bas à droite

La touche « Jouer » du boîtier envoie à son tour une requête acceptée :

POST /creatures/1/jouer
Réponse : 302, retour à la page de modification

Faire dormir par la console

La page de modification n'a pas de touche « Dodo » : elle n'existe que sur la page « Mon tamagotchi », pour ses propres créatures. Il reste la route POST /creatures/{creature}/dormir. Dans l'onglet Console des outils de développement, Mallory tape ces deux lignes, puis Entrée :

const token = decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)[1]);
fetch('/creatures/1/dormir', { method: 'POST', headers: { 'X-XSRF-TOKEN': token } });

fetch() envoie une requête depuis la page, avec les cookies de la session de Mallory. La première ligne lit le jeton CSRF, une valeur que Laravel range dans le cookie XSRF-TOKEN et exige avec chaque envoi, pour vérifier que l'envoi part bien d'une page du site. Le composant <Form> d'Inertia l'ajoute tout seul, et le chapitre 2.11 explique son rôle. Sans cet en-tête, la même requête reçoit 419 et rien ne change.

Dans l'onglet Réseau, la requête POST dormir reçoit 302. fetch() suit cette redirection et reçoit ensuite la page de modification, avec un statut 200. Mallory recharge la page. Les jauges de Braise sont passées de 90, 95 et 80 à 75, 100 et 95 pour la nourriture, l'humeur et l'énergie, après une partie et une sieste qu'Alice n'a jamais demandées.

Relâcher

Mallory clique sur « Relâcher À Mallory », puis confirme dans la fenêtre de dialogue :

POST /creatures/1?_method=DELETE
Réponse : 303, puis /dashboard

Le message « Créature relâchée. » s'affiche sur sa propre page. Dans l'enclos d'Alice, Braise a disparu.

La section Pensionnaires de l'enclos d'Alice après l'attaque : il ne reste qu'une vignette, Moustache n° 002

Cinq routes laissent passer Mallory. route:list les montre, toutes avec un paramètre {creature} :

php artisan route:list --path=creatures
  POST            creatures ............................................... creatures.store › CreatureController@store
  GET|HEAD        creatures/nouvelle .................................... creatures.create › CreatureController@create
  PUT|PATCH       creatures/{creature} .................................. creatures.update › CreatureController@update
  DELETE          creatures/{creature} ................................ creatures.destroy › CreatureController@destroy
  POST            creatures/{creature}/dormir ............................. creatures.sleep › CreatureController@sleep
  POST            creatures/{creature}/jouer ................................ creatures.play › CreatureController@play
  GET|HEAD        creatures/{creature}/modifier ............................. creatures.edit › CreatureController@edit

create() affiche le formulaire d'adoption, et store() crée une créature pour la personne connectée. Ces deux actions ne visent aucune créature existante. Les cinq autres agissent sur la créature désignée par l'adresse. Le schéma suit la requête de Mallory. La première ligne est l'application que vous avez reçue. La seconde est celle que vous aurez à la fin de la section 5.

La requête GET /creatures/1/modifier de Mallory, deux fois. Sans policy : auth vérifie qu'elle est connectée, edit() ne vérifie rien d'autre, et la réponse est 200 avec la page de Braise. Avec la policy : edit() appelle Gate::authorize('update', $creature), CreaturePolicy::update() compare user_id 1 et l'identifiant 2 de Mallory et répond false, la réponse est 403 Accès refusé. Pour Alice, à la même adresse, la policy répond true et la page s'affiche

4. À vous : l'attaque sur votre copie (5 min)

On n'attaque que sa propre copie, sur sa propre machine.

  1. Lancez php artisan migrate:fresh --seed pour partir de la base du seeder, puis connectez-vous comme Mallory, mallory@example.com, mot de passe password.
  2. Relevez le numéro de Braise dans l'enclos d'Alice, puis ouvrez tamagotchi.test/creatures/1/modifier.
  3. Ouvrez l'onglet Réseau des outils de développement. Renommez Braise, puis cliquez sur « Jouer ». Relevez la méthode, l'adresse et le statut de chaque requête.
  4. Dans l'onglet Console, tapez les deux lignes de la section « Faire dormir par la console », puis rechargez la page et lisez l'énergie de Braise.
  5. Relâchez Braise. Connectez-vous ensuite comme Alice et regardez son enclos.

Relancez php artisan migrate:fresh --seed à la fin : Braise revient avec son nom et ses jauges.

5. La correction : une policy

L'idée

Avant de toucher à la créature, chaque action doit vérifier qu'elle appartient à la personne connectée. La table creatures a une colonne user_id, l'identifiant du propriétaire. La vérification tient donc en une comparaison : $creature->user_id === $request->user()->id. Écrite à la main dans une action qui reçoit $request, elle donnerait ceci :

if ($creature->user_id !== $request->user()->id) {
    abort(403);
}

abort(403) arrête la requête et répond « accès refusé ». Cette version fonctionne, mais la même règle serait copiée dans cinq méthodes. Le jour où la règle change, par exemple pour laisser un administrateur tout faire, il faut retrouver les cinq copies.

Laravel range ces règles dans une classe dédiée au modèle, une méthode par action. Cette classe s'appelle une policy. Les règles des créatures vivent alors dans un seul fichier : les quatre actions de modification consultent la méthode update(), et la suppression consulte delete().

Générer la policy

php artisan make:policy CreaturePolicy --model=Creature
   INFO  Policy […\app\Policies\CreaturePolicy.php] created successfully.

--model=Creature demande une méthode par action courante. Le fichier app/Policies/CreaturePolicy.php en contient sept : viewAny, view, create, update, delete, restore et forceDelete. Elles renvoient toutes false. Voici l'une d'elles :

/**
 * Determine whether the user can update the model.
 */
public function update(User $user, Creature $creature): bool
{
    return false;
}

update() et delete() reçoivent la personne connectée, puis la créature visée. viewAny() et create() ne reçoivent que la personne, car elles ne visent aucune créature précise. Chaque méthode renvoie true si l'action est permise, false sinon. Chaque méthode correspond à une capacité, le nom d'une action qu'on autorise ou non : update, delete.

Vous n'enregistrez la policy nulle part. Laravel la trouve par son nom : pour le modèle App\Models\Creature, il cherche une classe CreaturePolicy dans app/Models/Policies, puis dans app/Policies. La documentation appelle cela la découverte automatique des policies.

Lancez tout de suite les six tests de cette faille :

php artisan test --filter=Flaw02

Ils restent rouges, avec le même résultat qu'avant la policy :

  Tests:    5 failed, 1 passed (6 assertions)

Le fichier existe, mais aucun contrôleur ne le consulte. Une policy seule ne protège rien.

Consulter la policy : Gate::authorize()

La façade Gate est l'entrée de Laravel pour les autorisations. Sa méthode authorize() prend le nom d'une capacité et la donnée visée. Elle trouve la policy du modèle, appelle la méthode de ce nom avec la personne connectée, et lève une exception si la réponse est false. Laravel transforme cette exception en réponse 403. Voici edit() protégée :

public function edit(Creature $creature): Response
{
    Gate::authorize('update', $creature);

    return Inertia::render('creatures/Edit', [
        'creature' => $creature->only('id', 'name', 'species', 'food', 'mood', 'energy'),
    ]);
}

Ajoutez use Illuminate\Support\Facades\Gate; en tête du contrôleur. Gate::authorize() est la première ligne de la méthode : si la policy refuse, rien de ce qui suit ne tourne.

À ce stade, la policy générée renvoie encore false. Mallory est refusée, mais Alice aussi. Le dernier test de la faille vérifie justement que la propriétaire garde l'accès :

  ⨯ the owner still can
  …
  Expected response status code [200] but received 403.

Il reste à écrire la règle dans la policy.

Le trajet de POST /creatures/1/jouer dans Laravel : la route trouve CreatureController@play, le middleware auth reconnaît Mallory, la liaison de modèle charge Braise, puis la première ligne de play(), Gate::authorize('update', user, $creature). Si elle répond false, la réponse est 403 et play() s'arrête là. Si elle répond true, play() modifie les jauges

À vous : les cinq actions et le lien « Modifier » (15 min)

Ce que vous devez obtenir :

Action Capacité vérifiée Mallory sur Braise Alice sur Braise
edit() update 403 la page s'affiche
update() update 403, nom inchangé renommée
play() update 403, jauges inchangées jauges modifiées
sleep() update 403, jauges inchangées jauges modifiées
destroy() delete 403, Braise reste relâchée
  1. Dans CreaturePolicy, gardez update() et delete(), et supprimez les cinq autres méthodes. Chacune des deux compare le propriétaire de la créature avec la personne connectée.
  2. Appelez Gate::authorize() en première ligne des cinq actions, avec la capacité du tableau.
  3. Sur la page de l'enclos, affichez un lien « Modifier » sous chaque vignette, seulement si la personne connectée a le droit de modifier la créature.

Pour le point 3, le contrôleur calcule la permission et l'envoie en prop. Le modèle User a une méthode can(), qui consulte la même policy et renvoie true ou false sans rien lever. $request->user()->can('update', $creature) répond donc à la question pour une créature. EnclosureController::show() doit envoyer, pour chaque créature, une clé can qui contient update. Le composant EnclosureCreatureTile affiche dans son pied ce que vous placez entre ses balises, et la fonction edit de Wayfinder vient de @/routes/creatures.

Où chercher : la page « Authorization » de la documentation de Laravel, sections « Generating Policies », « Writing Policies », « Via the Gate Facade » et « Via the User Model ». Ouvrez la solution après dix minutes de recherche.

app/Policies/CreaturePolicy.php :

<?php

namespace App\Policies;

use App\Models\Creature;
use App\Models\User;

class CreaturePolicy
{
    /**
     * Rename the creature or play with it.
     */
    public function update(User $user, Creature $creature): bool
    {
        return $creature->user_id === $user->id;
    }

    /**
     * Release the creature.
     */
    public function delete(User $user, Creature $creature): bool
    {
        return $creature->user_id === $user->id;
    }
}

app/Http/Controllers/CreatureController.php, avec use Illuminate\Support\Facades\Gate; en tête. edit() est celle de la section précédente. Les quatre autres actions :

public function update(UpdateCreatureRequest $request, Creature $creature): RedirectResponse
{
    Gate::authorize('update', $creature);

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

    Inertia::flash('toast', ['type' => 'success', 'message' => 'Créature renommée.']);

    return to_route('creatures.edit', $creature);
}

public function destroy(Creature $creature): RedirectResponse
{
    Gate::authorize('delete', $creature);

    $creature->delete();

    Inertia::flash('toast', ['type' => 'success', 'message' => 'Créature relâchée.']);

    return to_route('dashboard');
}

public function play(Creature $creature): RedirectResponse
{
    Gate::authorize('update', $creature);

    $creature->update([
        'mood' => min(100, $creature->mood + 15),
        'food' => max(0, $creature->food - 10),
        'energy' => max(0, $creature->energy - 10),
    ]);

    return back();
}

public function sleep(Creature $creature): RedirectResponse
{
    Gate::authorize('update', $creature);

    $creature->update([
        'energy' => min(100, $creature->energy + 25),
        'food' => max(0, $creature->food - 5),
    ]);

    return back();
}

app/Http/Controllers/EnclosureController.php, avec use App\Models\Creature; en tête. Dans show(), la clé creatures devient :

'creatures' => $user->creatures()
    ->orderBy('name')
    ->get()
    ->map(fn (Creature $creature) => [
        ...$creature->only('id', 'name', 'species', 'food', 'mood', 'energy'),
        'can' => [
            'update' => $request->user()->can('update', $creature),
        ],
    ]),

resources/js/pages/enclosures/Show.vue, l'import de edit à côté de create :

import { create, edit } from '@/routes/creatures';

Puis la vignette, dans la liste des créatures :

<EnclosureCreatureTile :creature="creature" class="w-full">
    <TextLink
        v-if="creature.can.update"
        :href="edit(creature.id)"
    >
        Modifier
    </TextLink>
</EnclosureCreatureTile>

La page 403

Le Tamagotchi a déjà sa page d'erreur, comme votre portfolio depuis le chapitre 1.08. Dans app/Providers/AppServiceProvider.php, Inertia::handleExceptionsUsing() rend la page ErrorPage pour les codes 403, 404 et 429. Pour 403, son titre est « Accès refusé » et son message « Vous n'avez pas le droit de faire ça. ». Vous n'avez rien à écrire : le 403 de Gate::authorize() passe par là.

La page d'erreur du Tamagotchi vue par Mallory : le code 403 en très grands chiffres, le titre ACCÈS REFUSÉ, la phrase Vous n'avez pas le droit de faire ça., un bouton Retour à mes créatures et un écran LCD qui montre un cadenas

Masquer un lien n'est pas une sécurité

Quand vous êtes connecté avec le compte d'Alice, son enclos montre maintenant un lien « Modifier » sous chaque vignette. Avec le compte de Mallory, le même enclos n'en montre aucun : pour elle, la prop vaut "can":{"update":false} pour Braise comme pour Moustache.

La section Mes créatures de l'enclos d'Alice, vue par Alice : les vignettes de Braise et de Moustache portent chacune un lien Modifier sous leurs jauges

Ce lien masqué est du confort : Mallory ne voit pas un lien qui la mènerait à une page refusée. Il ne protège rien. Dans l'application que vous avez reçue, aucun lien ne menait non plus à la page de Braise, et Mallory l'a ouverte en tapant l'adresse. La protection, c'est Gate::authorize() dans le contrôleur, côté serveur, sur chaque requête.

La même policy sert deux fois. Dans le navigateur, can.update vaut false et l'enclos d'Alice n'affiche pas le lien Modifier : c'est du confort. Dans Laravel, Gate::authorize() refuse Mallory avec une réponse 403 dans les cinq actions protégées sur Braise : c'est la protection. Mallory qui tape /creatures/1/modifier sans lien arrive quand même devant Gate::authorize()

6. La preuve

Avant la correction, les tests de la faille sont rouges, sauf celui de la propriétaire :

php artisan test --filter=Flaw02
   FAIL  Tests\Feature\Flaws\Flaw02AccessControlTest
  ⨯ a player cannot open the edit page of someone else's creature    0.76s
  ⨯ a player cannot rename someone else's creature                   0.07s
  ⨯ a player cannot play with someone else's creature                0.05s
  ⨯ a player cannot put someone else's creature to sleep             0.04s
  ⨯ a player cannot release someone else's creature                  0.04s
  ✓ the owner still can                                              0.05s
  …
   FAILED  Tests\Feature\Flaws\Flaw02AccessControlTest > a player cannot open the edit page of someone else's creatu…
  Expected response status code [403] but received 200.
  …
  Tests:    5 failed, 1 passed (6 assertions)

« received 200 » est la page de Braise envoyée à Mallory. Pour les quatre autres, le test reçoit 302 : l'action a eu lieu, puis le contrôleur a redirigé. Après la correction :

   PASS  Tests\Feature\Flaws\Flaw02AccessControlTest
  ✓ a player cannot open the edit page of someone else's creature    0.99s
  ✓ a player cannot rename someone else's creature                   0.18s
  ✓ a player cannot play with someone else's creature                0.15s
  ✓ a player cannot put someone else's creature to sleep             0.14s
  ✓ a player cannot release someone else's creature                  0.17s
  ✓ the owner still can                                              0.16s

  Tests:    6 passed (10 assertions)

Lancez ensuite toute la suite avec php artisan test. Le tableau de bord du chapitre 2.01 passe de 24 tests rouges à 19, et les tests du jeu restent verts :

  Tests:    19 failed, 66 passed (294 assertions)

Rejouez enfin l'attaque avec le compte de Mallory. tamagotchi.test/creatures/1/modifier affiche la page « 403 · Accès refusé » de la section 5. Dans la console, les deux lignes de la section 3 reçoivent 403, et l'énergie de Braise ne bouge plus. Avec le compte d'Alice, la même adresse ouvre la page de Braise.

7. La règle

Recopiez cette phrase dans votre carnet des failles, avec l'endroit de votre portfolio où elle s'applique :

Chaque action sur une donnée vérifie côté serveur, avec une policy, que la personne connectée a le droit de la toucher. Un lien masqué ne protège rien.

À retenir

  • L'authentification établit qui envoie la requête, et l'autorisation décide si cette personne peut agir sur cette donnée.
  • Un numéro qui se lit dans l'adresse se change à la main, et le serveur doit donc vérifier chaque requête qui en contient un.
  • Une policy regroupe les règles d'un modèle, une méthode par capacité, et Laravel la trouve par son nom dans app/Policies.
  • Gate::authorize('update', $creature) en première ligne d'une action répond 403 et arrête l'action quand la policy renvoie false.
  • $request->user()->can() envoyé en prop sert à masquer un lien, ce qui reste du confort et jamais une protection.

Pour aller plus loin