A low-privilege account quietly gains powers it was never granted — and starts acting as admin.
Privilege escalation is when an account ends up with more access than it was meant to have. Vertical escalation moves up a level — a normal user becomes an admin. Horizontal escalation stays at the same level but reaches sideways — user A reads user B’s data. Both come from the same root mistake: the authorization decision is made in the wrong place, or the server trusts something the client controls.
The whole incident lives in one design choice: does the server let the client decide an authorization-relevant field? The bad handler copies the request body straight onto the user, so role rides in like any other field. The fixed handler binds only an allowlist of safe fields, and gates any role change behind a server-side require_role('admin') check that the client cannot influence.
# BAD — mass assignment: client controls every field, including role
def update_profile(request, user):
user.update(request.body) # {name, bio, role:'admin'} all written
return user # mallory is now admin
# FIXED — allowlist fields, decide authz on the server
EDITABLE = {'name', 'bio', 'avatar_url'} # role is NOT here
def update_profile(request, user):
fields = {k: v for k, v in request.body.items() if k in EDITABLE}
user.update(fields) # role can never be set this way
return user
# Role changes live behind a separate, audited, admin-only path
def change_role(request, target_user):
require_role(request.actor, 'admin') # deny by default
new_role = validate_role(request.body['role'])
audit_log(actor=request.actor, target=target_user,
change=new_role, approver=request.approver)
target_user.set_role(new_role)
return target_user
| Choice | Benefit | Cost |
|---|---|---|
| Deny-by-default authz | A missed check fails closed, not open | Every new privileged path needs an explicit grant |
| Field allowlisting | Client can never bind role or is_admin | You maintain the editable-field set per model |
| Central policy engine | One audited place to reason about access | Indirection; a policy outage blocks everything |
| Scattered inline checks | Simple to write the first one | Easy to forget one; no single source of truth |
| Audited approval for role changes | Every elevation has an owner and a record | Slower path; needs an approver workflow |
role or is_admin from the request body lets the client grant themselves power.Mallory signs up as a normal user. The profile-edit endpoint reflects the whole request body into the model, so she sends {"name":"M","role":"admin"}. The server writes it blindly and her role flips to admin. She now reaches /admin/users and starts managing accounts. Triage catches it: a days-old, low-trust account is hitting admin-only endpoints, and the audit log shows a role change with no approver. Containment forces her role back to user, invalidates her sessions, and blocks the endpoint. The fix is structural — allowlist the editable fields and require a server-side require_role('admin') before any role change can happen.
Hiding the admin button in the UI is enough to prevent escalation.