Mass Assignment And Why Allow Lists Beat Deny Lists

No linter flags this bug, because nothing about the code is malformed. Spreading a request body onto a database record just quietly trusts fields the UI never meant to expose.

July 25, 20265 min read3 / 4

The last post ended with a promise: the next vulnerability trusts an entire object instead of a single string. This one doesn't need a single quote or a broken query. It just needs a spread operator and an update endpoint that trusts too much.

A Profile Update Endpoint, Written the Obvious Way

Picture a small SaaS app where James can edit his own profile. The server has a PATCH /profile route that looks completely reasonable at first glance.

JavaScript
app.patch("/profile", async (req, res) => { const updated = await db.users.update(req.user.id, { ...req.body }); res.json(updated); });

This is the pattern a lot of us reach for out of habit, especially coming from React, where spreading objects to build a new one without mutating the original feels like second nature. Applied to a database record instead of component state, that habit turns into a liability.

James sends { "name": "James Whitfield" } through the profile form, and it works exactly as expected. The form only ever sends the fields it exposes, so in the browser, everything looks fine.

The Attack Doesn't Touch the Form at All

Here's the part that makes this different from SQL injection: the attacker never needs to break anything.

Open the browser's DevTools, find the request the profile form sends, and edit the payload directly before it goes out. Or skip the browser entirely and send the same request with curl.

Bash
curl -X PATCH https://example.com/profile \ -H "Content-Type: application/json" \ --cookie "sid=abc123" \ -d '{ "name": "James Whitfield", "role": "admin" }'

Nothing about that request is malformed. It's valid JSON, sent to a route the attacker is genuinely allowed to call, using a session that genuinely belongs to them. The server does exactly what its code says: spread the entire body onto the record.

role was never a field the UI exposed. The server never asked whether it should be allowed. It just applied whatever showed up.

This Isn't Just a SQL Problem

The example above uses a generic db.users.update call, but the exact same shape of bug shows up regardless of what's storing the data.

Document databases like MongoDB store records as something close to a plain JavaScript object, which makes the spread-onto-record pattern feel even more natural to reach for. A Mongo update that does { $set: { ...req.body } } has the identical problem: whatever properties exist on the incoming object get applied, whether or not the route intended to expose them. This isn't a SQL-injection-shaped bug either. The query itself is completely valid. It's just applying more than it should.

No Library Catches This

This is the detail that surprised me most about mass assignment. A code reviewer skimming this route sees clean, idiomatic code. No SQL string, no obvious red flag, nothing a linter would ever flag as dangerous.

The bug isn't in the syntax. It's in an architecture decision: trusting the shape of an incoming object instead of deciding, explicitly, what that object is allowed to contain.

That's a different category of mistake than SQL injection. No query library, no ORM, no parameterization fixes this, because the request itself is completely well-formed. The only thing that catches it is the code choosing what to accept.

Deny Lists Lose Because They Guess

The first instinct is usually to block the dangerous fields by name.

JavaScript
const { password, role, isAdmin, ...safeUpdates } = req.body; await db.users.update(req.user.id, safeUpdates);

This works right up until someone finds a field name you didn't think to block. Maybe the schema also has permissions, or accountTier, or a field added six months from now by a teammate who has no idea this route even exists. A deny list has to correctly predict every dangerous field, forever, including ones that don't exist yet.

That's not a security boundary. That's a guessing game you're guaranteed to eventually lose.

Allow Lists Win Because They Default to No

Flip the logic, and the same endpoint gets much harder to attack.

JavaScript
app.patch("/profile", async (req, res) => { const { name, bio } = req.body; const updated = await db.users.update(req.user.id, { name, bio }); res.json(updated); });

A request body with an unexpected role field passing straight through a deny list, versus an allow list that drops anything not explicitly named. ExpandA request body with an unexpected role field passing straight through a deny list, versus an allow list that drops anything not explicitly named.

Only name and bio ever reach the database call. role, password, permissions, anything the request happened to include that wasn't explicitly named, gets dropped, silently and automatically. The attacker's curl command still succeeds as a request. It just does nothing beyond what the route was actually meant to allow.

The difference is what happens by default. A deny list defaults to trusting anything not explicitly named as dangerous. An allow list defaults to rejecting anything not explicitly named as safe. One of those defaults is forgiving of a mistake you haven't made yet. The other isn't.

What This Sets Up

SQL injection and mass assignment both come from trusting input more than the server should. Neither one needed the attacker to touch the network itself.

The next post covers what happens when someone does exactly that, along with the rest of the injection family that doesn't get its own headline as often as SQL does.

The Essentials

  1. Mass assignment happens when an update route spreads the entire request body onto a record, instead of picking specific fields.
  2. The attack needs no exploit, just a request payload edited in DevTools or curl, sent to a route the attacker already has access to.
  3. This is an architecture decision, not a syntax bug. No linter or library catches it.
  4. Deny lists fail because they require predicting every dangerous field name, forever, including ones added later.
  5. Allow lists win by defaulting to no. Anything not explicitly permitted gets dropped automatically.

Next up: man-in-the-middle attacks, SameSite, and the rest of the injection family, the last stop before this course moves on to requests that aren't even legitimate in the first place.

Further Reading and Watching