Skip to Content

Chapitre 5.2 — Broken access control & IDOR

⏱️ TL;DR — Le Broken Access Control est le risque n°1 du Top 10 OWASP, et sa forme la plus courante est l’IDOR (Insecure Direct Object Reference) — côté API on parle de BOLA (Broken Object Level Authorization, n°1 de l’OWASP API Top 10). Le principe est trivial et dévastateur : une ressource est référencée par un identifiant que le client fournit (/api/factures/123), et le serveur la renvoie sans vérifier qu’elle appartient au demandeur. En changeant l’id (124, 125…), l’attaquant lit — ou modifie — les données de tout le monde. Autres formes : accès à des fonctions réservées (endpoints d’admin non protégés), et confiance en des champs client (role=admin dans le corps).

🎯 Objectifs

  • Comprendre le mécanisme de l’IDOR/BOLA et pourquoi il est si fréquent.
  • Reconnaître les variantes : object-level, function-level, mass assignment, contrôle « caché » côté client.
  • Repérer un IDOR dans du code et savoir exactement placer la parade.
  • Comprendre pourquoi les ids « non devinables » ne sont pas une solution suffisante.

L’IDOR en une image

Imagine cet endpoint, très banal :

// ❌ VULNERABLE : renvoie la facture demandee, SANS verifier le proprietaire app.get('/api/factures/:id', requireAuth, async (req, res) => { const facture = await db.factures.findById(req.params.id) res.json(facture) // et si :id n'est pas MA facture ? })

Le requireAuth vérifie que l’utilisateur est connecté (authentification) — mais rien ne vérifie que la facture :id lui appartient (autorisation par objet). Un utilisateur légitime demande /api/factures/123 (sa facture). Un attaquant connecté demande /api/factures/124, /125, /126… et récupère les factures des autres. C’est un IDOR.

C’est banal, mécanique, et massivement exploité : un simple script qui incrémente l’id aspire toute la base. Et c’est invisible aux tests classiques, qui utilisent toujours le bon utilisateur sur le bon id.

🎯 Côté attaquant — L’IDOR est souvent la première chose qu’un attaquant teste sur une API : il crée un compte, regarde les requêtes que fait son propre client (DevTools), repère les identifiants d’objets (?id=, /users/42, un uuid dans le corps), puis les change pour ceux d’autres utilisateurs. S’il obtient les données d’autrui, c’est gagné — sans exploit sophistiqué, juste en modifiant un nombre. Il automatise ensuite l’énumération. C’est pour ça que le BOLA est n°1 de l’OWASP API Top 10.

Les variantes du broken access control

1. Object-level (l’IDOR classique)

Accéder à un objet d’un autre utilisateur en manipulant son référence (id, uuid, nom de fichier, clé). Vaut pour la lecture (GET) et, plus grave, pour la modification/suppression (PUT/DELETE sur l’objet d’autrui).

2. Function-level (accès à des fonctions réservées)

Accéder à une fonctionnalité réservée à un rôle supérieur parce que la route n’est pas protégée côté serveur — souvent parce que le contrôle n’existe que dans l’UI (« le bouton Admin n’est pas affiché pour les apprenants »). Mais l’endpoint /api/admin/supprimer-utilisateur répond quand même si on l’appelle directement.

# L'UI cache le bouton "supprimer utilisateur" aux non-admins... # ... mais l'endpoint repond a TOUT le monde : POST /api/admin/users/42/delete <- appele directement par un apprenant -> ca marche ?!

3. Mass assignment (sur-affectation)

Le serveur lie tout le corps de la requête aux champs de l’objet, y compris des champs sensibles que le client n’aurait pas dû pouvoir écrire (role, isAdmin, ownerId, solde). L’attaquant ajoute "role":"admin" au JSON d’un innocent formulaire de profil.

// ❌ Mass assignment : l'utilisateur peut envoyer {"role":"admin"} et devenir admin await db.users.update(req.user.id, req.body) // req.body contient ce que le client veut

4. Contrôle « caché » côté client

Faire reposer l’accès sur quelque chose que le client contrôle : un champ role dans le JWT non revérifié, un booléen isAdmin dans le localStorage, un paramètre ?admin=true, un menu masqué en CSS. Tout cela est manipulable (rappel : le client est hostile, Partie 1).

La parade (aperçu — détaillée au chapitre suivant)

Une seule règle ferme tout ça : vérifier l’autorisation côté serveur, pour chaque requête, en liant l’objet au demandeur. Concrètement, on ne récupère pas « l’objet :id puis on l’envoie » — on récupère « l’objet :id qui appartient à l’utilisateur courant », et on refuse (403/404) sinon.

// ✅ CORRIGE : la requete elle-meme lie l'objet au proprietaire app.get('/api/factures/:id', requireAuth, async (req, res) => { const facture = await db.factures.findOne({ id: req.params.id, ownerId: req.user.id, // <- l'objet DOIT appartenir au demandeur }) if (!facture) return res.sendStatus(404) // 404 pour ne pas confirmer l'existence res.json(facture) })

