DEV Community

jaryn
jaryn

Posted on

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 });
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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"]);
Enter fullscreen mode Exit fullscreen mode

Also test duplicate JSON keys at the HTTP parser boundary, nested objects, null, arrays, Unicode normalization, and 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.

Top comments (1)

Collapse
 
frank_signorini profile image
Frank

I've seen object spreading lead to security issues, how do you handle nested objects with this approach? I'd love to swap ideas on this.