Stop Mass Assignment Before It Reaches Your Authorization Layer
Stop Mass Assignment Before It Reaches Your Authorization Layer
Mass assignment is what happens when an API treats the request body as the update policy.
await db.users.update(req.params.id, { ...req.body });
That line is convenient until a client sends role, accountId, credit, or emailVerified. Validation alone does not answer whether this caller may change a valid field. The safer design has three separate gates: accepted fields, valid values, and authorized transitions.
Build a Mutation Contract
const MUTATIONS = {
displayName: {
validate: (v) => typeof v === "string" && v.trim().length <= 80,
authorize: ({ actor, target }) =>
actor.id === target.id || actor.permissions.includes("users:write"),
normalize: (v) => v.trim(),
},
timezone: {
validate: (v) => Intl.supportedValuesOf("timeZone").includes(v),
authorize: ({ actor, target }) => actor.id === target.id,
normalize: (v) => v,
},
};
export function buildPatch(body, context) {
const patch = {};
const rejected = [];
for (const [field, value] of Object.entries(body)) {
const rule = MUTATIONS[field];
if (!rule || !rule.validate(value) || !rule.authorize(context)) {
rejected.push(field);
continue;
}
patch[field] = rule.normalize(value);
}
return { patch, rejected };
}
An allowlist makes new database columns non-writable by default. Per-field authorization prevents a broad "can edit user" check from granting authority over every property. Returning rejected field names to server-side telemetry makes probing visible; do not echo sensitive values.
Test Forbidden Fields as Invariants
import assert from "node:assert/strict";
const actor = { id: "u1", permissions: [] };
const target = { id: "u1", role: "member", accountId: "a1" };
const { patch, rejected } = buildPatch(
{ displayName: "Ada", role: "admin", accountId: "a2" },
{ actor, target },
);
assert.deepEqual(patch, { displayName: "Ada" });
assert.deepEqual(rejected.sort(), ["accountId", "role"]);
Also test:
- Duplicate JSON keys at the HTTP parser boundary
- Nested objects
null- Arrays
- Unicode normalization
- Races where authorization changes between read and write
Apply the database update with a condition on the version or authorization-relevant state; otherwise a correct pre-check can become stale. Log the actor, target, accepted field names, rejected field names, policy version, and result. Never log bearer tokens or entire request bodies.
The public MonkeyCode repository describes project requirements, task management, team collaboration, and private deployment. Any platform that accepts structured updates for tasks, projects, or permissions benefits from explicit mutation contracts. This example is independent and does not claim a MonkeyCode vulnerability or describe its internal API.
Disclosure: I contribute to the MonkeyCode project. The product context comes from its public repository; no security test of MonkeyCode is reported here.
The important boundary is simple: a request body proposes data. It never defines its own authority.
Comments
No comments yet. Start the discussion.