# Patch Analysis — CVE-2026-57518 (Pagekit CMS 1.0.18)

## Summary

There is **no patch/fix** for CVE-2026-57518. The Pagekit project was archived
on 2023-12-01; version **1.0.18** (commit `95446c5f08fc43993fad5e2b581743ffcce50c35`,
2020-01-20) is the only git tag and the latest commit. The CHANGELOG's most
recent security entry is "CSRF vulnerability in Finder" in 1.0.18; nothing
addresses the `saveAction` role-assignment authorization gap.

Because no upstream fix exists, this analysis treats the **incomplete
authorization check itself** as the "fix" boundary that any remediation must
close, and documents what a correct fix must cover so that the variant found in
this stage (and the parent trigger) are both blocked.

## What the (would-be) fix must change

### Vulnerable code

`app/system/modules/user/src/Controller/UserApiController.php`, `saveAction()`
(lines ~178–183):

```php
$key    = array_search(Role::ROLE_ADMINISTRATOR, @$data['roles'] ?: []);
$add    = false !== $key && !$user->isAdministrator();
$remove = false === $key && $user->isAdministrator();

if (($self && $remove) || !App::user()->isAdministrator() && ($remove || $add)) {
    App::abort(403, 'Cannot add/remove Admin Role.');
}
```

`Role::ROLE_ADMINISTRATOR` is the constant `3`
(`app/system/modules/user/src/Model/Role.php`). The controller class is gated by
`@Access("user: manage users")`, so any authenticated user holding the
`user: manage users` permission may call every action, including `saveAction`
and `bulkSaveAction`.

### What the check actually does

It tests **only** whether the built-in Administrator role (id=3) is being added
to or removed from the target user. If the caller is not an administrator and is
adding/removing role id=3, it aborts with 403. **Every other role id is
unconditionally accepted.** Custom roles created by an administrator receive
ids 4, 5, 6, … and are never subjected to any authorization check.

### What a correct fix must do

Validate **every** role in `$data['roles']`, not just id=3. A non-administrator
caller must be forbidden from assigning any role whose permissions are
"trusted"/elevated (or, more simply, from assigning any role they themselves do
not already hold, or any role that grants a permission the caller lacks). The
`trusted` flag already exists in the permission registry
(`app/installer/index.php`, `app/system/modules/user/index.php`,
`app/system/modules/settings/index.php`) and should be enforced server-side
(see "Assumptions" below).

## Assumptions the current check makes (and why they fail)

1. **"Only the Administrator role is dangerous."** False. Pagekit lets an
   administrator create custom roles carrying arbitrary permissions, including
   `trusted` ones such as `system: manage packages`, `system: software updates`,
   `user: manage user permissions`, and `system: access system settings`. Any of
   these, when self-assigned by a low-privileged user, leads to full
   administrative capability and RCE.

