Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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.

NotionQuestionDans 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

FormeCe qui manqueExemple
Contrôle de fonctionLa vérification du rôleUn visiteur appelle la route de validation, réservée au comptable
Contrôle d'objet (IDOR)La vérification du propriétaireUn visiteur lit la fiche d'un collègue en changeant l'identifiant
Confiance dans le clientLe serveur croit une donnée qu'il devrait calculerLe rôle est lu dans le corps de la requête ; le bouton « Valider » est seulement masqué
Navigation forcéeUne page non protégée, supposée introuvable/admin/export.php répond à qui connaît l'adresse
Parcours de répertoireLe contrôle du chemin d'un fichier?fichier=../../etc/passwd lit un fichier du serveur
CORS trop permissifLa liste des sites autorisés à appeler l'APIN'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'applicationUn site piégé fait valider une fiche par le navigateur du comptable (chapitre 9)
SSRFLe contrôle des adresses que le serveur appelleLe 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é :

js
// 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 :

js
// 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 :

php
// 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.

js
// 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 :

Visiteur Aid 42, connectéAPIGET /fiches/:idBase MySQLfiche_fraisVersion vulnérable1. GET /api/fiches/11882. jeton valide3. … WHERE id = 11884. la fiche du visiteur B5. fiche de B renvoyée à AVersion corrigée1. GET /api/fiches/11882. … WHERE id = 1188AND id_visiteur = 423. aucune ligne4. 404 Fiche introuvable
IDOR : l'API sait qui demande, mais ne vérifie pas à qui appartient la fiche

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.

js
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 :

js
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 :

js
// 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 :

js
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 :

sql
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 ?

CodeSensQuand le renvoyer
401 UnauthorizedNon authentifiéPas de jeton, jeton invalide ou expiré : « connecte-toi »
403 ForbiddenAuthentifié, mais pas autoriséBon jeton, mauvais rôle ou pas propriétaire
404 Not FoundRessource introuvableIdentifiant 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 :

js
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èlePrincipeExemple
RBAC (Role-Based Access Control)Les droits sont attachés à des rôles ; chaque utilisateur a un ou plusieurs rôlesvisiteur, comptable, admin
ABAC (Attribute-Based Access Control)La décision combine des attributs : rôle, service, région, heure, sensibilité de la donnéeUn 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).

ActionVisiteurComptableAdmin
Saisir et modifier sa fiche en coursOui (les siennes)NonNon
Consulter une ficheLes siennesToutesNon
Valider, rembourser une ficheNonOuiNon
Gérer les comptes utilisateursNonNonOui

Une écriture déclarative garde la matrice lisible et testable :

js
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 :

js
// 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 :

  1. 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.
  2. 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).
  3. Si un nom doit malgré tout être utilisé, vérifier que le chemin résolu reste dans le dossier autorisé :
js
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 :

js
// 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 :

js
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 :

js
// 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
});
réseau internepare-feuAttaquantInternetAPI AppliFraispublique13Métadonnées cloud169.254.169.254Console d'admin10.0.1.10:8080Base de données10.0.1.20:330621. L'attaquant envoie une URL interne à l'API publique : le pare-feu laisse passer.2. L'API, placée dans le réseau interne, télécharge elle-même cette URL.3. La réponse, par exemple des clés d'accès temporaires, repart vers l'attaquant.
SSRF : l'attaquant fait faire au serveur une requête qu'il ne pourrait pas faire lui-même

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 :

  1. Éviter la fonctionnalité : faire envoyer le fichier par le navigateur (upload) plutôt que de le télécharger côté serveur.
  2. 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.
  3. 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 vers 127.0.0.1.
  4. Ne pas suivre les redirections (redirect: 'error' avec fetch) : une URL autorisée peut rediriger vers une adresse interne.
  5. Ne pas renvoyer la réponse brute à l'utilisateur, limiter la taille et la durée du téléchargement.
  6. 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 :

js
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 :

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));
  1. 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 ?
  2. Nomme la faille et sa catégorie OWASP 2025.
  3. 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
  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.
  2. 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é.
  3. On ajoute le propriétaire dans la condition :
php
$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.

  1. Une requête vers /api/fiches arrive sans en-tête Authorization.
  2. Un visiteur authentifié appelle PATCH /api/compta/fiches/12/valider.
  3. Un comptable demande la fiche 99999, qui n'existe pas.
  4. Le jeton envoyé a expiré il y a dix minutes.
Voir la réponseMasquer la réponse de l'exercice 2
  1. 401 : l'utilisateur n'est pas authentifié.
  2. 403 : il est authentifié, mais son rôle ne l'autorise pas.
  3. 404 : la ressource n'existe pas.
  4. 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 } }).

  1. Qu'envoie un visiteur pour devenir administrateur ?
  2. Pourquoi la condition where ne suffit-elle pas à protéger ?
  3. 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
  1. Un corps JSON contenant un champ supplémentaire : { "email": "a@gsb.fr", "role": "admin" }.
  2. 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).
  3. On choisit explicitement les champs :
js
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>.

  1. Explique en deux phrases comment un attaquant peut en profiter.
  2. Le développeur propose de refuser les URL contenant la chaîne localhost. Donne deux façons de contourner ce filtre.
  3. Propose trois mesures plus solides.
Voir la réponseMasquer la réponse de l'exercice 4
  1. 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.
  2. 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.
  3. 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.

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Mauvaise configuration (A02), environnements et secretsRevoir le sommaire du cours