OWASP & AppSec

Exposition excessive de données API : guide 2026

Exposition excessive de données API en 2026 : OWASP API3 BOPLA, mass assignment, response filtering, DTO patterns, tests offensifs, défenses par framework.

Naim Aouaichia
17 min de lecture
  • API Security
  • OWASP API3
  • BOPLA
  • Mass Assignment
  • DTO
  • Pydantic
  • Zod
  • GraphQL

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

VarianteDirectionExempleImpact
Mass AssignmentInput (body request)User envoie {"email":"...", "role":"admin"} à POST /users, le role est traitéEscalade de privilèges, manipulation business logic
Excessive Data ExposureOutput (response)GET /users/me retourne password_hash, ssn, stripe_customer_idFuite 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 user

Attaque :

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 user

Avec 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=true

Dé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 champs

Ré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 :

  1. Capturer la réponse JSON via proxy.
  2. Identifier dans le code frontend (DevTools, source maps si exposées) les champs réellement utilisés.
  3. 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 all

Schemathesis 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.json

Semgrep 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: WARNING

GraphQL Shield

Middleware GraphQL pour authorization déclarative par field, type, query.

npm install graphql-shield

Inté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 @JsonView ou fail-on-unknown-properties, Go avec DisallowUnknownFields() et tags json:"-", .NET avec JsonUnmappedMemberHandling.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

Questions fréquentes

  • Quelle différence entre mass assignment et excessive data exposure ?
    Les deux sont les variantes d'OWASP API3:2023 BOPLA (Broken Object Property Level Authorization). Mass assignment : côté input. L'utilisateur envoie dans le body de la requête des champs qu'il ne devrait pas pouvoir contrôler (par exemple : `role: admin` lors de la création d'un compte standard). Si le serveur les traite, escalade de privilèges. Excessive data exposure : côté output. La réponse de l'API retourne plus de champs que nécessaire (par exemple : `password_hash`, `ssn`, `internal_notes` dans une réponse `/api/users/me` qui ne devrait exposer que `id` et `email`). Les deux exploitent la même racine : absence de filtrage explicite des propriétés autorisées en lecture ou en écriture.
  • Pourquoi les ORM modernes ne protègent-ils pas automatiquement contre mass assignment ?
    Parce que la plupart des ORM (SQLAlchemy, Prisma, TypeORM, ActiveRecord) sont conçus pour être flexibles et productifs. Quand vous appelez `User(**body)` ou `User.create(body)`, l'ORM remplit automatiquement tous les champs du modèle correspondant à des clés du body. Sans whitelist explicite, tout champ envoyé par l'attaquant qui matche une colonne du modèle est traité. Certains ORM modernes (Rails 4+ avec strong parameters, Django avec Forms) ont introduit des défenses par défaut, mais beaucoup laissent encore la responsabilité au développeur. La défense moderne 2026 = DTO (Data Transfer Object) explicite avec validation schema (Pydantic Python, Zod TypeScript, Jackson @JsonView Java, marshmallow Python).
  • GraphQL est-il plus exposé que REST à BOPLA ?
    Différemment exposé. REST : excessive data exposure typique = la réponse JSON contient trop de champs. Mass assignment typique = le body POST traite des champs non documentés. GraphQL : le client choisit explicitement les champs (`query { user { id, email } }`), donc l'excessive data exposure côté serveur est limité (le client demande ce qu'il veut). Mais GraphQL introduit une variante critique : le client peut demander des champs sensibles s'ils sont exposés dans le schema (`query { user { passwordHash, ssn } }`). Défense GraphQL : field-level authorization via GraphQL Shield, fragments allowlisting, ne pas exposer les champs sensibles dans le schema même protégés. Mass assignment GraphQL : équivalent dans les input types, même défense (DTO explicite, filtrage).
  • Comment détecter excessive data exposure en pentest ?
    Trois techniques cumulatives. 1) Manuel : intercepter chaque réponse API via Burp ou Caido, examiner systématiquement le JSON pour identifier les champs sensibles non utilisés par le frontend (regarder le code JS frontend pour comparer). Pattern fréquent : l'API retourne `user.passwordHash` mais le frontend n'affiche que `user.email`. 2) Automatisé : Schemathesis avec property-based testing inclut des assertions sur la présence de champs sensibles dans les réponses. 3) Dev-time : Spectral OpenAPI linter (Stoplight) détecte les schemas qui exposent des champs sensibles connus (`password`, `ssn`, `secret`). 42Crunch Security Audit complète avec scoring continu. Le pattern le plus frappant en audit : champs `internal_*`, `_admin`, `debug`, `metadata` qui apparaissent dans des endpoints publics.
  • DTO obligatoire ou peut-on retourner directement les entités ORM ?
    DTO fortement recommandé en 2026 pour tout endpoint exposé. Retourner les entités ORM directement crée trois risques : 1) couplage tight entre modèle data et API contract (impossible de changer le schema DB sans casser l'API ou inversement), 2) excessive data exposure quasi-inévitable (les entités contiennent souvent des champs sensibles ou techniques), 3) absence de validation explicite des champs en sortie. Pattern moderne : entité ORM en interne, DTO de sortie restrictif (Pydantic BaseModel, Zod schema, Jackson @JsonView). Pour les CRUD admin internes simples, exposition directe acceptable mais documenter explicitement le risque.
  • Quels sont les incidents API les plus marquants liés à l'exposition excessive ?
    Quatre cas documentés majeurs. 1) USPS 2018 : API qui exposait les données personnelles (nom, adresse, numéro téléphone) de 60 millions d'utilisateurs sans authentification correcte. 2) T-Mobile janvier 2023 : 37 millions de comptes via API qui retournait plus de champs que nécessaire combinée à BFLA. 3) Twitter (X) janvier 2022 : 5,4 millions de comptes énumérés via API qui retournait des associations email/phone à username (BOPLA + énumération). 4) Optus septembre 2022 : API d'authentification qui retournait des numéros de passeport et permis de conduire dans les réponses pour 10M de clients. Tous partagent un patron : exposition par défaut + absence de filtrage par DTO + manque de revue sécurité des contrats API.

Écrit par

Naim Aouaichia

Expert cybersécurité et fondateur de Zeroday Cyber Academy

Expert cybersécurité avec un master spécialisé et un parcours hybride : développement, DevOps, DevSecOps, SOC, GRC. Fondateur de Hash24Security et Zeroday Cyber Academy. Formateur et créateur de contenu technique sur la cybersécurité appliquée, la sécurité des LLM et le DevSecOps.