Chapitre 323 minIntermédiaire
Contrôle d'accès défaillant (A01) : IDOR, droits et SSRF
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- distinguer authentification et autorisation ;
- corriger une IDOR en vérifiant le propriétaire côté serveur ;
- traduire une matrice d'habilitations en contrôles dans le code ;
- reconnaître une SSRF et choisir ses parades ;
- écrire des tests qui prouvent le contrôle d'accès.
4 exercices corrigés2 schémas
Le contrôle d'accès défaillant (Broken Access Control, A01:2025) désigne toute situation où un utilisateur peut faire ou voir ce que ses droits ne lui permettent pas : lire la donnée d'un autre, appeler une fonction réservée, élever ses privilèges. C'est la première catégorie de l'OWASP Top 10 depuis 2021. Ce chapitre montre ses formes (contrôle de fonction, IDOR, confiance dans le client, parcours de répertoire, CORS, SSRF), la façon de les corriger côté serveur et de les tester.
Authentification ou autorisation ?
Deux mots qu'on confond, et cette confusion est à l'origine de la faille.
| Notion | Question | Dans AppliFrais |
|---|---|---|
| Authentification | « Qui es-tu ? » | Le visiteur donne son identifiant et son mot de passe ; l'API lui remet un jeton |
| Autorisation (ou habilitation) | « As-tu le droit de faire ça, sur cette donnée ? » | Ce visiteur peut-il lire cette fiche ? Peut-il valider une fiche ? |
Un jeton valide prouve l'identité ; il ne dit rien des droits. Le contrôle d'accès défaillant, c'est presque toujours une application qui vérifie le premier point et oublie le second. Le chapitre 11 traite l'authentification ; celui-ci traite l'autorisation.
Les formes du contrôle d'accès défaillant
| Forme | Ce qui manque | Exemple |
|---|---|---|
| Contrôle de fonction | La vérification du rôle | Un visiteur appelle la route de validation, réservée au comptable |
| Contrôle d'objet (IDOR) | La vérification du propriétaire | Un visiteur lit la fiche d'un collègue en changeant l'identifiant |
| Confiance dans le client | Le serveur croit une donnée qu'il devrait calculer | Le rôle est lu dans le corps de la requête ; le bouton « Valider » est seulement masqué |
| Navigation forcée | Une page non protégée, supposée introuvable | /admin/export.php répond à qui connaît l'adresse |
| Parcours de répertoire | Le contrôle du chemin d'un fichier | ?fichier=../../etc/passwd lit un fichier du serveur |
| CORS trop permissif | La liste des sites autorisés à appeler l'API | N'importe quel site peut lire les réponses de l'API avec les cookies de la victime |
| Falsification de requête intersite (CSRF) | La preuve que la requête vient bien de l'application | Un site piégé fait valider une fiche par le navigateur du comptable (chapitre 9) |
| SSRF | Le contrôle des adresses que le serveur appelle | Le serveur va chercher une URL interne fournie par l'attaquant |
Vérifier le rôle : le contrôle de fonction
Dans la première version d'AppliFrais, la route de validation vérifie seulement que l'utilisateur est connecté :
// VULNÉRABLE : n'importe quel utilisateur connecté peut valider une fiche
router.patch('/api/fiches/:id/valider', authentifier, async (req, res) => {
await db.execute("UPDATE fiche_frais SET etat = 'VA' WHERE id = ?", [req.params.id]);
res.status(204).end();
});
Le middleware authentifier vérifie le jeton et place l'utilisateur dans req.utilisateur. Un visiteur qui connaît l'URL (elle est dans le code du front, lisible par tous) valide sa propre fiche avec curl. La correction ajoute un second middleware qui vérifie le rôle :
// Un middleware réutilisable : refuse si le rôle ne fait pas partie de la liste
function exigerRole(...rolesAutorises) {
return (req, res, next) => {
if (!rolesAutorises.includes(req.utilisateur.role)) {
return res.status(403).json({ erreur: 'Action non autorisée' });
}
next();
};
}
router.patch('/api/fiches/:id/valider', authentifier, exigerRole('comptable'), validerFiche);
Le rôle vient de la base ou d'un jeton signé par le serveur, jamais d'un paramètre envoyé par le client. En PHP, la même règle s'écrit en tête du contrôleur :
// Le rôle est en session, écrit par le serveur au moment de la connexion
if (($_SESSION['role'] ?? null) !== 'comptable') {
http_response_code(403);
exit(json_encode(['erreur' => 'Action non autorisée']));
}
IDOR : accéder aux données d'un autre
L'IDOR (Insecure Direct Object Reference, CWE-639) est une route qui renvoie ou modifie un objet désigné par un identifiant fourni par le client, sans vérifier que le demandeur y a droit. Dans les API, on parle de BOLA (Broken Object Level Authorization), premier risque de l'OWASP API Security Top 10.
// VULNÉRABLE : la fiche est renvoyée à quiconque est connecté
router.get('/api/fiches/:id', authentifier, async (req, res) => {
const [lignes] = await db.execute('SELECT * FROM fiche_frais WHERE id = ?', [req.params.id]);
if (lignes.length === 0) return res.status(404).json({ erreur: 'Fiche introuvable' });
res.json(lignes[0]);
});
La requête est préparée : aucune injection possible. Le jeton est vérifié. Et pourtant :
Le visiteur A connaît l'identifiant de sa fiche, 1187. Il essaie 1188, 1189, 1190… et lit les fiches de tous ses collègues : montants, déplacements, RIB. Avec des identifiants séquentiels, un script aspire toute la table en quelques secondes. C'est exactement ce qui s'est produit chez Optus en 2022.
La correction la plus sûre met le propriétaire dans la requête : la base ne renvoie que ce que le demandeur a le droit de voir.
router.get('/api/fiches/:id', authentifier, async (req, res) => {
const { id: idUtilisateur, role } = req.utilisateur;
const sql = role === 'comptable'
? 'SELECT * FROM fiche_frais WHERE id = ?'
: 'SELECT * FROM fiche_frais WHERE id = ? AND id_visiteur = ?';
const params = role === 'comptable' ? [req.params.id] : [req.params.id, idUtilisateur];
const [lignes] = await db.execute(sql, params);
if (lignes.length === 0) return res.status(404).json({ erreur: 'Fiche introuvable' });
res.json(lignes[0]);
});
L'autre écriture charge l'objet puis compare son propriétaire à l'utilisateur. Elle convient aussi, à condition de comparer des valeurs du même type :
if (fiche.id_visiteur !== req.utilisateur.id && req.utilisateur.role !== 'comptable') {
return res.status(403).json({ erreur: 'Action non autorisée' });
}
Ne jamais croire le client
Tout ce que le navigateur envoie peut être modifié. Plusieurs failles viennent d'une donnée que le serveur aurait dû calculer lui-même.
Le rôle dans la requête. Une route d'inscription qui enregistre req.body tel quel laisse l'utilisateur choisir son rôle :
// VULNÉRABLE : affectation de masse (mass assignment)
await Utilisateur.create(req.body); // { "login": "eve", "mdp": "…", "role": "admin" }
Le serveur doit choisir explicitement les champs acceptés et fixer lui-même les champs sensibles :
const { login, email, mdp } = req.body;
await Utilisateur.create({ login, email, mdpHash: await hacher(mdp), role: 'visiteur' });
L'OWASP API Security Top 10 appelle ce défaut Broken Object Property Level Authorization (BOPLA) : le contrôle porte sur l'objet, mais pas sur chacune de ses propriétés.
Les champs cachés et les calculs côté client. Un champ <input type="hidden" name="montant_rembourse"> ou un total calculé en JavaScript ne valent rien : le serveur recalcule à partir des données de la base et des barèmes.
L'identifiant de l'utilisateur dans l'URL ou le corps. GET /api/fiches?idVisiteur=12 permet de remplacer 12 par 13. L'identifiant de l'utilisateur connecté se lit dans la session ou le jeton vérifié, jamais dans un paramètre.
Le cycle de vie des objets. Un visiteur ne doit plus modifier une fiche à l'état VA. La règle se vérifie côté serveur, dans la requête elle-même :
UPDATE ligne_frais l
JOIN fiche_frais f ON f.id = l.id_fiche
SET l.montant = ?
WHERE l.id = ? AND f.id_visiteur = ? AND f.etat = 'CR';
Si la requête modifie zéro ligne, l'API répond par une erreur : la ligne n'existe pas, n'appartient pas au visiteur ou n'est plus modifiable.
401, 403 ou 404 ?
| Code | Sens | Quand le renvoyer |
|---|---|---|
401 Unauthorized | Non authentifié | Pas de jeton, jeton invalide ou expiré : « connecte-toi » |
403 Forbidden | Authentifié, mais pas autorisé | Bon jeton, mauvais rôle ou pas propriétaire |
404 Not Found | Ressource introuvable | Identifiant inexistant, ou ressource qu'on préfère ne pas révéler |
Le nom anglais de 401 est trompeur : il signifie « non authentifié ». Certaines API renvoient 404 plutôt que 403 sur une ressource qui appartient à un autre, pour ne pas confirmer son existence. Les deux choix se défendent ; l'essentiel est de ne jamais renvoyer la donnée.
Refuser par défaut et centraliser
Ajouter le contrôle à la main sur chaque route, c'est cinquante occasions d'en oublier un. Les équipes centralisent :
const api = express.Router();
api.post('/connexion', connexion); // la seule route publique, déclarée avant
api.use(authentifier); // toutes les routes suivantes exigent un jeton
api.get('/fiches', listerMesFiches);
api.get('/fiches/:id', lireFiche);
const compta = express.Router();
compta.use(exigerRole('comptable')); // tout le sous-routeur est réservé au comptable
compta.patch('/fiches/:id/valider', validerFiche);
compta.patch('/fiches/:id/rembourser', rembourserFiche);
api.use('/compta', compta);
Une route ajoutée plus tard dans compta est protégée sans qu'on y pense. Les frameworks offrent le même mécanisme : les policies de Laravel, les voters et l'access_control de Symfony, l'attribut [Authorize(Roles = "comptable")] d'ASP.NET Core.
Deux modèles de droits coexistent :
| Modèle | Principe | Exemple |
|---|---|---|
| RBAC (Role-Based Access Control) | Les droits sont attachés à des rôles ; chaque utilisateur a un ou plusieurs rôles | visiteur, comptable, admin |
| ABAC (Attribute-Based Access Control) | La décision combine des attributs : rôle, service, région, heure, sensibilité de la donnée | Un comptable ne valide que les fiches de sa région |
Traduire une matrice d'habilitations
Les sujets donnent souvent les droits sous forme de tableau. Le travail consiste à le traduire en contrôles dans le code, et en droits SQL quand la base est concernée (traduire une matrice en GRANT).
| Action | Visiteur | Comptable | Admin |
|---|---|---|---|
| Saisir et modifier sa fiche en cours | Oui (les siennes) | Non | Non |
| Consulter une fiche | Les siennes | Toutes | Non |
| Valider, rembourser une fiche | Non | Oui | Non |
| Gérer les comptes utilisateurs | Non | Non | Oui |
Une écriture déclarative garde la matrice lisible et testable :
const droits = {
'fiche:modifier': ['visiteur'],
'fiche:lire': ['visiteur', 'comptable'],
'fiche:valider': ['comptable'],
'utilisateur:gerer': ['admin'],
};
function autoriser(action) {
return (req, res, next) =>
(droits[action] ?? []).includes(req.utilisateur.role) // action inconnue : refus
? next()
: res.status(403).json({ erreur: 'Action non autorisée' });
}
Le ?? [] applique le refus par défaut : une action absente de la matrice n'est autorisée pour personne. La matrice gère les rôles ; la vérification du propriétaire (« les siennes ») reste dans la requête, comme plus haut.
C'est le même raisonnement que la méthode possedeDroit du sujet E7 SLAM 2025 : parcourir les droits du profil et renvoyer true seulement si la table et l'opération correspondent (corrigé du sujet 2025).
Parcours de répertoire : télécharger un justificatif
Les justificatifs (factures d'hôtel, tickets) sont stockés dans un dossier du serveur. La route de téléchargement prend le nom du fichier en paramètre :
// VULNÉRABLE
router.get('/api/justificatifs', authentifier, (req, res) => {
res.sendFile(path.join('/srv/appli-frais/justificatifs', req.query.fichier));
});
Avec ?fichier=../../../etc/passwd, path.join remonte l'arborescence et le serveur envoie un fichier système. C'est le parcours de répertoire (path traversal, CWE-22), rattaché à A01. Trois corrections, à combiner :
- Ne jamais utiliser le nom fourni par le client comme chemin. Stocker les fichiers sous un nom généré (
crypto.randomUUID()) et garder le nom d'origine en base, dans la ligne de frais. - Retrouver le fichier à partir de la ligne de frais, après avoir vérifié qu'elle appartient au demandeur (même logique que l'IDOR).
- Si un nom doit malgré tout être utilisé, vérifier que le chemin résolu reste dans le dossier autorisé :
const racine = path.resolve('/srv/appli-frais/justificatifs');
const chemin = path.resolve(racine, req.query.fichier);
if (!chemin.startsWith(racine + path.sep)) {
return res.status(400).json({ erreur: 'Nom de fichier invalide' });
}
En PHP, basename($_GET['fichier']) retire les répertoires, et realpath() permet la même comparaison avec le dossier autorisé.
CORS mal configuré
Par défaut, un navigateur interdit à une page de site-pirate.example de lire les réponses d'une API d'un autre domaine : c'est la Same-Origin Policy. Le mécanisme CORS (Cross-Origin Resource Sharing) permet à l'API d'assouplir cette règle pour les origines qu'elle choisit, avec l'en-tête Access-Control-Allow-Origin.
La faille consiste à tout autoriser, en particulier en renvoyant l'origine reçue quelle qu'elle soit, avec les cookies :
// VULNÉRABLE : renvoie l'origine reçue et autorise les cookies
app.use(cors({ origin: true, credentials: true }));
N'importe quel site peut alors lire les données de la victime connectée. La configuration correcte liste les origines légitimes :
app.use(cors({ origin: ['https://frais.gsb.fr'], credentials: true }));
SSRF : quand le serveur fait la requête pour l'attaquant
La SSRF (Server-Side Request Forgery, CWE-918) survient quand une application télécharge une ressource dont l'utilisateur fournit l'adresse, sans contrôler cette adresse. L'attaquant se sert alors du serveur comme d'un relais pour atteindre ce que lui-même ne peut pas atteindre : le réseau interne, les services d'administration, les métadonnées d'un serveur cloud. Catégorie à part en 2021 (A10), elle fait partie d'A01 depuis 2025.
Une nouvelle fonctionnalité d'AppliFrais permet d'importer un justificatif en collant son URL :
// VULNÉRABLE : le serveur télécharge n'importe quelle URL
router.post('/api/justificatifs/import', authentifier, async (req, res) => {
const reponse = await fetch(req.body.url);
const contenu = Buffer.from(await reponse.arrayBuffer());
// … enregistre le fichier et renvoie un aperçu
});
Avec http://169.254.169.254/latest/meta-data/, l'adresse du service de métadonnées des machines virtuelles chez plusieurs fournisseurs de cloud, l'attaquant peut récupérer des informations sur la machine, parfois des clés d'accès temporaires. Avec http://10.0.1.10:8080/admin, il atteint une console d'administration interne. Avec file:///etc/passwd, certaines bibliothèques lisent un fichier local.
Les parades, de la plus forte à la plus faible :
- Éviter la fonctionnalité : faire envoyer le fichier par le navigateur (upload) plutôt que de le télécharger côté serveur.
- Liste blanche de domaines autorisés, comparée sur le nom d'hôte analysé avec
new URL(), jamais par une recherche de sous-chaîne. - Refuser les schémas autres que
https:et les adresses privées, locales et réservées (127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,::1…), après résolution DNS : un nom de domaine peut pointer vers127.0.0.1. - Ne pas suivre les redirections (
redirect: 'error'avecfetch) : une URL autorisée peut rediriger vers une adresse interne. - Ne pas renvoyer la réponse brute à l'utilisateur, limiter la taille et la durée du téléchargement.
- Côté réseau, isoler le serveur : un pare-feu en sortie qui n'autorise que les destinations prévues.
Tester le contrôle d'accès
Le contrôle d'accès ne se voit pas dans une démonstration : l'application marche aussi bien sans. Il se prouve par des tests automatisés qui jouent chaque rôle contre chaque route. Avec Vitest et Supertest :
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { app } from '../src/app.js';
import { jetonPour } from './outils.js'; // fabrique un jeton de test pour un utilisateur
describe('GET /api/fiches/:id', () => {
it('renvoie sa fiche au propriétaire', async () => {
const rep = await request(app).get('/api/fiches/1187').set('Authorization', `Bearer ${jetonPour('visiteurA')}`);
expect(rep.status).toBe(200);
});
it("refuse la fiche d'un autre visiteur", async () => {
const rep = await request(app).get('/api/fiches/1188').set('Authorization', `Bearer ${jetonPour('visiteurA')}`);
expect(rep.status).toBe(404);
});
it('refuse la validation à un visiteur', async () => {
const rep = await request(app).patch('/api/compta/fiches/1187/valider').set('Authorization', `Bearer ${jetonPour('visiteurA')}`);
expect(rep.status).toBe(403);
});
});
Ces tests de non-régression empêchent qu'une modification future rouvre la faille. Le chapitre 14 les intègre dans l'intégration continue ; le cours Tests détaille les tests unitaires et d'intégration.
Exercice 1 sur 4 Trouver la faille Réussi À revoir
Voici une route d'AppliFrais en PHP :
$id = (int) $_GET['id'];
$stmt = $pdo->prepare('SELECT * FROM fiche_frais WHERE id = :id');
$stmt->execute(['id' => $id]);
echo json_encode($stmt->fetch(PDO::FETCH_ASSOC));
- Le développeur affirme que la route est sécurisée « car la requête est préparée et l'id converti en entier ». Qu'en penses-tu ?
- Nomme la faille et sa catégorie OWASP 2025.
- Corrige la requête pour un visiteur, dont l'identifiant est dans
$_SESSION['id'].
Voir la réponseMasquer la réponse de l'exercice 1
- La requête préparée et la conversion empêchent l'injection, pas l'accès aux fiches des autres. N'importe quel utilisateur peut demander n'importe quel identifiant.
- Une IDOR (référence directe non sécurisée à un objet), dans A01:2025 Contrôle d'accès défaillant. Elle atteint la confidentialité.
- On ajoute le propriétaire dans la condition :
$stmt = $pdo->prepare('SELECT * FROM fiche_frais WHERE id = :id AND id_visiteur = :visiteur');
$stmt->execute(['id' => $id, 'visiteur' => $_SESSION['id']]);
$fiche = $stmt->fetch(PDO::FETCH_ASSOC);
if ($fiche === false) {
http_response_code(404);
exit;
}
echo json_encode($fiche);
Tu avais trouvé ?
Exercice 2 sur 4 Le bon code de retour Réussi À revoir
Pour chaque situation, donne le code HTTP que l'API doit renvoyer.
- Une requête vers
/api/fichesarrive sans en-têteAuthorization. - Un visiteur authentifié appelle
PATCH /api/compta/fiches/12/valider. - Un comptable demande la fiche 99999, qui n'existe pas.
- Le jeton envoyé a expiré il y a dix minutes.
Voir la réponseMasquer la réponse de l'exercice 2
- 401 : l'utilisateur n'est pas authentifié.
- 403 : il est authentifié, mais son rôle ne l'autorise pas.
- 404 : la ressource n'existe pas.
- 401 : un jeton expiré ne prouve plus l'identité ; le client doit se reconnecter ou rafraîchir son jeton.
Tu avais trouvé ?
Exercice 3 sur 4 Affectation de masse Réussi À revoir
La route de modification du profil exécute await Utilisateur.update(req.body, { where: { id: req.utilisateur.id } }).
- Qu'envoie un visiteur pour devenir administrateur ?
- Pourquoi la condition
wherene suffit-elle pas à protéger ? - Corrige la route pour n'accepter que la modification de l'email et du téléphone.
Voir la réponseMasquer la réponse de l'exercice 3
- Un corps JSON contenant un champ supplémentaire :
{ "email": "a@gsb.fr", "role": "admin" }. - La condition limite la modification à son propre compte (pas d'IDOR), mais pas aux champs autorisés : le visiteur modifie son propre rôle. C'est une autorisation défaillante au niveau des propriétés (BOPLA).
- On choisit explicitement les champs :
const { email, telephone } = req.body;
await Utilisateur.update({ email, telephone }, { where: { id: req.utilisateur.id } });
Tu avais trouvé ?
Exercice 4 sur 4 Repérer une SSRF Réussi À revoir
La fonctionnalité « Aperçu de lien » d'un intranet affiche le titre de la page dont l'utilisateur colle l'URL. Le serveur télécharge la page puis extrait la balise <title>.
- Explique en deux phrases comment un attaquant peut en profiter.
- Le développeur propose de refuser les URL contenant la chaîne
localhost. Donne deux façons de contourner ce filtre. - Propose trois mesures plus solides.
Voir la réponseMasquer la réponse de l'exercice 4
- L'attaquant fournit l'adresse d'un service interne (
http://10.0.1.10:8080/admin,http://169.254.169.254/…) que le serveur, placé dans le réseau interne, peut atteindre. Le serveur fait la requête à sa place et lui renvoie une partie de la réponse : c'est une SSRF, rattachée à A01:2025. - Par exemple
http://127.0.0.1/,http://2130706433/(127.0.0.1 en décimal),http://[::1]/, ou un nom de domaine qui résout vers 127.0.0.1. Toute adresse privée (10.x,192.168.x) échappe aussi au filtre. - N'accepter que
https:; refuser les adresses privées, locales et réservées après résolution DNS ; ne pas suivre les redirections ; limiter la taille et la durée du téléchargement ; filtrer les connexions sortantes du serveur au pare-feu. Si la liste des sites est connue, une liste blanche de domaines est la meilleure solution.
Tu avais trouvé ?
Pour aller plus loin. Les modèles de contrôle d'accès (RBAC, ABAC) et la matrice d'habilitations dont ces règles découlent sont détaillés dans les chapitres RBAC et moindre privilège et Matrice d'habilitations du cours Authentification.