Skip to content

Défense contre la mass assignment ​

La mass assignment est une vulnérabilité où un attaquant ajoute des champs supplémentaires à un corps de requête — comme role=admin ou is_active=false — et le serveur les persiste sans l'avoir voulu.

NENE2 n'a pas de méthode magique create($body) qui rendrait cela facile à déclencher accidentellement. Même ainsi, le pattern de liste blanche DTO est la défense correcte et explicite.

La vulnérabilité ​

php
// ❌ Dangereux : $body passé directement à INSERT
$body = json_decode((string) $request->getBody(), true);

$this->executor->insert(
    'INSERT INTO users (name, email, role, is_active) VALUES (?, ?, ?, ?)',
    [$body['name'], $body['email'], $body['role'] ?? 'user', $body['is_active'] ?? 1],
);

Un attaquant envoie :

json
{
  "name": "Attacker",
  "email": "attacker@example.com",
  "role": "admin"
}

Parce que $body['role'] est lu depuis la requête, l'attaquant obtient role=admin dans la base de données.

La défense : liste blanche explicite par DTO ​

Définir un DTO qui contient uniquement les champs qu'un utilisateur est autorisé à fournir :

php
/**
 * Seuls name et email sont acceptés en entrée utilisateur.
 * role et is_active sont définis par la logique côté serveur, jamais depuis la requête.
 */
final readonly class CreateUserInput
{
    public function __construct(
        public string $name,
        public string $email,
    ) {}
}

Dans le contrôleur, mapper uniquement les champs autorisés vers le DTO :

php
// ✅ Les champs supplémentaires (role, is_active, id, created_at) ne sont jamais lus depuis $body
$input = new CreateUserInput(
    name:  trim((string) $body['name']),
    email: strtolower(trim((string) $body['email'])),
);

$user = $this->repo->create($input);

Dans le repository, utiliser directement les propriétés du DTO :

php
public function create(CreateUserInput $input): User
{
    $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s');
    $id = $this->executor->insert(
        'INSERT INTO users (name, email, role, is_active, created_at) VALUES (?, ?, ?, ?, ?)',
        [$input->name, $input->email, 'user', 1, $now], // role et is_active sont codés en dur
    );
    // ...
}

Même si l'attaquant envoie role=admin, $input n'a que name et email — le champ supplémentaire n'atteint jamais l'INSERT.

Scénarios d'attaque couverts ​

ChampIntention d'attaqueDéfense
role=adminEscalade de privilègesrole n'est pas dans CreateUserInput ; toujours défini à 'user' dans le repository
is_active=falseCréer un compte désactivé ou verrouiller un utilisateuris_active pas dans le DTO ; toujours défini à 1
id=9999Écraser la clé primaireid pas dans le DTO ; auto-assigné par SQLite
created_at=2000-01-01Falsifier l'horodatage d'auditcreated_at pas dans le DTO ; toujours défini à l'heure courante

Contrôle des champs de réponse ​

La défense s'étend à la réponse : ne jamais retourner les lignes DB directement. Mapper explicitement ce qui doit être inclus :

php
return $this->json->create([
    'id'         => $user->id,
    'name'       => $user->name,
    'email'      => $user->email,
    'role'       => $user->role,
    'is_active'  => $user->isActive,
    'created_at' => $user->createdAt,
    // password_hash intentionnellement exclu
    // deleted_at intentionnellement exclu
], 201);

Tester l'absence des champs sensibles :

php
$this->assertArrayNotHasKey('password_hash', $data);
$this->assertArrayNotHasKey('deleted_at', $data);

Services internes de confiance ​

Quand un service interne doit créer un utilisateur admin (ex: un service de provisionnement), utiliser un DTO séparé :

php
final readonly class AdminCreateUserInput
{
    public function __construct(
        public string $name,
        public string $email,
        public string $role,   // autorisé pour les appelants internes uniquement
        public bool $isActive,
    ) {}
}

Appeler ce DTO uniquement depuis des chemins de code qui ont déjà vérifié l'identité de l'appelant (ex: clé API machine, auth de service interne). Ne jamais exposer un endpoint HTTP public qui accepte AdminCreateUserInput directement.

create() vs createList() pour les réponses ​

Lors du retour d'une liste, utiliser createList() au lieu de create() :

php
// ✅ Tableau JSON de premier niveau
return $this->json->createList(array_map(fn (User $u) => [...], $users));

// ✅ Objet JSON de premier niveau
return $this->json->create(['id' => $user->id, ...], 201);

create() attend array<string, mixed> (un objet). Passer directement la sortie de array_map() à create() cause une erreur de type PHPStan level 8 parce que array_map retourne une list<T>.

Checklist de revue de code ​

  • [ ] Les champs du corps de requête sont mappés vers un DTO avant d'être passés au repository
  • [ ] Le DTO ne contient que les champs que l'utilisateur est autorisé à fournir
  • [ ] Les champs contrôlés par le serveur (role, is_active, horodatages, clés primaires) sont définis dans le repository, pas lus depuis $body
  • [ ] La réponse liste explicitement les champs retournés ; pas de SELECT * générique ni de sérialisation directe de ligne en JSON
  • [ ] Les tests vérifient que les champs de requête supplémentaires sont ignorés et n'affectent pas la valeur persistée

Publié sous licence MIT.