Spreading req.body directly into a db.user.update({ data: body }) call is mass assignment (CWE-915): any field the attacker includes in the request body will be written to the database. For user records, this means { "role": "admin" } is a single-request privilege escalation. CWE-285 (Improper Authorization) and OWASP A01 (Broken Access Control) cover this exact pattern. NIST 800-53 AC-6 (Least Privilege) requires that users cannot update fields beyond their entitlement — without an explicit allowlist, every field in the database schema is implicitly writable by authenticated users.
High because an authenticated user can escalate to admin privileges with a single crafted PATCH request, requiring no special knowledge beyond knowing which fields exist in the user model.
Parse update requests through a Zod schema that explicitly includes only safe profile fields. Zod silently strips any extra fields — including role, permissions, or isAdmin — before the data reaches the database.
// lib/schemas/user.ts
export const ProfileUpdateSchema = z.object({
displayName: z.string().min(1).max(50).optional(),
bio: z.string().max(500).optional(),
avatarUrl: z.string().url().optional(),
// 'role', 'permissions', 'isAdmin' are intentionally absent
});
// app/api/users/[id]/route.ts
export async function PATCH(req: NextRequest, { params }) {
const session = await auth();
if (!session || session.user.id !== params.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const data = ProfileUpdateSchema.parse(await req.json()); // Strips sensitive fields
await db.user.update({ where: { id: params.id }, data });
}
Add .strict() to the schema to reject (rather than silently strip) requests that include unexpected fields — this surfaces integration bugs faster.
ID: saas-authorization.admin-privilege.no-privilege-escalation-params
Severity: high
What to look for: Count all relevant instances and enumerate each. Find user update endpoints — PATCH /api/users/[id], PUT /api/profile, updateUser Server Actions. Check how request body data is applied to the database update. Specifically: is ...body or req.body spread directly into a db.user.update({ data: body }) call? Are sensitive fields like role, permissions, isAdmin, plan, credits, subscriptionStatus in the update schema? This is a mass assignment vulnerability.
Pass criteria: User update endpoints use an explicit allowlist of updatable fields (via Zod schema or manual field selection) that excludes sensitive fields. At least 1 implementation must be verified. The request body cannot contain role, permissions, isAdmin, or similar fields that would be applied.
Fail criteria: Any user update endpoint spreads the request body directly into a database update without filtering out sensitive fields, or a Zod schema for user updates includes role, permissions, or similar fields without requiring admin authority.
Skip (N/A) when: No user update endpoints found in the codebase.
Detail on fail: "User update at [route] may allow mass assignment. Sensitive fields (role, permissions, isAdmin) could be updated via the request body." (Note the specific file and the pattern used.)
Remediation: Always use an explicit field allowlist for user updates. Zod's .pick() or .omit() methods are the easiest way to enforce this.
// lib/schemas/user.ts
// This schema only allows safe profile fields — no role, no permissions
export const ProfileUpdateSchema = z.object({
displayName: z.string().min(1).max(50).optional(),
bio: z.string().max(500).optional(),
avatarUrl: z.string().url().optional(),
});
// app/api/users/[id]/route.ts
export async function PATCH(req: NextRequest, { params }) {
const session = await auth();
if (!session || session.user.id !== params.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const data = ProfileUpdateSchema.parse(await req.json()); // Strips any extra fields
await db.user.update({ where: { id: params.id }, data });
}
The key is that ProfileUpdateSchema.parse() will strip any fields not in the schema — so even if an attacker sends { "role": "admin" }, Zod will discard it silently (or throw if .strict() is used).