AppSec|Formation Développement Sécurisé
← Retour aux cas
01
A01:2025

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

  1. Ouvrir https://appsec.cc/login · se connecter avec jdupont / password1
  2. La liste affiche les sinistres #1001 et #1002 uniquement
  3. Cliquer sur le sinistre #1001 → URL /sinistres/1001 → normal
  4. Modifier l'URL en /sinistres/1003
  5. 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

  1. 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.

  2. 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.

  3. @AuthenticationPrincipal est la façon propre de récupérer l'utilisateur courant dans Spring Security — éviter SecurityContextHolder.getContext().getAuthentication() dans les contrôleurs.

  4. 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.

  5. 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)