L'exposition excessive de données dans les API est l'une des classes de vulnérabilités les plus fréquentes et les plus dommageables documentées en 2026, codifiée par OWASP API Security Top 10 2023 sous l'identifiant API3:2023 (Broken Object Property Level Authorization, BOPLA). Elle se manifeste sous deux variantes : mass assignment côté input (l'utilisateur envoie des champs non autorisés qui sont traités par le serveur, par exemple role: admin dans une création de compte) et excessive data exposure côté output (la réponse de l'API retourne des propriétés sensibles non nécessaires, par exemple password_hash, ssn, internal_notes). Les deux exploitent la même racine : absence de filtrage explicite des propriétés autorisées en lecture ou écriture, souvent due à l'usage direct des entités ORM sans couche DTO. Les incidents documentés sont massifs : USPS 2018 (60M utilisateurs), T-Mobile janvier 2023 (37M comptes), Twitter 2022 (5,4M), Optus 2022 (10M avec numéros passeport et permis exposés). Cet article détaille les deux variantes BOPLA avec exemples concrets, les défenses DTO par framework majeur (Pydantic Python, Zod TypeScript, Jackson Java, Go encoding/json, .NET), les patterns spécifiques GraphQL, les techniques de détection en pentest et dev-time, l'outillage de prévention (Spectral OpenAPI linter, 42Crunch, Schemathesis, Semgrep custom rules) et les pièges récurrents.
Définition et taxonomie OWASP
L'exposition excessive de données est codifiée comme partie d'OWASP API Security Top 10 2023 sous l'ID API3:2023 Broken Object Property Level Authorization (BOPLA). Évolution de la version 2019 qui distinguait :
- API3:2019 Excessive Data Exposure : output excessif.
- API6:2019 Mass Assignment : input non filtré.
OWASP a fusionné les deux dans BOPLA 2023 car ils partagent la même racine architecturale : absence de contrôle d'autorisation au niveau de la propriété d'un objet. La distinction reste utile pédagogiquement.
Les deux variantes en détail
| Variante | Direction | Exemple | Impact |
|---|---|---|---|
| Mass Assignment | Input (body request) | User envoie {"email":"...", "role":"admin"} à POST /users, le role est traité | Escalade de privilèges, manipulation business logic |
| Excessive Data Exposure | Output (response) | GET /users/me retourne password_hash, ssn, stripe_customer_id | Fuite de données sensibles, reconnaissance attaquant |
Les deux peuvent coexister dans la même API : un endpoint vulnérable aux deux à la fois.
Pourquoi BOPLA est devenu critique en 2026
Trois forces convergent.
Multiplication des APIs internes exposées involontairement
Les organisations modernes exposent typiquement 50 à 500 endpoints API par application. Beaucoup ont été développés rapidement pour répondre aux besoins frontend ou mobile sans revue dédiée du contrat API. Pattern fréquent : l'endpoint retourne l'entité ORM complète parce que c'est facile, le frontend n'utilise que 3 champs, mais 30 champs sont exposés.
Couplage ORM-API par défaut
Les frameworks modernes encouragent par défaut le passage direct entre la couche ORM et la couche API : return User.objects.get(id=1) retourne tout le modèle. Sans discipline DTO explicite, l'exposition est inévitable.
Audit difficile sans tooling
Détection manuelle d'excessive data exposure exige de comparer le JSON retourné avec les champs réellement utilisés par le frontend. Sans outils dédiés, l'audit prend des jours pour chaque endpoint.
Variante 1 — Mass Assignment
L'utilisateur envoie dans le body de la requête des champs qui ne devraient pas être modifiables.
Exemple vulnérable Python avec FastAPI + SQLAlchemy
from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
app = FastAPI()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String)
password_hash = Column(String)
role = Column(String, default="user")
is_active = Column(Boolean, default=True)
is_email_verified = Column(Boolean, default=False)
credits = Column(Integer, default=0)
# VULNÉRABLE : passe directement le body au constructeur
@app.post("/api/users")
async def create_user(body: dict, db: Session = Depends(get_db)):
user = User(**body) # ← traite TOUS les champs envoyés
db.add(user)
db.commit()
return userAttaque :
curl -X POST https://api.example.test/api/users \
-H "Content-Type: application/json" \
-d '{
"email": "attacker@example.test",
"password_hash": "...",
"role": "admin",
"is_active": true,
"is_email_verified": true,
"credits": 999999
}'Résultat : compte créé en admin avec verification email court-circuitée et 999 999 crédits.
Défense Python avec Pydantic
from pydantic import BaseModel, EmailStr, Field
from pydantic import ConfigDict
class UserCreateRequest(BaseModel):
"""DTO restrictif : seuls ces champs sont acceptés en input."""
email: EmailStr
password: str = Field(min_length=12)
display_name: str = Field(min_length=1, max_length=100)
model_config = ConfigDict(extra="forbid") # rejette explicitement champs supplémentaires
@app.post("/api/users")
async def create_user(body: UserCreateRequest, db: Session = Depends(get_db)):
# Hash le password avant stockage
password_hash = hash_password(body.password)
# Whitelist explicite des champs traités
user = User(
email=body.email,
password_hash=password_hash,
display_name=body.display_name,
role="user", # forcé, jamais depuis input
is_active=True, # forcé
is_email_verified=False, # forcé, vérification par email séparée
credits=0, # forcé
)
db.add(user)
db.commit()
return userAvec extra="forbid", toute clé non listée dans le DTO renvoie une 422 Unprocessable Entity. Avec extra="ignore" (défaut Pydantic v2), les clés inattendues sont silencieusement ignorées.
Défense Node.js avec Zod
import { z } from "zod";
import { Hono } from "hono";
const app = new Hono();
const UserCreateSchema = z.object({
email: z.string().email(),
password: z.string().min(12),
displayName: z.string().min(1).max(100),
}).strict(); // équivalent extra:"forbid" : rejette champs additionnels
app.post("/api/users", async (c) => {
const body = await c.req.json();
// Validation stricte
const parsed = UserCreateSchema.safeParse(body);
if (!parsed.success) {
return c.json({ errors: parsed.error.errors }, 422);
}
// Whitelist explicite des champs DB
const user = await db.user.create({
data: {
email: parsed.data.email,
passwordHash: await hashPassword(parsed.data.password),
displayName: parsed.data.displayName,
role: "user",
isActive: true,
isEmailVerified: false,
credits: 0,
},
});
return c.json(user);
});Défense Java avec Jackson @JsonProperty et @JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
public class UserCreateRequest {
@JsonProperty(required = true)
private String email;
@JsonProperty(required = true)
private String password;
@JsonProperty(required = true)
private String displayName;
// Champs sensibles explicitement ignorés en input
@JsonIgnore
private String role;
@JsonIgnore
private Boolean isAdmin;
// getters / setters
}
@RestController
public class UserController {
@PostMapping("/api/users")
public User createUser(@Valid @RequestBody UserCreateRequest body) {
User user = User.builder()
.email(body.getEmail())
.passwordHash(hashPassword(body.getPassword()))
.displayName(body.getDisplayName())
.role("user") // forcé
.isActive(true) // forcé
.build();
return userRepository.save(user);
}
}Configuration globale Jackson pour rejeter champs inconnus :
// application.properties
spring.jackson.deserialization.fail-on-unknown-properties=trueDéfense Go avec encoding/json
Go par défaut ne traite que les champs déclarés dans la struct (les inconnus sont ignorés). Pour rejeter explicitement les champs inconnus :
package main
import (
"encoding/json"
"errors"
"net/http"
)
type UserCreateRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=12"`
DisplayName string `json:"display_name" validate:"required,min=1,max=100"`
}
func createUserHandler(w http.ResponseWriter, r *http.Request) {
var body UserCreateRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields() // Rejette champs additionnels
if err := decoder.Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
// Whitelist explicite, jamais via reflection sur body
user := User{
Email: body.Email,
PasswordHash: hashPassword(body.Password),
DisplayName: body.DisplayName,
Role: "user",
IsActive: true,
}
db.Create(&user)
}Défense .NET avec Data Annotations
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
public class UserCreateRequest
{
[Required]
[EmailAddress]
public string Email { get; set; }
[Required]
[MinLength(12)]
public string Password { get; set; }
[Required]
[MinLength(1), MaxLength(100)]
public string DisplayName { get; set; }
}
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create([FromBody] UserCreateRequest body)
{
if (!ModelState.IsValid)
return UnprocessableEntity(ModelState);
var user = new User
{
Email = body.Email,
PasswordHash = HashPassword(body.Password),
DisplayName = body.DisplayName,
Role = "user", // forcé
IsActive = true // forcé
};
await _context.Users.AddAsync(user);
await _context.SaveChangesAsync();
return Ok(user);
}
}
// Configuration globale pour rejeter propriétés inconnues
// dans Program.cs
builder.Services.AddControllers().AddJsonOptions(opts => {
opts.JsonSerializerOptions.UnmappedMemberHandling =
JsonUnmappedMemberHandling.Disallow;
});Variante 2 — Excessive Data Exposure
La réponse de l'API retourne plus de champs que nécessaire pour le frontend.
Exemple vulnérable
# Endpoint qui retourne l'entité User complète
@app.get("/api/users/me")
async def get_me(current_user: User = Depends(authenticate)):
return current_user # ← retourne TOUS les champsRéponse JSON typique :
{
"id": 42,
"email": "user@example.test",
"display_name": "John Doe",
"password_hash": "$2b$12$xyz...",
"ssn": "123-45-6789",
"stripe_customer_id": "cus_ABC123",
"internal_notes": "VIP customer, flagged for upsell",
"credit_score": 750,
"fraud_score": 0.12,
"two_factor_secret": "JBSWY3DPEHPK3PXP",
"created_at": "2024-01-15T10:30:00Z",
"last_login_ip": "203.0.113.42",
"marketing_consent": true,
"_internal_metadata": {...}
}Le frontend n'affiche que id, email, display_name. Les 12 autres champs sont leakés.
Défense : DTO de sortie restrictif
# Pydantic : DTO de sortie qui filtre explicitement
from pydantic import BaseModel, EmailStr
from datetime import datetime
class UserPublicResponse(BaseModel):
"""DTO de sortie public : seuls ces champs sont exposés."""
id: int
email: EmailStr
display_name: str
created_at: datetime
# password_hash, ssn, stripe_customer_id, etc. NON inclus
@app.get("/api/users/me", response_model=UserPublicResponse)
async def get_me(current_user: User = Depends(authenticate)):
return UserPublicResponse.model_validate(current_user)FastAPI valide automatiquement que la réponse correspond au response_model et exclut les champs non listés.
Défense Node.js avec response shaping
import { z } from "zod";
const UserPublicSchema = z.object({
id: z.number(),
email: z.string().email(),
displayName: z.string(),
createdAt: z.date(),
});
type UserPublic = z.infer<typeof UserPublicSchema>;
function toPublicUser(user: User): UserPublic {
return UserPublicSchema.parse({
id: user.id,
email: user.email,
displayName: user.displayName,
createdAt: user.createdAt,
});
}
app.get("/api/users/me", async (c) => {
const user = await getCurrentUser(c);
return c.json(toPublicUser(user));
});Défense Java avec Jackson @JsonView
// Définition de vues
public class Views {
public static class Public {}
public static class Admin extends Public {}
public static class Internal extends Admin {}
}
public class User {
@JsonView(Views.Public.class)
private Long id;
@JsonView(Views.Public.class)
private String email;
@JsonView(Views.Public.class)
private String displayName;
@JsonView(Views.Admin.class)
private String stripeCustomerId;
@JsonView(Views.Internal.class)
private String passwordHash;
@JsonView(Views.Internal.class)
private String ssn;
// getters/setters
}
@RestController
public class UserController {
@GetMapping("/api/users/me")
@JsonView(Views.Public.class) // expose uniquement les champs Public
public User getMe(@AuthenticationPrincipal User user) {
return user;
}
}Défense Go avec struct tags
type User struct {
ID uint `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
PasswordHash string `json:"-"` // jamais sérialisé
SSN string `json:"-"` // jamais sérialisé
StripeCustomerID string `json:"-"` // jamais sérialisé
InternalNotes string `json:"-"` // jamais sérialisé
CreatedAt time.Time `json:"created_at"`
}Le tag json:"-" exclut le champ de toute sérialisation JSON. Pattern simple et efficace en Go.
Pour des sorties différenciées par contexte (public vs admin) :
type UserPublic struct {
ID uint `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
CreatedAt time.Time `json:"created_at"`
}
func toPublic(u User) UserPublic {
return UserPublic{
ID: u.ID,
Email: u.Email,
DisplayName: u.DisplayName,
CreatedAt: u.CreatedAt,
}
}Field-level authorization (granularité par propriété)
Au-delà du filtrage statique par DTO, certains cas nécessitent de filtrer dynamiquement selon le rôle de l'utilisateur.
Exemple : champ visible selon rôle
class UserResponse(BaseModel):
id: int
email: EmailStr
display_name: str
created_at: datetime
# Champs admin-only
stripe_customer_id: Optional[str] = None
internal_notes: Optional[str] = None
fraud_score: Optional[float] = None
def serialize_user(user: User, viewer: User) -> dict:
"""Sérialise selon le rôle du viewer."""
base = {
"id": user.id,
"email": user.email,
"display_name": user.display_name,
"created_at": user.created_at,
}
# Champs admin uniquement si viewer est admin
if viewer.role == "admin":
base.update({
"stripe_customer_id": user.stripe_customer_id,
"internal_notes": user.internal_notes,
"fraud_score": user.fraud_score,
})
return base
@app.get("/api/users/{user_id}")
async def get_user(
user_id: int,
current_user: User = Depends(authenticate),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(404)
return serialize_user(user, current_user)GraphQL : variantes spécifiques
GraphQL change la structure du problème mais BOPLA reste pertinent.
Excessive data exposure GraphQL : ne pas exposer dans le schema
En GraphQL, le client choisit explicitement les champs. Le risque excessive data exposure se reformule : ne jamais exposer dans le schema GraphQL des champs sensibles, même protégés par auth.
# MAUVAIS : passwordHash exposé dans le schema même si protégé
type User {
id: ID!
email: String!
displayName: String!
passwordHash: String! # ← visible via introspection, traité par resolvers
ssn: String! # ← idem
}
# BON : champs sensibles ABSENTS du schema
type User {
id: ID!
email: String!
displayName: String!
}
# Si admin a besoin de voir SSN, créer un type dédié restreint
type AdminUserView {
id: ID!
email: String!
ssn: String!
internalNotes: String!
}
type Query {
user(id: ID!): User
adminUser(id: ID!): AdminUserView # endpoint séparé avec auth admin
}Field-level authorization GraphQL
Pour cas où certains champs doivent être visibles conditionnellement :
// graphql-shield : middleware d'autorisation par champ
import { shield, rule } from "graphql-shield";
const isAdmin = rule()(async (parent, args, ctx) => {
return ctx.user?.role === "admin";
});
const permissions = shield({
User: {
stripeCustomerId: isAdmin,
internalNotes: isAdmin,
fraudScore: isAdmin,
},
});Mass assignment GraphQL : input types restrictifs
# Input type : whitelist explicite des champs acceptés
input UserCreateInput {
email: String!
password: String!
displayName: String!
# role, isActive, etc. NON listés = non acceptables en input
}
type Mutation {
createUser(input: UserCreateInput!): User
}Désactiver introspection en production
// Apollo Server : désactiver introspection en prod
const server = new ApolloServer({
schema,
introspection: process.env.NODE_ENV !== "production",
// En prod, le schema ne peut pas être exploré par un attaquant
});Détection en pentest
Trois approches cumulatives.
Manuel via Burp / Caido
Pour chaque endpoint API :
- Capturer la réponse JSON via proxy.
- Identifier dans le code frontend (DevTools, source maps si exposées) les champs réellement utilisés.
- Comparer : tout champ JSON non utilisé par le frontend = exposition excessive potentielle.
Patterns suspects à chercher : password*, *hash*, *secret*, *token*, internal_*, _admin*, debug*, metadata, ssn, national_id, tax_id, stripe_*, _private.
Automatisé via Schemathesis
Property-based testing à partir d'OpenAPI spec :
# Test depuis spec OpenAPI
schemathesis run \
https://api.example.test/openapi.json \
--base-url https://api.example.test \
--checks allSchemathesis valide notamment que les réponses correspondent au schema OpenAPI déclaré (détecte les champs non documentés retournés).
Mass assignment fuzzing
Tester chaque endpoint POST/PATCH/PUT en injectant des champs non documentés courants :
# Liste de champs à fuzzer
SENSITIVE_FIELDS = [
"role", "is_admin", "isAdmin", "is_staff", "is_superuser",
"is_active", "is_verified", "is_email_verified", "verified",
"credits", "balance", "premium", "tier",
"owner_id", "user_id", "organization_id",
"permissions", "scopes",
]
def test_mass_assignment(endpoint: str, baseline_body: dict, token: str):
for field in SENSITIVE_FIELDS:
body = baseline_body.copy()
body[field] = "admin" if "role" in field else True
response = requests.post(
endpoint,
json=body,
headers={"Authorization": f"Bearer {token}"}
)
# Vérifier si le champ a été traité
if response.status_code == 201:
created = response.json()
if created.get(field) == body[field]:
print(f"[!] Mass assignment: {field} = {body[field]} accepté")Outillage de prévention dev-time
Cinq outils à intégrer dans le workflow développement.
Spectral (Stoplight) — OpenAPI linter
Détection statique des problèmes dans les specs OpenAPI :
# .spectral.yaml
extends: spectral:oas
rules:
no-sensitive-fields-in-response:
description: "Response schema must not contain sensitive fields"
given: $.paths.*[*].responses[?(@property === '200')].content.*.schema..properties
severity: error
then:
function: pattern
functionOptions:
notMatch: "(?i)(password|secret|hash|token|ssn|credit_card)"42Crunch Security Audit
Plateforme commerciale de scoring sécurité OpenAPI continu (intégration GitHub, GitLab). Détecte automatiquement les contrats avec champs sensibles, schemas trop permissifs, manques de rate limiting.
Schemathesis
Property-based testing à partir d'OpenAPI ou GraphQL schema :
schemathesis run --hypothesis-deadline=5000 \
--checks not_a_server_error,response_schema_conformance,not_a_data_exposure \
https://api.example.test/openapi.jsonSemgrep custom rules
Détection en static analysis dans le code :
# .semgrep/api-mass-assignment.yaml
rules:
- id: mass-assignment-spread
pattern: |
User(**$BODY)
message: "Potential mass assignment via spread of request body"
languages: [python]
severity: ERROR
- id: response-without-dto
patterns:
- pattern: |
@app.get("$URL")
def $FUNC(...) -> ...:
return $USER
- pattern-not: |
@app.get("$URL", response_model=$DTO)
def $FUNC(...) -> ...:
return ...
message: "API endpoint missing response_model DTO - potential excessive data exposure"
languages: [python]
severity: WARNINGGraphQL Shield
Middleware GraphQL pour authorization déclarative par field, type, query.
npm install graphql-shieldIntégration au schema GraphQL pour appliquer des règles d'authorization à grain fin.
Cas réels d'incidents
Quatre cas documentés majeurs liés à BOPLA.
USPS — novembre 2018
API publique du US Postal Service exposait sans authentification correcte les données personnelles (nom, adresse, numéro de téléphone, type de service souscrit) de 60 millions d'utilisateurs. Vulnérabilité dans l'API de tracking. Découverte par chercheur indépendant Brian Krebs. Patch en novembre 2018 après 12+ mois de notification ignorée.
T-Mobile — janvier 2023
37 millions de comptes exfiltrés via une API qui retournait plus de champs que nécessaire combinée à BFLA. Inclut nom, date de naissance, numéros de téléphone, adresses email. Détecté en novembre 2022, divulgué en janvier 2023. Coût estimé supérieur à 350 millions $ en règlements et amendes.
Twitter (X) — janvier 2022
API qui permettait d'identifier un compte Twitter à partir d'un email ou numéro de téléphone (BOPLA + leak design). 5,4 millions de comptes énumérés et publiés sur forums. Amende FTC subséquente, coûts de remédiation.
Optus — septembre 2022
API d'authentification publique sans contrôle d'accès qui retournait dans les réponses des numéros de passeport et permis de conduire. Exfiltration de 10 millions de clients australiens. Coût total estimé supérieur à 140 millions AUD.
Pièges récurrents en code
Cinq erreurs observées sur les codebases en France 2024-2026.
1. Retour direct d'entités ORM
# Anti-pattern fréquent : retourne l'entité ORM directement
@app.get("/api/users/{id}")
async def get_user(id: int, db: Session = Depends(get_db)):
return db.query(User).filter(User.id == id).first()Solution : DTO obligatoire pour tous les endpoints publics.
2. Spread du body request dans constructeur
// Anti-pattern : spread du body
app.post("/api/users", async (req, res) => {
const user = await db.user.create({ data: req.body }); // mass assignment trivial
res.json(user);
});Solution : validation schema Zod/Joi avant create.
3. Sérialisation à l'oubli
Modification d'entité ORM pour ajouter un champ sensible (par exemple internal_notes), mais oubli de mettre à jour les DTO existants. Le champ apparaît automatiquement dans toutes les réponses API.
Solution : DTO whitelist explicite (forbid extra fields), tests automatisés qui valident la stabilité du contrat API.
4. GraphQL avec schema exposant tout
Schema GraphQL généré automatiquement depuis l'ORM (par exemple Hasura sans configuration restrictive) qui expose toutes les colonnes de toutes les tables.
Solution : configuration explicite des permissions par champ et type, désactiver introspection en production.
5. Endpoints admin sans contrôle de rôle pour champs sensibles
# Endpoint /api/users avec query param ?include=ssn pour les admins
@app.get("/api/users")
async def list_users(include: str = "", current_user = Depends(authenticate)):
users = db.query(User).all()
if "ssn" in include: # ← pas de check du rôle !
return [{"id": u.id, "ssn": u.ssn} for u in users]
return [{"id": u.id} for u in users]Solution : check de rôle systématique sur les champs sensibles, séparation claire des endpoints publics et admin.
Points clés à retenir
- L'exposition excessive de données API est codifiée par OWASP API Security Top 10 2023 sous API3:2023 BOPLA (Broken Object Property Level Authorization), avec deux variantes : mass assignment (input non filtré) et excessive data exposure (output excessif).
- La cause racine architecturale est l'absence de filtrage explicite des propriétés autorisées en lecture ou écriture, souvent due à l'usage direct des entités ORM sans couche DTO.
- La défense moderne 2026 = DTO explicite avec validation schema : Pydantic Python avec
extra="forbid", Zod TypeScript avec.strict(), Jackson Java avec@JsonViewoufail-on-unknown-properties, Go avecDisallowUnknownFields()et tagsjson:"-", .NET avecJsonUnmappedMemberHandling.Disallow. - GraphQL réduit l'excessive data exposure côté output (le client choisit) mais introduit une variante critique : les champs sensibles dans le schema sont accessibles via introspection. Ne jamais exposer dans le schema des champs sensibles, même protégés.
- Outillage dev-time obligatoire en 2026 : Spectral OpenAPI linter pour détection statique, 42Crunch Security Audit pour scoring continu, Schemathesis pour fuzzing schema-aware, Semgrep custom rules pour détection patterns code, GraphQL Shield pour authorization fine-grained GraphQL.
Pour aller plus loin
- Qu'est-ce que la sécurité des API - vue d'ensemble incluant BOPLA dans les 4 couches de défense.
- API Gateway : rôle sécurité - centralisation des contrôles transverses, complément aux DTO applicatifs.
- Méthodologie de pentest API - tests offensifs incluant détection BOPLA en pentest.
- Rate limiting : pourquoi et comment - défense contre l'énumération massive qui exploite BOPLA.





