Skip to content

Defesa Contra Mass Assignment

Mass assignment é uma vulnerabilidade onde um atacante adiciona campos extras ao corpo da requisição — como role=admin ou is_active=false — e o servidor os persiste sem intenção.

O NENE2 não possui um método mágico create($body) que tornaria isso fácil de acionar acidentalmente. Mesmo assim, o padrão de whitelist por DTO é a defesa correta e explícita.

A Vulnerabilidade

php
// ❌ Perigoso: $body passado diretamente para 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],
);

Um atacante envia:

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

Como $body['role'] é lido da requisição, o atacante recebe role=admin no banco de dados.

A Defesa: Whitelist Explícita por DTO

Defina um DTO que contenha apenas os campos que o usuário tem permissão de fornecer:

php
/**
 * Apenas name e email são aceitos da entrada do usuário.
 * role e is_active são definidos por lógica do lado do servidor, nunca da requisição.
 */
final readonly class CreateUserInput
{
    public function __construct(
        public string $name,
        public string $email,
    ) {}
}

No controller, mapeie apenas os campos permitidos para o DTO:

php
// ✅ Campos extras (role, is_active, id, created_at) nunca são lidos de $body
$input = new CreateUserInput(
    name:  trim((string) $body['name']),
    email: strtolower(trim((string) $body['email'])),
);

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

No repositório, use as propriedades do DTO diretamente:

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 e is_active são hardcoded
    );
    // ...
}

Mesmo que o atacante envie role=admin, $input só tem name e email — o campo extra nunca chega ao INSERT.

Cenários de Ataque Cobertos

CampoIntenção do ataqueDefesa
role=adminEscalação de privilégiorole não está em CreateUserInput; sempre definido como 'user' no repositório
is_active=falseCriar conta desativada ou bloquear usuáriois_active não está no DTO; sempre definido como 1
id=9999Sobrescrever chave primáriaid não está no DTO; atribuído automaticamente pelo SQLite
created_at=2000-01-01Forjar timestamp de auditoriacreated_at não está no DTO; sempre definido como hora atual

Controle de Campos na Resposta

A defesa se estende à resposta: nunca retorne linhas do banco de dados diretamente. Mapeie explicitamente o que incluir:

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 intencionalmente excluído
    // deleted_at intencionalmente excluído
], 201);

Teste a ausência de campos sensíveis:

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

Serviços Internos Confiáveis

Quando um serviço interno precisa criar um usuário admin (por ex., um serviço de provisionamento), use um DTO separado:

php
final readonly class AdminCreateUserInput
{
    public function __construct(
        public string $name,
        public string $email,
        public string $role,   // permitido apenas para chamadores internos
        public bool $isActive,
    ) {}
}

Chame este DTO somente de caminhos de código que já verificaram a identidade do chamador (por ex., chave de API de máquina, autenticação de serviço interno). Nunca exponha um endpoint HTTP público que aceite AdminCreateUserInput diretamente.

create() vs createList() para Respostas

Ao retornar uma lista, use createList() em vez de create():

php
// ✅ Array JSON de nível superior
return $this->json->createList(array_map(fn (User $u) => [...], $users));

// ✅ Objeto JSON de nível superior
return $this->json->create(['id' => $user->id, ...], 201);

create() espera array<string, mixed> (um objeto). Passar a saída de array_map() diretamente para create() causa um erro de tipo PHPStan nível 8 porque array_map retorna list<T>.

Checklist de Revisão de Código

  • [ ] Campos do corpo da requisição são mapeados para um DTO antes de serem passados ao repositório
  • [ ] O DTO contém apenas campos que o usuário tem permissão de fornecer
  • [ ] Campos controlados pelo servidor (role, is_active, timestamps, chaves primárias) são definidos no repositório, não lidos de $body
  • [ ] A resposta lista explicitamente os campos retornados; sem SELECT * curinga ou serialização direta de linha para JSON
  • [ ] Testes verificam que campos extras da requisição são ignorados e não afetam o valor persistido

Publicado sob a licença MIT.