Tout ce que la page reçoit est public : les props
Temps 2 · séance 1 · ~10 min
Les slides du chapitre (PDF)PDF
- Retrouver dans le code source ou dans l'onglet Réseau les props qu'une page a reçues.
- Repérer une prop qui envoie un modèle entier.
- Remplacer un modèle entier par un tableau explicite, comme au chapitre 1.07.
1. Une page qui en dit plus qu'elle n'en montre
Cette faille n'a pas d'incident célèbre à elle seule. Une donnée envoyée au navigateur reste lisible, même si la page ne l'affiche pas. Avec Inertia, le serveur envoie les props en JSON, et un modèle entier y part avec toutes ses colonnes.
Le classement du Tamagotchi, /classement, montre les dix créatures les plus heureuses. Pour chacune, la page affiche son nom, son espèce, son score et le nom de son joueur : « Braise, dragon, chez Alice ». Voici le contrôleur, app/Http/Controllers/LeaderboardController.php :
public function __invoke(): Response
{
return Inertia::render('Leaderboard', [
'creatures' => Creature::with('user')
->orderByRaw('food + mood + energy desc')
->take(10)
->get(),
]);
}
get() renvoie des modèles Creature, chacun avec son modèle User chargé par with('user'). Inertia les transforme en JSON, colonne par colonne. Dans le modèle User, l'attribut #[Hidden] retire password, two_factor_secret, two_factor_recovery_codes et remember_token, comme au chapitre 1.10. Les autres colonnes partent vers le navigateur : l'adresse e-mail, la devise et le solde de chaque joueur.

Ce que Mallory obtient : l'adresse e-mail et le solde de tous les joueurs du classement, sans attaque. Elle n'a qu'à lire. Le développeur ne l'a pas vu, parce qu'il a regardé la page, et que la page ne montre rien de plus.
2. Lire ce que la page a reçu
Connectée comme Mallory, ouvrez /classement. Les props se lisent à deux endroits.
Au premier chargement, elles sont écrites dans le HTML de la page. Ouvrez le code source avec Ctrl+U, puis cherchez alice@ avec Ctrl+F. Vous tombez dans la balise <script data-page="app" type="application/json">. Voici la première créature, coupée par … :
"creatures":[{"id":1,"user_id":1,"name":"Braise","species":"dragon","food":90,"mood":95,"energy":80,"created_at":"…","updated_at":"…","user":{"id":1,"name":"Alice","email":"alice@example.com","email_verified_at":"…",…,"motto":"Un enclos propre, des cr\u00e9atures heureuses.","balance":120}},…
Le \u00e9 est le é de « créatures », encodé par Inertia comme au chapitre 1.07.
Pendant la navigation, Inertia demande les props en JSON. Ouvrez l'onglet Réseau, filtrez sur « Fetch/XHR », puis cliquez sur « 03 Classement » dans l'en-tête. La requête classement porte l'en-tête X-Inertia: true, et sa réponse est du JSON. Ce JSON est l'objet de la page entière : component, props, url, version et sharedProps. Dans l'onglet « Aperçu », dépliez props, puis creatures, puis l'entrée 0. Voici cette première créature :
{
"id": 1,
"user_id": 1,
"name": "Braise",
"species": "dragon",
"food": 90,
"mood": 95,
"energy": 80,
"created_at": "…",
"updated_at": "…",
"user": {
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"email_verified_at": "…",
"created_at": "…",
"updated_at": "…",
"two_factor_confirmed_at": null,
"motto": "Un enclos propre, des créatures heureuses.",
"balance": 120
}
}
Les cinq créatures du seeder sont au classement : les adresses d'Alice, de Bob et de Mallory s'y lisent, avec leurs soldes de 120, 0,3 et 5 euros.
La prop partagée auth.user contient aussi le modèle entier, mais c'est celui de Mallory, envoyé à Mallory : le chapitre 1.10 l'a laissé tel quel pour cette raison.
3. La correction
Au chapitre 1.07, la liste des projets construisait un tableau par projet avec map(). La fiche d'un projet construisait le sien avec only() et les trois points qui le déballent. Combinez ici les deux écritures dans LeaderboardController. Gardez les clés que la page Leaderboard.vue utilise, ni plus ni moins : id, name, species, food, mood, energy, et pour le joueur id et name. La page se sert de creature.user.id pour surligner vos propres créatures.
'creatures' => Creature::with('user')
->orderByRaw('food + mood + energy desc')
->take(10)
->get()
->map(fn (Creature $creature) => [
...$creature->only('id', 'name', 'species', 'food', 'mood', 'energy'),
'user' => $creature->user->only('id', 'name'),
]),
with('user') reste : il charge les joueurs en une requête, au lieu d'une requête par créature. Rien ne change dans la page.
4. La preuve
Le test de la faille est dans tests/Feature/Flaws/Flaw04PublicPropsTest.php. Il lit la prop creatures et refuse d'y trouver email ou balance. Il ne vérifie rien d'autre : la devise ou une date qui resteraient passeraient le test. Relisez donc aussi le JSON après la correction. Avant la correction :
php artisan test --filter=Flaw04
FAIL Tests\Feature\Flaws\Flaw04PublicPropsTest
⨯ the leaderboard only sends what it displays 0.86s
────────────────────────────────…
FAILED Tests\Feature\Flaws\Flaw04PublicPropsTest > the leaderboard only sends what it displays
Expecting '[{"id":1,"user_id":1,"name":"…:50}}]' not to contain 'alice@example.com'.
…
Tests: 1 failed (10 assertions)
Après la correction :
PASS Tests\Feature\Flaws\Flaw04PublicPropsTest
✓ the leaderboard only sends what it displays
Tests: 1 passed (12 assertions)
php artisan test passe de 18 tests rouges à 17. L'onglet Réseau garde l'ancienne réponse : cliquez sur « 01 Mon tamagotchi », puis sur « 03 Classement », pour envoyer une nouvelle requête. Dans la nouvelle réponse classement, dépliez props, creatures, puis 0 :
{
"id": 1,
"name": "Braise",
"species": "dragon",
"food": 90,
"mood": 95,
"energy": 80,
"user": {
"id": 1,
"name": "Alice"
}
}
Chaque objet user ne contient plus que id et name. La page affiche exactement le même podium. Connectée comme Mallory, avec les cinq créatures du seeder et les fichiers compilés par npm run build, le contenu de la balise data-page passe de 2 743 caractères à 1 036.
5. La règle
Toute personne lit les props envoyées à son navigateur : les données des autres joueurs partent dans un tableau construit, avec les seuls champs nécessaires, jamais dans un modèle entier.
Recopiez-la dans votre carnet des failles. Dans votre portfolio, cherchez les Inertia::render qui passent un modèle ou un get() sans map() ni colonnes choisies.
À retenir
- Inertia écrit les props dans la balise
<script data-page>au premier chargement, et les envoie en JSON pendant la navigation. - Dans les modèles du Tamagotchi, un modèle passé en prop part avec ses colonnes non masquées par
#[Hidden]et avec ses relations chargées. - Le code source, Ctrl+U, et l'onglet Réseau suffisent pour lire tout ce que la page a reçu.
map()etonly()construisent un tableau des seuls champs nécessaires à l'affichage et au fonctionnement de la page.- Le test de la faille et le JSON relu prouvent la correction, alors que la page, elle, ne change pas.
Pour aller plus loin
- « 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