⚠️ Piège — Croire qu’un identifiant non devinable (un UUID aléatoire au lieu d’un entier séquentiel) règle l’IDOR. C’est faux : c’est de la sécurité par l’obscurité. Un UUID rend l’énumération plus difficile, mais les identifiants fuient constamment (logs, URLs partagées, Referer, réponses d’API, autre utilisateur qui les voit) — et une fois l’id connu, l’accès non contrôlé fonctionne. L’UUID est une défense en profondeur utile, jamais un substitut à la vérification d’appartenance côté serveur. La sécurité vient du contrôle, pas du secret de l’id.

💡 Réflexe — À chaque fois que ton code fait « findById(idVenantDuClient) », une alarme doit sonner : « à qui appartient cet objet, et l’ai-je vérifié ? ». La bonne forme n’est presque jamais findById(id) suivi d’un envoi, mais une requête portée par le propriétaire/tenant (findOne({ id, ownerId })) ou un contrôle d’appartenance explicite juste après la récupération. Le findById nu sur une entrée client est le motif de l’IDOR.

🧭 Sur FormaCampus — Un audit interne de FormaCampus a révélé un IDOR sur l’export de résultats : GET /api/classes/:id/resultats renvoyait les résultats de n’importe quelle classe à tout formateur connecté (le contrôle vérifiait le rôle « formateur », pas l’appartenance de la classe). Un formateur pouvait aspirer les résultats de toutes les classes de l’établissement — une fuite de données d’apprenants massive. Correctif : la requête lie désormais la classe au formateur (WHERE classe.id = :id AND classe.formateurId = :userId), via un voter Symfony centralisé et testé, et un test de non-régression vérifie qu’un formateur A reçoit 403 sur la classe d’un formateur B.

✏️ Exercices

Exercice 1 — Trouve et corrige l’IDOR. Cet endpoint laisse un utilisateur télécharger un document. Où est la faille, et comment la fermer ?

$id = $_GET['doc']; $doc = $db->query("SELECT chemin FROM documents WHERE id = ?", [$id])->fetch(); readfile($doc['chemin']); // envoie le fichier

✅ Solution

La requête est bien paramétrée (pas d’injection SQL), mais il n’y a aucune vérification d’appartenance : n’importe quel utilisateur connecté peut changer ?doc= pour l’id d’un document d’autrui et le télécharger → IDOR. Correctif : lier le document au propriétaire courant, SELECT chemin FROM documents WHERE id = ? AND proprietaire_id = ? avec [$id, $user->id], et renvoyer un 404/403 si aucune ligne. (Bonus : vérifier aussi que chemin ne permet pas une traversée de répertoire — voir Partie 9 — mais ici le point central est le contrôle d’accès par objet.)

Exercice 2 — Mass assignment. Ce code met à jour un profil. Quel champ un attaquant peut-il abuser, et comment corriger ?

app.patch('/api/profil', requireAuth, async (req, res) => { await db.users.update(req.user.id, req.body) // { nom, bio, role?, ... } res.sendStatus(200) })

✅ Solution

C’est un mass assignment : req.body est appliqué tel quel, donc l’attaquant peut envoyer {"role":"admin"} (ou isAdmin, emailVerified, ownerId…) et élever ses privilèges ou falsifier des champs sensibles. Correctif : n’accepter qu’une liste blanche de champs modifiables (const { nom, bio } = req.body; update(user.id, { nom, bio })), valider ces champs (schéma Zod), et ne jamais laisser le client écrire des champs de droit/état (role, solde, ownerId) — ceux-là ne changent que via une logique serveur autorisée.

🧠 Quiz de révision

1. Qu’est-ce qu’un IDOR (ou BOLA) ?

Accéder à — ou modifier — un objet d’un autre utilisateur en manipulant l’identifiant qu’on fournit (/factures/123/124), parce que le serveur renvoie l’objet sans vérifier qu’il appartient au demandeur. C’est la forme la plus courante du Broken Access Control (n°1 OWASP).

2. Pourquoi l’IDOR est-il si fréquent et souvent non détecté ?

Parce que la fonctionnalité « marche » (chaque utilisateur voit ses données) et que les tests classiques utilisent toujours le bon utilisateur sur le bon id — l’oubli du contrôle par objet ne se voit que si on pense à attaquer en changeant l’id. Aucune erreur ni crash ne le signale.

3. Qu’est-ce que le broken function-level access control ?

Accéder à une fonctionnalité réservée (ex. endpoint d’admin) parce que la route n’est pas protégée côté serveur — souvent parce que le contrôle n’existe que dans l’UI (bouton masqué). Appeler l’endpoint directement (curl) contourne l’UI.

4. Un identifiant non devinable (UUID) règle-t-il l’IDOR ?

Non : c’est de la sécurité par l’obscurité. Les ids fuient (logs, URLs, Referer, réponses…), et une fois connus, l’accès non contrôlé marche. L’UUID est une défense en profondeur utile mais jamais un substitut à la vérification d’appartenance côté serveur.

5. Quel motif de code doit déclencher une alarme « IDOR ? »

Un findById(idVenantDuClient) suivi d’un envoi, sans contrôle du propriétaire. La bonne forme lie l’objet au demandeur : findOne({ id, ownerId: user.id }) (ou un contrôle d’appartenance explicite), refusant sinon. Le findById nu sur une entrée client est le motif de l’IDOR.


Chapitre suivant : Vérifier côté serveur & multi-tenant — la parade universelle, et comment isoler strictement les données entre organisations/clients.

Last updated on