2. **"The `trusted` permission flag is enforced."** It is **not**. A repository
   search for `trusted` shows the flag is consumed **only** in Vue.js admin views
   (`app/system/modules/user/views/admin/role-index.php`,
   `permission-index.php`) to render an advisory warning icon ("Grant this
   permission to trusted roles only to avoid security implications."). No
   server-side code path reads or enforces `trusted`. Consequently,
   `system: software updates` (explicitly `'trusted' => true` in
   `app/installer/index.php`) is just as self-assignable as any non-trusted
   permission.

3. **"Self-assignment is the only escalation vector."** The check is keyed on
   `$self = App::user()->id == $user->id`, but the blocking branch is
   `!App::user()->isAdministrator() && ($remove || $add)` — which is independent
   of `$self` for the add/remove-admin case. For custom roles, both `$add` and
   `$remove` are `false`, so the branch never fires regardless of who the target
   is. A non-administrator with `user: manage users` can therefore inject a
   privileged custom role into **any** user account (including a brand-new
   account created via `POST /api/user/` with `id=0`), not only their own.

## Code paths the (would-be) fix does NOT cover

The parent reproduction exercises one post-escalation RCE sink. This stage
identified and **confirmed at runtime** a second, distinct sink that the same
escalation reaches and that any complete fix must also neutralize:

### Variant sink: UpdateController + SelfUpdater (CONFIRMED)

`app/installer/src/Controller/UpdateController.php`:

```php
/** @Access("system: software updates", admin=true) */
class UpdateController {
    /** @Request({"url": "string"}, csrf=true) */
    public function downloadAction($url) {
        $file = tempnam(App::get('path.temp'), 'update_');
        App::session()->set('system.update', $file);
        if (!file_put_contents($file, @fopen($url, 'r'))) {
            App::abort(500, 'Download failed or path not writable.');
        }
        return [];
    }
    /** @Request(csrf=true) */
    public function updateAction() {
        if (!$file = App::session()->get('system.update')) { App::abort(400, ...); }
        App::session()->remove('system.update');
        return App::response()->stream(function () use ($file) {
            $updater = new SelfUpdater($output);
            $updater->update($file);   // extracts ZIP over App::path() (Pagekit root)
        });
    }
}
```

`app/installer/src/SelfUpdater.php::update()` lists the ZIP entries, filters out
`.htaccess` and the `packages/` / `storage/` prefixes, requires the archive to
contain `app/installer/requirements.php` (included via the `zip://` stream
wrapper and expected to return an object whose `getFailedRequirements()` is
empty), then `$zip->extractTo(App::path(), $fileList)` writes every remaining
entry over the Pagekit root. A webshell placed at the archive root lands at
`/var/www/html/<name>.php` and is executed directly by Apache because the root
`.htaccess` only rewrites non-existent paths
(`RewriteCond %{REQUEST_FILENAME} !-f`).

`downloadAction`'s `$url` is fully attacker-controlled; a `file://` URL makes the
server copy a locally planted archive with no outbound network required, so the
sink is reachable in completely isolated/sandboxed deployments.

### Other sinks surveyed (rule-out)

| Path | File | Outcome |
|------|------|---------|
| `POST /api/user/bulk` (`bulkSaveAction`) | `UserApiController.php` | Calls `saveAction` directly — **same** function/check, not a distinct entry point. |
| `ProfileController::saveAction` | `ProfileController.php` | Calls `$user->save()` with **no `$data`**; only name/email/password are set. No role injection possible. **Ruled out.** |
| `RegistrationController::registerAction` | `RegistrationController.php` | Creates user with explicit fields only; roles not derived from request data (only `ROLE_AUTHENTICATED` added by the `@Saving` hook). **Ruled out.** |
| `RoleApiController::saveAction` | `RoleApiController.php` | Gated by `@Access("user: manage user permissions")` — a **different** permission/trust boundary; manages roles, not user→role assignment. Not reachable by a `user: manage users`-only account. **Ruled out as a variant of this CVE.** |
| `InstallerController` (`/installer/install`) | `InstallerController.php` | Route `/installer` is registered **only** when `installer.enabled` config is true (default `false`, disabled after install). **Ruled out post-install.** |
| `PackageController` (`/admin/system/package`) | `PackageController.php` | The **parent** reproduction's sink (`system: manage packages`). Confirmed in the parent RCA; not a new variant. |

## Behavior before/after (since no fix exists)

There is no "after." On the only available version (1.0.18), a non-administrator
with `user: manage users` can self-assign a custom role carrying
`system: software updates` and then drive `UpdateController::downloadAction` +
`updateAction` to overwrite arbitrary files under the Pagekit root with
attacker-supplied PHP, achieving RCE as `www-data`. This is in addition to the
parent's `system: manage packages` → package-installer RCE.

## Target threat-model scope

Pagekit ships no `SECURITY.md` and no formal threat model. The permission
registry's `trusted` flag is the closest thing to a stated security boundary,
and it is advisory-only (UI). Nothing in the codebase excludes privilege
escalation or RCE from scope; the parent CVE itself classifies the
`saveAction` escalation → RCE as a high-severity vulnerability (CVSS 8.8). The
variant found here operates entirely within that same scope and trust boundary
(authenticated low-privileged user → network HTTP API → server-side code
execution), so it is a valid in-scope finding.
