Skip to content

Security: fix privilege escalation and mass assignment in UserController - #843

Merged
edwh merged 5 commits into
developfrom
security/c1-c2-m1-privilege-escalation
Apr 24, 2026
Merged

Security: fix privilege escalation and mass assignment in UserController#843
edwh merged 5 commits into
developfrom
security/c1-c2-m1-privilege-escalation

Conversation

@edwh

@edwh edwh commented Apr 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes three related vulnerabilities in UserController.php identified in a responsible disclosure from iFixit Engineering:

  • C1 — Privilege escalation: postProfileInfoEdit, postProfilePasswordEdit, postProfilePictureEdit and postAdminEdit all accepted an id parameter from the POST body without verifying the caller owned that account or had permission to edit it. Any authenticated user could promote themselves to ROOT with a single request (POST /profile/edit-admin-settings with user_role=1).
  • C2 — Mass assignment via edit(): The method passed $request->post() (minus a few unset keys) directly to User::update(), allowing any caller to inject arbitrary fillable fields — including role and api_token — via the POST body.
  • M1 — Sensitive fields in User::$fillable: role and api_token were mass-assignable. Removing them from $fillable adds model-level protection independent of controller logic.

Changes

app/Http/Controllers/UserController.php

  • postProfileInfoEdit, postProfilePasswordEdit, postProfilePictureEdit: added ownership check — abort 403 if id != Auth::id() and caller is not Administrator
  • postAdminEdit: requires Administrator role unconditionally (previously no check at all)
  • postAdminEdit: sets $user->role directly rather than via update([]) since role is no longer mass-assignable
  • edit(): replaced $request->post() + denylist with $request->only([...]) allowlist covering only the fields a web edit is expected to modify

app/User.php

  • Removed role and api_token from $fillable

Code Quality Review

  • All existing profile edit tests continue to pass — the happy paths (user editing themselves, admin editing others) are unchanged
  • api_token is set via direct property assignment in ensureAPIToken() — not affected
  • role is set via direct assignment in the patched postAdminEdit — not affected
  • No other callers of User::update() were found to rely on mass-assigning role or api_token

Test Plan

  • New PrivilegeEscalationTest — 8 tests, 12 assertions, all green
    • Restarter cannot edit another user's info, password, or photo via forged id
    • Restarter cannot access admin edit settings (role change blocked)
    • Admin CAN change role via admin edit settings
    • Admin CAN edit another user's info
    • User cannot escalate role via edit() endpoint
    • User cannot overwrite api_token via edit() endpoint
  • Full PHPUnit suite (CI)

… M1)

C1 — Authorization checks on profile edit endpoints:
- postProfileInfoEdit, postProfilePasswordEdit, postProfilePictureEdit: abort(403)
  if the request id differs from Auth::id() and the caller is not an Administrator
- postAdminEdit: requires Administrator role unconditionally; previously any
  authenticated user could set any account to ROOT with a single POST request

C2 — Replace $request->post() mass assignment in edit() with allowlist:
- Switch from unset()-based denylist to $request->only([...]) covering only the
  fields a web edit is expected to modify; password handled separately
- Removes the possibility of injecting role, api_token, or any other sensitive
  field via the POST body

M1 — Remove role and api_token from User::$fillable:
- role is now set explicitly ($user->role = ...; $user->save()) in postAdminEdit
- api_token is always set via direct property assignment in ensureAPIToken()
- Removing these from fillable adds model-level protection independent of
  controller logic

Adds PrivilegeEscalationTest covering all eight cases (4 blocked, 2 admin
allowed, 2 mass-assignment blocked).
@edwh
edwh force-pushed the security/c1-c2-m1-privilege-escalation branch from a791dd5 to b02d3d1 Compare April 24, 2026 12:20
edwh and others added 4 commits April 24, 2026 14:17
… fix

Removing from fillable breaks user creation in factories, seeders and
the Taskfile setup for Playwright tests (firstOrCreate with role=>2
silently discards the role, so jane@bloggs.net is not admin and the
event approval UI never renders). The real M1 fix is the abort(403)
guards in UserController, not the fillable restriction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
role and api_token are now excluded from $fillable to prevent privilege
escalation via mass assignment (security: C2/M1). All internal code that
legitimately sets these fields has been updated to use direct property
assignment ($user->role = X; $user->save()) instead.

Factory states (administrator, host, etc.) now use afterCreating callbacks
so they work correctly without relying on mass assignment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Factories use Model::unguarded() internally so definition() attributes
bypass \$fillable — no afterCreating callbacks needed. The previous
afterCreating approach caused segfaults from double save() calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@edwh
edwh merged commit 056c4c6 into develop Apr 24, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant