一行代码就能瓦解你的整个访问控制模型。直接将请求体(request body)展开到数据库更新操作中,就相当于把一支笔递给了客户端,让他们可以随意重写你的 Schema。这就是批量赋值(Mass Assignment)。它不是什么罕见的漏洞或边缘案例,而是一种设计缺陷——每当 API 将负载(payload)视为其自身的更新策略时,这种缺陷就会出现。

await db.users.update(req.params.id, { ...req.body });

它看起来很简洁,节省了输入量。但客户端控制着键(keys)。攻击者可以在一个普通的个人资料更新请求中添加 "role": "admin""accountId": "someone_else""credit": 99999。你的校验层可能会检查这些值是否为字符串或数字,并认为它们看起来没问题。然而,有效性(validity)并不等同于授权(authorization)。用户可能确实拥有目标记录的所有权,但这并不意味着他们应该有权编辑其中的每一个字段。

批量赋值究竟是什么样的

危险隐藏在便利之中。框架和 ORM 让 JSON 键直接映射到数据库列变得轻而易举。当你这样做时,你是在告诉数据库去信任客户端关于“什么”应该改变的信息,而不仅仅是“如何”改变。

一个更新个人资料的用户可能会发送有效的 displayNamebio 数据,但同时悄悄夹带了 rolebalance。如果你的控制器只是简单地转发该对象,数据库就会将所有内容写入。校验层可以捕获格式错误的值,但很少能捕获恶意的键。原本“该用户允许更新其个人资料”的业务规则,变成了对该行中每一列的通配权限。

解决办法不是增加更多的校验,而是更严格的架构。

三道关卡

一个安全的变更(mutation)在触及存储层之前,必须通过三道独立的检查。

允许的字段(白名单)

首先,明确决定哪些键是你需要关注的。如果某个字段不在白名单上,则拒绝请求或丢弃该键。这反转了默认的安全姿态:新的数据库列在开发者显式暴露之前,都是不可写的。Schema 会随着时间的推移而增长。队友增加了一个 stripeCustomerIddepartmentBudgetisVerified 标志。有了白名单,这些新列会自动受到保护,防止客户端写入。如果没有白名单,每一个新列都会变成一个意外暴露的 API 接口。

有效的值

一旦确定了允许哪些字段,就要检查这些值是否合理。时区字符串是否是公认的时区?电子邮件格式是否正确?数值是否在合理的范围内?这属于数据清洗(hygiene)工作。它能防止垃圾数据进入系统,但无法阻止滥用。如果发送者身份不对,即使 "admin" 字符串本身格式完全正确,在 role 字段中依然是危险的。

授权变更

这是大多数团队都会忽略的关卡,也是真正防御所在。提出一个细粒度的问题:这个特定的操作者是否有权修改这条特定记录上的这个特定字段?不是问“用户是管理员吗?”,也不是问“用户是否拥有 write:users 权限?”,而是问“该用户是否被允许修改自己的 displayName,但绝不允许修改其 accountId?”基于字段的授权可以防止像“Editor”或“User”这样宽泛的权限变成该行中每个属性的万能钥匙。

构建 Patch 函数

将这三道关卡串联成一个单一的流水线。当 Patch 请求到达时,按顺序运行各个阶段。

首先,根据白名单过滤输入。如果 role 不是该端点的允许字段,立即停止。你没有理由去校验或授权一个你根本不应该接收到的值。

其次,校验允许的值。检查类型、格式和业务规则。位置字段必须是能解析为真实时区的字符串。头像 URL 必须是符合特定长度限制的有效 URI。

第三,对操作进行授权。验证操作者是否拥有目标记录的所有权,或者是否持有该字段所需的精确权限。对于个人数据,所有权是一个很好的默认设置,但某些字段仍需要额外的关卡。用户可能拥有自己的个人资料,但只有计费管理员才能触碰 taxRegion

第四,规范化数据。修剪空格、合并重复空格、将电子邮件转为小写或剥离控制字符。在校验之后、存储之前执行此操作,这样在进行授权检查时,你就不会在比较“脏”字符串。

If the input fails any gate, reject the entire mutation. Do not partially apply the safe fields and quietly drop the bad ones. A mixed response trains clients to spray every key they can think of and see what sticks. Fail explicitly.

The Edge Cases That Actually Matter

Mass assignment defenses live or die on details that unit tests often miss.

Duplicate JSON keys. Attackers can send payloads like {"role": "user", "role": "admin"}. Depending on your HTTP parser and framework, the second value might overwrite the first before your application code sees the object. Test this behavior at the parser level. If your framework silently accepts the last key, your allowlist might be looking at "user" while the database receives "admin".

Nested objects, nulls, and arrays. Do not assume the payload is flat. A client might wrap a restricted field inside a nested object like { "profile": { "role": "admin" } }. Your allowlist must recurse if your schema does. Likewise, decide how you handle null. Does it mean "ignore this field" or "delete this field"? And if an array is expected, does your validator reject unexpected structures, or does it cast a single object into an array and let it through?

Unicode normalization. Two strings can look identical to a human while being different sequences of bytes. A user might send a precomposed é or a decomposed e plus combining accent. If your authorization check normalizes once but your storage layer normalizes differently, you can end up with inconsistent data or, worse, a bypass where a username collision slips past your logic. Normalize early and normalize consistently.

Race conditions. Authorization decisions are not freeze frames. They happen at a point in time. Two requests can read the same record, both see that the actor is allowed to write, and both issue updates. In between, the state or the actor’s permissions might have changed. Always apply database updates with a condition on a version number or a state machine value. Use something like UPDATE users SET ... WHERE id = ? AND version = 5. If the row changed since you read it, the write fails. Handle the failure by retrying or rejecting. This keeps stale authorization checks from corrupting your data.

Monitor What Matters

You cannot secure what you cannot see. Build your audit logging around the decision, not just the action.

Log the actor ID and the target ID. Log the exact field names that were accepted and the ones that were rejected. Log the policy version that made the decision and the final result. If a user suddenly starts getting role rejected in their profile update, you want to know immediately.

Never log bearer tokens. Never dump entire request bodies into your logs. An audit trail should help you investigate abuse, not become a repository of credentials and personal data.

The Only Rule You Need

A request body proposes data. It never defines its own authority. The client can ask for anything. Your server decides, field by field and row by row, what is allowed to land in permanent storage. Build your patches with that separation in mind, and mass assignment becomes a problem you stopped long before it reached your authorization layer.