Objectifs
À la fin de ce cours, tu sauras :
- Identifier les deux familles de contrôle d'accès cassé : IDOR/BOLA horizontal et BFLA vertical.
- Analyser pourquoi un identifiant non prédictible (UUID) n'est pas un mécanisme d'autorisation.
- Démontrer un IDOR avec la méthode à deux comptes, de façon reproductible et sans toucher aux données de tiers.
- Évaluer la sévérité d'un IDOR selon la nature des données, l'action possible et la prédictibilité des identifiants.
- Rédiger une correction fondée sur une autorisation par objet côté serveur et une politique centralisée.
Pourquoi c'est important en bug bounty
Le contrôle d'accès cassé occupe la première place de l'OWASP Top 10 2021 (A01:2021) et la BOLA ouvre l'OWASP API Security Top 10 2023 (API1:2023 Broken Object Level Authorization), tandis que la BFLA y figure en API5:2023 Broken Function Level Authorization. Dans les rapports publics des grandes plateformes, l'IDOR fait partie des catégories les plus récompensées : il est fréquent, ne dépend d'aucun framework particulier et échappe largement aux scanners automatiques, qui ne savent pas à qui appartient un objet.
Les sévérités vont de faible (lecture d'un pseudonyme ou d'une préférence d'affichage) à critique (modification de l'e-mail d'un compte arbitraire menant à sa prise de contrôle, export de toutes les factures d'une plateforme).
Ce qui distingue un rapport payé d'un rapport fermé :
- La preuve repose sur deux comptes que tu contrôles, pas sur les données d'un client réel.
- L'objet obtenu est réellement privé : accéder à un profil public par son identifiant n'est pas un IDOR.
- L'impact est qualifié : quelles données, quelle action, quelle prédictibilité des identifiants, combien d'objets potentiellement concernés (estimé, jamais énuméré).
Le mécanisme
Une application gère deux questions distinctes :
- Authentification : qui es-tu ? Le jeton, le cookie de session ou la clé d'API y répondent.
- Autorisation : as-tu le droit d'effectuer cette action sur cet objet ? Seule la logique métier côté serveur peut y répondre.
Un IDOR (Insecure Direct Object Reference, CWE-639) apparaît lorsque le serveur reçoit un identifiant d'objet fourni par le client, vérifie l'authentification, puis récupère et renvoie l'objet sans vérifier qu'il appartient à l'utilisateur courant. Le terme BOLA, utilisé par l'OWASP pour les API, décrit exactement la même cause racine.
On distingue deux axes :
| Axe | Nom | Exemple |
|---|---|---|
| Horizontal | IDOR / BOLA | Un client lit la facture d'un autre client de même niveau |
| Vertical | BFLA (élévation de privilèges) | Un utilisateur standard appelle DELETE /api/admin/users/42 |
Les deux se combinent souvent : une route d'administration accessible à un utilisateur standard (BFLA) qui accepte en plus n'importe quel identifiant d'organisation (BOLA).
Identifiants prédictibles et UUID
Un identifiant séquentiel (1001, 1002…) rend l'exploitation triviale et massive. Un UUID v4 (122 bits aléatoires) rend la devinette impossible, et c'est pourquoi beaucoup de développeurs pensent être protégés. Mais un UUID n'est pas une autorisation : c'est un nom difficile à deviner, pas un contrôle. Il suffit qu'il fuie une fois. Or les UUID fuient constamment :
- dans des URL partagées (liens d'invitation, de partage, d'export) qui finissent dans l'historique, les journaux de proxy, l'en-tête
Referer; - dans d'autres réponses d'API : la liste des membres d'une équipe renvoie l'UUID de chaque membre, un commentaire renvoie l'UUID de son auteur ;
- dans des notifications e-mail, des webhooks, des fichiers exportés ;
- dans des fonctionnalités publiques : profil, avis client, ticket de support visible par l'organisation.
Note également que les UUID v1 encodent un horodatage et une adresse de nœud, ce qui les rend partiellement prédictibles. La prédictibilité joue sur la sévérité (complexité d'attaque), jamais sur l'existence de la vulnérabilité.
Question de réflexion : un identifiant chiffré ou encodé en Base64 protège-t-il contre l'IDOR ?
L'encodage Base64 ne protège rien : il se décode instantanément. Un identifiant chiffré ou signé empêche de forger un identifiant arbitraire, mais si l'identifiant chiffré d'un objet d'autrui fuit (exactement comme un UUID), le serveur l'acceptera toujours faute de contrôle de propriété. La seule défense est que le serveur vérifie, à chaque requête, la relation entre l'utilisateur courant et l'objet.
Où chercher
- Tout paramètre qui désigne un objet : chemin (
/api/invoices/1001), chaîne de requête (?account_id=), corps JSON, en-têtes personnalisés (X-Org-Id), cookies (current_team=). - Fonctionnalités à risque : factures, commandes, documents, messages, pièces jointes, exports, adresses, moyens de paiement, membres d'équipe, invitations, tickets de support, paramètres de notification.
- Actions en écriture : modification d'e-mail ou de numéro de téléphone, changement de rôle, suppression, annulation de commande, transfert.
- Signaux faibles : un identifiant numérique dans une réponse, une réponse
403différente d'une404selon que l'objet existe, un objet JSON qui contientowner_idouorg_idrenvoyé tel quel au client, une application mobile qui appelle une API différente du site web. - Surfaces oubliées : anciennes versions d'API (
/api/v1/encore routée alors que le front utilise/api/v3/), API mobiles, endpoints GraphQL (contrôle par résolveur), exports asynchrones (/exports/8812/download).
Méthode de test
Prérequis : deux comptes de test personnels (A et B), idéalement dans deux organisations distinctes si l'application est multi-locataire, plus un compte à privilèges bas et un compte administrateur de ton organisation de test pour la BFLA. Utilise deux navigateurs ou deux conteneurs de session séparés et un proxy d'interception. L'extension Autorize (Burp) ou une fonctionnalité équivalente rejoue automatiquement chaque requête de A avec la session de B.
- Crée des objets avec A : une facture, un document, une adresse. Note leurs identifiants.
- Rejoue avec la session de B les requêtes de A, en ne changeant que le jeton :
GET /api/invoices/1001 HTTP/1.1
Host: shop.techshop.test
Authorization: Bearer eyJ...jeton_de_B
Accept: application/jsonHTTP/1.1 200 OK
Content-Type: application/json
{"id":1001,"owner_id":17,"amount":"249.00","billing_address":"Compte de test A, 1 rue Exemple"}- Compare les réponses : code HTTP, taille, contenu. Un
200avec les données de A est une BOLA. Un403ou un404indique un contrôle (vérifie quand même les variantes ci-dessous). - Teste les méthodes alternatives : la lecture est protégée, mais l'écriture ?
PUT /api/invoices/1001 HTTP/1.1
Host: shop.techshop.test
Authorization: Bearer eyJ...jeton_de_B
Content-Type: application/json
{"billing_address":"Modifié par le compte de test B"}Essaie aussi PATCH, DELETE, et la surcharge de méthode (X-HTTP-Method-Override: PUT) lorsque le framework la supporte. Les contrôles sont souvent écrits pour la route GET uniquement.
- Teste les paramètres imbriqués et dupliqués :
{"invoice": {"id": 1001}, "user": {"id": 17}}Une route POST /api/invoices/search peut contrôler l'identifiant du chemin mais pas celui du corps ; un paramètre dupliqué (?id=2001&id=1001) peut être validé sur la première occurrence et utilisé sur la seconde ; un tableau ("ids":[2001,1001]) peut n'être contrôlé que sur son premier élément.
- Teste les anciennes versions : remplace
/api/v3/invoices/1001par/api/v2/ou/api/v1/. Les versions dépréciées restent fréquemment routées sans le middleware d'autorisation ajouté plus tard (API9:2023 Improper Inventory Management). - BFLA : rejoue les requêtes de ton compte administrateur de test avec la session d'un utilisateur standard de la même organisation.
Les tests en écriture se font uniquement sur les objets de tes propres comptes de test. Ne modifie, ne supprime et ne lis jamais volontairement un objet appartenant à un tiers.
Variantes et défenses courantes
- IDOR via fonctionnalité indirecte : l'export PDF, la génération de reçu ou l'aperçu de pièce jointe utilise un identifiant que la page principale contrôle correctement.
- IDOR sur relation : B ne peut pas lire la facture de A, mais peut s'ajouter lui-même comme membre de l'organisation de A (
POST /api/orgs/77/members). - Mass assignment combiné : envoyer
"owner_id": 42lors d'une mise à jour réattribue l'objet. - IDOR aveugle : la réponse est un
204identique, mais l'action a bien eu lieu ; vérifie l'effet depuis le compte A. - GraphQL : la requête
invoice(id:)est protégée mais le champinvoicesd'un objetorganizationaccessible ne l'est pas.
Pourquoi les défenses partielles échouent :
- Cacher le bouton dans l'interface : le contrôle côté client ne contraint pas une requête forgée.
- Compter sur l'aléa des UUID : ils fuient, comme vu plus haut.
- Vérifier le rôle sans vérifier l'objet : « l'utilisateur est un client » ne dit pas « cette facture est la sienne ».
- Contrôler dans chaque contrôleur à la main : il suffit d'un oubli sur une route, une méthode ou une version pour rouvrir la faille. C'est la raison d'être d'une politique centralisée.
- Faire confiance à un identifiant de contexte envoyé par le client (
X-Org-Id) au lieu de le dériver de la session.
Question de réflexion : le serveur renvoie 403 pour une facture d'autrui et 404 pour un identifiant inexistant. Est-ce un problème ?
C'est une fuite d'information mineure : elle permet de savoir quels identifiants existent (énumération), ce qui peut révéler un volume d'affaires ou faciliter une autre attaque. Ce n'est généralement pas signalable seul. La bonne pratique est de renvoyer 404 dans les deux cas, pour ne pas confirmer l'existence d'un objet auquel l'utilisateur n'a pas accès.
Prouver l'impact sans nuire
Une preuve acceptée par les programmes se compose de :
- Deux comptes de test identifiés dans le rapport (identifiants ou e-mails de test).
- La requête de B visant un objet de A, et la réponse qui contient les données de A (ou, en écriture, la capture montrant que l'objet de A a été modifié, vue depuis A).
- Une analyse de prédictibilité : identifiants séquentiels, ou endroit où un UUID d'autrui est exposé.
- Une estimation de l'étendue fondée sur des éléments observables (par exemple « les identifiants de facture sont séquentiels, la tienne est la n° 1001 »), sans jamais énumérer.
Ce qui est interdit : itérer sur des identifiants pour « voir combien de factures sont accessibles », télécharger les données de clients réels, modifier ou supprimer un objet qui ne t'appartient pas. Si tu tombes accidentellement sur les données d'un tiers, arrête-toi, ne les conserve pas, et mentionne-le dans le rapport. La preuve minimale est une requête réussie entre tes deux comptes.
Évaluer la sévérité
| Scénario | Vecteur CVSS 3.1 | Score |
|---|---|---|
| Lecture de factures (nom, adresse, montant) d'un autre client par UUID ne fuyant que dans certains contextes | AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:N/A:N | 5.3 (Moyen) |
| Lecture de factures d'un autre client par identifiant séquentiel | AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N | 6.5 (Moyen) |
| Modification de l'adresse de livraison d'une commande d'autrui (détournement), lecture partielle | AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N | 7.1 (Élevé) |
| BFLA : un utilisateur standard appelle l'API d'administration (lecture et modification de tous les comptes) | AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N | 8.1 (Élevé) |
Un IDOR en écriture sur l'e-mail du compte qui permet une réinitialisation de mot de passe aboutit à la prise de contrôle complète : l'impact se rapproche alors du dernier scénario. À l'inverse, la lecture de données non sensibles (préférence de langue, pseudonyme déjà public) descend à faible ou informatif. Beaucoup de programmes appliquent leur propre grille pour les fuites massives de données personnelles, souvent plus sévère que le score CVSS brut.
Corriger
Le code vulnérable récupère l'objet par son seul identifiant :
// VULNÉRABLE : authentifié, mais aucun lien entre l'utilisateur et la facture
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
const invoice = await db.invoice.findUnique({ where: { id: Number(req.params.id) } });
if (!invoice) return res.sendStatus(404);
res.json(invoice);
});La correction lie la requête au propriétaire, dérivé de la session et jamais du client :
// CORRIGÉ : la propriété fait partie de la requête elle-même
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
const invoice = await db.invoice.findFirst({
where: { id: Number(req.params.id), ownerId: req.user.id },
});
if (!invoice) return res.sendStatus(404); // même réponse : inexistant ou non autorisé
res.json(invoice);
});Dans une application multi-locataire, la correction robuste passe par une politique centralisée appelée par chaque route, plutôt que par un filtre recopié à la main :
// Politique d'autorisation unique, testée une fois, réutilisée partout
const policies = {
"invoice:read": (user, inv) => inv.ownerId === user.id || user.isOrgAdminOf(inv.orgId),
"invoice:update": (user, inv) => inv.ownerId === user.id && inv.status === "draft",
};
async function authorize(user, action, resource) {
const rule = policies[action];
if (!rule || !rule(user, resource)) throw new NotFoundError(); // refus par défaut
}Défense en profondeur :
- Refus par défaut : une route sans règle d'autorisation explicite doit échouer, pas réussir.
- Contexte dérivé de la session : l'organisation courante vient du jeton serveur, jamais d'un en-tête
X-Org-Idlibre. - Filtrage au niveau des données : portée par locataire dans l'ORM ou sécurité au niveau des lignes (Row-Level Security) dans la base.
- Tests automatisés d'autorisation : pour chaque route et chaque méthode, un test qui vérifie que le compte B reçoit 404 sur l'objet de A.
- Inventaire des API : décommissionner réellement les anciennes versions au lieu de les laisser routées.
- Identifiants non prédictibles en complément, pour réduire l'ampleur d'une éventuelle faille, jamais comme seule protection.
- Journalisation et alertes sur les volumes anormaux de 404 par utilisateur, signe d'énumération.
Erreurs qui font rejeter un rapport
- Accéder à une ressource publique par conception (profil public, produit, avis) et la présenter comme un IDOR.
- Utiliser un seul compte et modifier un identifiant au hasard : aucune preuve que l'objet appartient à quelqu'un d'autre ni qu'il est privé.
- Énumérer des identifiants ou télécharger des données de clients réels pour « montrer l'ampleur ».
- Modifier ou supprimer l'objet d'un tiers au lieu d'un objet de ton second compte de test.
- Signaler un UUID impossible à obtenir sans expliquer où il fuit : le triage conclura à un impact théorique.
- Confondre 403/404 différenciés avec un IDOR : c'est au mieux une fuite d'existence de faible gravité.
Checklist du hunter
- Deux comptes de test (deux organisations si multi-locataire), plus un couple admin/standard pour la BFLA.
- Sessions séparées, proxy actif, rejeu automatique des requêtes de A avec la session de B.
- Tous les emplacements d'identifiant : chemin, requête, corps JSON imbriqué, en-têtes, cookies.
- Toutes les méthodes :
GET,PUT,PATCH,DELETE, surcharge de méthode. - Anciennes versions d'API, API mobile, GraphQL, exports asynchrones.
- Prédictibilité documentée : séquentiel, ou source de fuite de l'UUID.
- Écriture uniquement sur tes propres objets, une seule requête de preuve, aucune énumération.
Pour aller plus loin
- OWASP API Security Top 10 2023 : API1:2023 Broken Object Level Authorization et API5:2023 Broken Function Level Authorization.
- OWASP Top 10 2021 : A01:2021 Broken Access Control.
- OWASP WSTG v4.2 : « Testing for Insecure Direct Object References » (WSTG-ATHZ-04).
- OWASP Authorization Cheat Sheet et Insecure Direct Object Reference Prevention Cheat Sheet.
- PortSwigger Web Security Academy : « Access control vulnerabilities and privilege escalation ».
- CWE-639 : Authorization Bypass Through User-Controlled Key ; CWE-285 : Improper Authorization.