Skip to content

批量赋值防御

批量赋值是一种漏洞,攻击者在请求体中添加额外字段——例如 role=adminis_active=false——而服务器在无意间将其持久化。

NENE2 没有 create($body) 魔法方法会让这种情况意外触发。即便如此,DTO 白名单模式才是正确且明确的防御手段。

漏洞描述

php
// ❌ 危险:$body 直接传入 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],
);

攻击者发送:

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

由于从请求中读取了 $body['role'],攻击者在数据库中获得了 role=admin

防御手段:显式 DTO 白名单

定义一个只包含用户允许提供的字段的 DTO:

php
/**
 * 只接受来自用户输入的 name 和 email。
 * role 和 is_active 由服务端逻辑设置,绝不从请求中读取。
 */
final readonly class CreateUserInput
{
    public function __construct(
        public string $name,
        public string $email,
    ) {}
}

在控制器中,只将允许的字段映射到 DTO:

php
// ✅ 额外字段(role、is_active、id、created_at)从不从 $body 中读取
$input = new CreateUserInput(
    name:  trim((string) $body['name']),
    email: strtolower(trim((string) $body['email'])),
);

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

在数据仓库中,直接使用 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 和 is_active 硬编码
    );
    // ...
}

即使攻击者发送了 role=admin$input 也只有 nameemail——额外字段永远不会到达 INSERT。

涵盖的攻击场景

字段攻击意图防御
role=admin权限提升role 不在 CreateUserInput 中;在数据仓库中始终设为 'user'
is_active=false创建禁用账户或锁定用户is_active 不在 DTO 中;始终设为 1
id=9999覆盖主键id 不在 DTO 中;由 SQLite 自动分配
created_at=2000-01-01伪造审计时间戳created_at 不在 DTO 中;始终设为当前时间

响应字段控制

防御延伸到响应:绝不直接返回 DB 行。显式映射要包含的内容:

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 故意排除
    // deleted_at 故意排除
], 201);

测试敏感字段的缺失:

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

受信任的内部服务

当内部服务需要创建管理员用户时(例如配置服务),使用独立的 DTO:

php
final readonly class AdminCreateUserInput
{
    public function __construct(
        public string $name,
        public string $email,
        public string $role,   // 仅允许内部调用者使用
        public bool $isActive,
    ) {}
}

只从已验证了调用者身份的代码路径调用该 DTO(例如机器 API 密钥、内部服务认证)。绝不暴露直接接受 AdminCreateUserInput 的公开 HTTP 端点。

列表响应使用 createList() 而非 create()

返回列表时,使用 createList() 而非 create()

php
// ✅ 顶级 JSON 数组
return $this->json->createList(array_map(fn (User $u) => [...], $users));

// ✅ 顶级 JSON 对象
return $this->json->create(['id' => $user->id, ...], 201);

create() 期望 array<string, mixed>(一个对象)。直接将 array_map() 的输出传给 create() 会导致 PHPStan level 8 类型错误,因为 array_map 返回 list<T>

代码审查清单

  • [ ] 请求体字段在传给数据仓库之前已映射到 DTO
  • [ ] DTO 只包含用户允许提供的字段
  • [ ] 服务端控制的字段(roleis_active、时间戳、主键)在数据仓库中设置,不从 $body 读取
  • [ ] 响应显式列出返回字段;无通配符 SELECT * 或直接行转 JSON 序列化
  • [ ] 测试验证额外的请求字段被忽略,不影响持久化的值

基于 MIT 许可证发布。