DEV Community

Anusha Dirisala for Pentrova

Posted on

Mass Assignment: The One-Line API Bug Hiding in Your Update Endpoint

This article was created with the help of AI and reviewed, tested and edited by me before publishing.

Here's an endpoint that looks completely fine in code review:

app.patch('/api/users/me', requireAuth, async (req, res) => {
  const user = await User.findByIdAndUpdate(req.user.id, req.body, { new: true });
  res.json(user);
});
Enter fullscreen mode Exit fullscreen mode

It's authenticated. It only updates the logged-in user. It's also vulnerable, because req.body goes straight into the database. If your User model has a role field, any user can send this:

PATCH /api/users/me
Content-Type: application/json

{ "displayName": "Ana", "role": "admin" }
Enter fullscreen mode Exit fullscreen mode

and promote themselves. That's mass assignment: the client sets object properties it was never meant to touch. OWASP folds it into API3:2023, Broken Object Property Level Authorization, and MITRE tracks it as CWE-915.

Let's find it, prove it, and fix it.

Why it happens

Frameworks make binding request data to models easy, which is great until the model grows. The endpoint above might have been safe on day one, when User only had displayName and email. Then someone added role, isVerified, credits or tenantId, and the update endpoint silently started accepting them too.

Typical sensitive fields to watch for:

  • privilege: role, isAdmin, permissions
  • state: isVerified, emailConfirmed, status
  • money: balance, credits, plan, discount
  • ownership: ownerId, tenantId, orgId

Finding it in an API you're allowed to test

  1. Read the responses, not just the docs. A GET /api/users/me that returns role, plan or tenantId tells you which properties exist.
  2. Send them back. Take the response object, change one sensitive field, and send it in the update request.
  3. Check the result with a fresh read. Some APIs echo your input in the response without saving it. Only a follow-up GET (or a behaviour change, like suddenly reaching an admin route) proves the write happened.
  4. Try create endpoints too. POST /api/projects with an extra ownerId is the same bug.
  5. Try nested objects. { "profile": { "verified": true } } slips past filters that only check top-level keys.

Only do this on your own apps or targets you're authorised to test.

Fix 1: allowlist the fields you accept

The OWASP Mass Assignment Cheat Sheet recommends allowlisting bindable fields over blocklisting dangerous ones, because a blocklist breaks the moment a new sensitive field is added.

const pick = (obj, keys) =>
  Object.fromEntries(keys.filter((k) => k in obj).map((k) => [k, obj[k]]));

const USER_EDITABLE = ['displayName', 'bio', 'avatarUrl'];

app.patch('/api/users/me', requireAuth, async (req, res) => {
  const updates = pick(req.body, USER_EDITABLE);
  const user = await User.findByIdAndUpdate(req.user.id, updates, { new: true });
  res.json(toPublicUser(user));
});
Enter fullscreen mode Exit fullscreen mode

Fix 2: validate with a strict schema

A schema validator that rejects unknown keys turns silent acceptance into a clear 400. With zod:

import { z } from 'zod';

const UpdateMe = z.object({
  displayName: z.string().min(1).max(80).optional(),
  bio: z.string().max(500).optional(),
}).strict(); // unknown keys like "role" fail validation

app.patch('/api/users/me', requireAuth, async (req, res) => {
  const parsed = UpdateMe.safeParse(req.body);
  if (!parsed.success) return res.status(400).json({ error: 'Invalid fields' });
  const user = await User.findByIdAndUpdate(req.user.id, parsed.data, { new: true });
  res.json(toPublicUser(user));
});
Enter fullscreen mode Exit fullscreen mode

Rejecting is better than silently dropping: clients find out quickly, and an attempted role change shows up in your logs.

Fix 3: control what goes out, too

API3 covers both directions. The toPublicUser function above matters because returning the raw model leaks internal fields (password hashes, internal flags) and hands attackers the list of fields to try. Use explicit response DTOs, not res.json(dbObject).

Lock it in with a test

The bug comes back whenever someone adds a field, so make it a regression test:

test('users cannot change their own role', async () => {
  const agent = await loginAs('regular-user');
  await agent.patch('/api/users/me').send({ role: 'admin' }).expect(400);

  const me = await agent.get('/api/users/me').expect(200);
  expect(me.body.role).toBeUndefined(); // not exposed at all
});
Enter fullscreen mode Exit fullscreen mode

Add one of these for every sensitive field and every endpoint that writes to the model, including create endpoints and admin-only ones (can a support role set role: "owner"?).

Checklist

  • [ ] No endpoint passes raw req.body to an ORM update or create
  • [ ] Writable fields are allowlisted or strictly validated per endpoint and per role
  • [ ] Unknown fields are rejected with a 400, not silently ignored
  • [ ] Responses use explicit DTOs
  • [ ] Regression tests try sensitive fields on every write endpoint

Mass assignment is rarely clever. It's a convenience left in place after the model changed, which is exactly why it's worth a test that never forgets.


Anusha Dirisala is the founder of Pentrova (pentrova.ai), a self-serve AI penetration testing platform for web apps and APIs, based in Hyderabad.

Top comments (0)