Mass Assignment Allows Role Escalation
Any authenticated user can promote their own account to admin by including a 'role' field in the body of a PATCH /api/users/me request. The endpoint deserialises the entire request body onto the user record without an allowlist, so the role field is treated as a normal updatable field rather than a protected one.
CriticalIntermediateSecurity testingAPI testingManual testing
// UNDERSTAND
// Symptoms
- A standard user sends PATCH /api/users/me with { "role": "admin" } and the response body shows role: 'admin'
- After the PATCH, the same user can call admin-only endpoints that previously returned 403
- The role field appears in the user object returned by GET /api/users/me after the update
- No 403 Forbidden or 422 Unprocessable Entity is returned when a non-admin submits a role field
// Root Cause
- The PATCH handler passes the raw request body to the ORM's update method (e.g. User.update(userId, req.body)) without an allowlist of updatable fields. Every key in the payload โ including role โ is written to the database.
- A field-level allowlist that restricts which fields a user can update on their own record is absent from the handler. The role field is not marked as non-writable via the API, so it is treated identically to display_name or email.
// Where It Appears
- User profile update endpoints that accept a JSON body without explicit field filtering
- REST APIs generated by frameworks or ORMs that auto-bind request body properties to model attributes
- Multi-tenant SaaS applications where role and plan fields are stored on the same user record as profile data
- Any PATCH or PUT endpoint where the allowed-fields list was not explicitly defined
// REPRODUCE & TEST
// How to Reproduce
- 01Log in as a standard user and obtain the bearer token
- 02Send GET /api/users/me with the bearer token and record the current role field value (e.g. 'viewer')
- 03Send PATCH /api/users/me with body { "role": "admin" } and the same bearer token
- 04Read the role field in the PATCH response body
- 05Send GET /api/users/me again and confirm the role field is now 'admin'
// Test Data Needed
- A standard user account (not an admin) with a valid bearer token
- A way to send a raw PATCH request with a controlled body (Postman or curl)
// Manual Testing Ideas
- Submit a PATCH /api/users/me with { "role": "admin" } and observe whether the role changes
- Also try other privileged fields: { "isAdmin": true }, { "plan": "enterprise" }, { "credits": 99999 } โ mass assignment often affects multiple fields
- After a successful role escalation, test whether the elevated role actually grants admin API access by calling an admin-only endpoint
- Confirm the field is truly protected by verifying that an explicit admin PUT /api/admin/users/{id} endpoint correctly requires admin auth
- Test every user-facing update endpoint (PATCH /api/profile, PUT /api/account) for the same vulnerability
// API Testing Ideas
- Authenticate as a standard user; capture the bearer token
- Send GET /api/users/me; record the role field โ confirm it is 'viewer' (not 'admin')
- Send PATCH /api/users/me with body { "role": "admin" } and the bearer token
- Assert the response status is 200 but the role in the response body is still 'viewer' โ not 'admin'
- Send GET /api/users/me again and assert role is still 'viewer'
- If role is 'admin' in either response, the mass assignment bug is confirmed
// Automation Idea
Authenticate as a standard user. Send PATCH /api/users/me with { "role": "admin" }. Immediately send GET /api/users/me and assert the role field is still 'viewer' (or whatever the user's original role was). If the GET returns role: 'admin', fail the test and report the escalation. Extend the test to other privileged fields: isAdmin, plan, credits.
// Expected Result
PATCH /api/users/me ignores or rejects any attempt to update the role field. The response returns the unchanged role, and a subsequent GET /api/users/me confirms the role was not modified.
// Actual Result (Example)
PATCH /api/users/me with body { "role": "admin" } returns 200 OK with the response body showing role: 'admin'. A subsequent GET /api/users/me confirms the role was persisted as 'admin'. The user now has admin-level access to all protected endpoints.
// REPORT IT
Example Bug Report
- Title
- Standard user can escalate their own role to admin via PATCH /api/users/me
- Severity
- Critical
- Environment
- Staging environment Postman Standard user account (initial role: viewer) Bearer token from authenticated request
- Steps to Reproduce
- 01Log in as a standard user and copy the bearer token from DevTools
- 02Send GET /api/users/me with the bearer token; confirm role is 'viewer'
- 03Send PATCH /api/users/me with body { "role": "admin" } and the bearer token
- 04Read the role field in the response body
- 05Send GET /api/users/me again and read the role field
- Expected Result
- The role field remains 'viewer' in both the PATCH response and the subsequent GET.
- Actual Result
- The PATCH response returns role: 'admin'. GET /api/users/me confirms role: 'admin'. The user can now access admin-only endpoints.
- Impact
- Any authenticated user can permanently escalate their own account to admin, gaining unrestricted access to all protected data and actions across the application.