Mass Assignment
The laziest vulnerability
Mass assignment happens when you accept whatever the client sends and use it directly. The developer intends for the user to set title and body. The attacker also sends user_id or role and the server happily stores it.
This is a different kind of vulnerability from the previous ones. There is no code injection and no path manipulation. The attacker simply sends extra fields that the server should not accept.
The vulnerable code
Look at the note creation route from the project setup:
route.post("/notes", {
resolve: async (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const body = await c.request.json();
const id = crypto.randomUUID();
// DELIBERATELY VULNERABLE — body.user_id can override ownership
db.prepare("INSERT INTO notes (id, user_id, title, body, tags) VALUES (?, ?, ?, ?, ?)").run(
id,
body.user_id ?? user.id,
body.title,
body.body,
body.tags ?? "",
);
return Response.json({ id }, { status: 201 });
},
}); The line body.user_id ?? user.id is the problem. If the client sends a user_id field, it overrides the authenticated user’s ID.
The attack
Alice creates a note but sets user_id to the admin’s ID:
curl -b cookies.txt -X POST http://localhost:3000/notes \
-H "Content-Type: application/json" \
-d '{"title":"Sneaky Note","body":"Created by Alice, owned by admin","user_id":"admin-1"}' The note is created with user_id: "admin-1". Now the admin sees a note they did not create. Depending on the app, this could let Alice inject content into the admin’s view, or bypass ownership checks on shared resources.
In a more dangerous scenario, imagine a user profile update route:
// VERY VULNERABLE — do not do this
route.put("/me", {
resolve: async (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const body = await c.request.json();
// Updates whatever fields the client sends, including role!
db.prepare("UPDATE users SET name = ?, role = ? WHERE id = ?").run(
body.name ?? user.name,
body.role ?? user.role,
user.id,
);
return Response.json({ message: "Updated" });
},
}); The attacker sends {"name": "Alice", "role": "admin"} and promotes themselves to admin.
The fix: explicit field picking
Never spread or passthrough the entire request body. Pick only the fields you expect:
route.post("/notes", {
resolve: async (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const body = await c.request.json();
const id = crypto.randomUUID();
// SAFE: only pick title, body, and tags. user_id comes from the session.
const title = body.title;
const noteBody = body.body;
const tags = body.tags ?? "";
if (!title || !noteBody) {
return Response.json({ error: "Title and body are required" }, { status: 400 });
}
db.prepare("INSERT INTO notes (id, user_id, title, body, tags) VALUES (?, ?, ?, ?, ?)").run(
id,
user.id,
title,
noteBody,
tags,
);
return Response.json({ id }, { status: 201 });
},
}); The user_id always comes from the authenticated session, never from the request body. Even if the attacker sends user_id, it is ignored.
Zod makes this easier
Zod schemas strip unknown fields by default. Define a schema with only the allowed fields:
const CreateNoteBody = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1),
tags: z.string().optional().default(""),
});
route.post("/notes", {
request: { body: CreateNoteBody },
resolve: (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
const { title, body: noteBody, tags } = c.input.body;
const id = crypto.randomUUID();
db.prepare("INSERT INTO notes (id, user_id, title, body, tags) VALUES (?, ?, ?, ?, ?)").run(
id,
user.id,
title,
noteBody,
tags,
);
return Response.json({ id }, { status: 201 });
},
}); Zod validates that the body matches the schema and discards any extra fields. If the attacker sends user_id, Zod ignores it because user_id is not in the schema.
The rule
Never use raw request bodies for database operations. Either pick fields explicitly or use a validation schema that defines exactly which fields are accepted. Server-controlled fields (user_id, role, created_at) should never come from the client.
Exercises
Exercise 1: Before fixing, try creating a note with user_id: "admin-1". Verify the note is owned by the admin.
Exercise 2: Apply the explicit field picking fix. Try the same attack. The user_id should be ignored, and the note should be owned by Alice.
Exercise 3: Add a Zod schema. Send {"title":"Test","body":"Body","role":"admin","user_id":"admin-1"}. Verify both extra fields are silently discarded.
Why is mass assignment dangerous even when the database requires specific columns?