TL;DR
- Ask Cursor or Claude Code for an "update profile" route and you often get
prisma.user.update({ data: req.body }). Any logged-in user can add"role": "admin"to the request and it saves. - Point that out and the fix it writes usually strips
roleand nothing else.emailVerified,credits,plan,orgIdand Prisma relation writes still go straight through. - The fix is an allowlist schema that rejects unknown keys, not a denylist. About ten lines of zod or Pydantic.
Here is a test I now run on every route an AI editor writes for me.
I ask for something boring. A settings page with name, bio and avatar. Cursor writes the form, the API route and the database call in under a minute, and all of it works. Then I open devtools, copy the PATCH request, add one field the form never sends, and replay it.
"role": "admin". 200 OK. Refresh. Admin.
The route had an auth check. It even scoped the update to my own user ID. It just never asked which columns I was allowed to change.
The Vulnerable Code
Mass assignment (CWE-915) happens when a route copies the whole request body into a database write, so the client decides which columns change. This is the shape AI editors produce for "update my profile" routes:
// CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes
app.patch('/api/me', requireAuth, async (req, res) => {
const user = await prisma.user.update({
where: { id: req.user.id },
data: req.body,
});
res.json(user);
});
The attack is one extra field:
curl -X PATCH https://app.example.com/api/me \
-H "Content-Type: application/json" \
-H "Cookie: session=..." \
-d '{"name":"Sam","role":"admin","emailVerified":true}'
Mongoose has the same problem. User.findByIdAndUpdate(req.user.id, req.body) drops fields that are not in the schema, but role is in the schema, so it lands.
Python versions look like this:
@app.patch("/api/me")
def update_me(payload: dict, user=Depends(current_user), db=Depends(get_db)):
for key, value in payload.items():
setattr(user, key, value) # CWE-915: the client picks the columns
db.commit()
return {"ok": True}
As a bonus, res.json(user) in the first example sends the password hash back to the browser. That one is a separate bug, but it travels with this one.
The Fix the AI Writes Is a Denylist
When you tell the AI that users can set their own role, it removes the field you named and keeps the rest of the body flowing through:
const { role, ...rest } = req.body;
await prisma.user.update({ where: { id: req.user.id }, data: rest });
That blocks role. It does not block emailVerified, credits, plan, stripeCustomerId, orgId, or the column someone adds next quarter. A denylist has to be updated every time the schema changes, and nobody updates it.
Prisma makes it worse. The data object accepts nested relation writes, so the body can do more than set columns:
{ "organization": { "connect": { "id": "someone-elses-org-id" } } }
If your User model has an organization relation, that payload moves the attacker into another tenant. No column called role involved.
Why This Keeps Happening
AI editors write mass assignment because passing the whole body through is the shortest code that makes the feature work, and CRUD tutorials do it constantly. The model is reproducing the most common pattern it saw, and the most common pattern skips the field check.
The bug is also invisible in review. The route has auth. It has ownership scoping. The form only sends three fields, so every happy-path test passes. Nothing fails until someone edits a request by hand.
TypeScript does not help either. req.body is whatever the client sent at runtime, and Prisma validates field names against your schema. role is a valid field, so Prisma is happy to write it.
Rails learned this the hard way. In 2012 a developer used a mass assignment bug on GitHub to add his own key to the Rails organization and push a commit to the Rails repository. Rails 4 then made strong parameters the default. Express, Fastify, Next.js route handlers and FastAPI have no equivalent default, so the protection exists only if someone writes it. The AI does not.
The Fix
Validate the body against an allowlist schema that names the only fields a user may change, reject unknown keys, and pass only the parsed result to the database. Admin-only fields get their own route with its own role check.
JavaScript with zod:
import { z } from 'zod';
const UpdateMe = z.object({
name: z.string().min(1).max(80).optional(),
bio: z.string().max(500).optional(),
avatarUrl: z.string().url().optional(),
}).strict(); // unknown keys like "role" fail validation
app.patch('/api/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 prisma.user.update({
where: { id: req.user.id },
data: parsed.data,
select: { id: true, name: true, bio: true, avatarUrl: true },
});
res.json(user);
});
Python with Pydantic v2:
from pydantic import BaseModel, ConfigDict, Field
class UpdateMe(BaseModel):
model_config = ConfigDict(extra="forbid") # unknown keys raise a 422
name: str | None = Field(default=None, min_length=1, max_length=80)
bio: str | None = Field(default=None, max_length=500)
avatar_url: str | None = Field(default=None, max_length=2048)
@app.patch("/api/me")
def update_me(payload: UpdateMe, user=Depends(current_user), db=Depends(get_db)):
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(user, key, value)
db.commit()
return {"ok": True}
I use .strict() and extra="forbid" instead of the default behavior on purpose. By default zod strips unknown keys and Pydantic ignores them. That is safe, but silent. Rejecting them turns a probe into a 4xx in your logs, and it makes a test fail loudly the day someone wires the wrong schema to a route.
To find the bug in an existing codebase, this takes about a minute:
grep -rnE "data: *req\.body|\.\.\.req\.body|Update\([^)]*req\.body" src/
grep -rn "setattr(" app/
FAQ
Q: What is mass assignment (CWE-915)?
A: It is a bug where an endpoint writes client-supplied fields straight into a model, so an attacker can set fields they should never control, like role, isAdmin or emailVerified. OWASP's API Security Top 10 (2023) groups it under API3, Broken Object Property Level Authorization.
Q: Does TypeScript or Prisma prevent mass assignment?
A: No. Types are erased at runtime, so req.body contains whatever the client sent. Prisma checks that each field exists in your schema, and role does.
Q: Is removing role from req.body enough?
A: No. A denylist misses every other sensitive field and every field added later. Use an allowlist schema that rejects unknown keys.
I've been running SafeWeave for this. It hooks into Cursor and Claude Code as an MCP server and scans each change before I move on. For this bug class, though, the schema is the real control: a scanner can flag data: req.body, but only you know which fields a user is allowed to touch. Even the grep above, run once a week, will catch most of what's in this post. The important thing is catching it early, whatever tool you use.
Top comments (0)