CVE case study

CVE-2026-62666: A User Manager Could Mint a Super-Admin API Key

A delegated user manager could create an API key for a super-admin account. The key inherited the target account's authority, turning a limited role into full instance control.

Severity
High (8.8)
Scoring
CVSS 3.1
Weakness
CWE-639 · CWE-862
Affected
grav-plugin-api 1.0.5 and earlier
Remediation state
Upgrade to grav-plugin-api 1.0.6
Advisory published
29 Jun 2026

Official vectorCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Why it matters

Grav allowed a non-super user manager with api.users.write to mint an API key for another account. The username in the route selected the account that owned the new key.

When that target held api.super, the key authenticated as the super user. Stored key scopes were not enforced at the authorization boundary, so the new credential could reach super-only API operations and turn delegated user management into full instance control.

Related 2FA generation and disable operations also missed the super-target guard. Those sibling paths could weaken a super-admin's 2FA, but the API-key path was the route to privilege escalation.

How I found it

I compared the endpoints covered by the earlier GHSA-p97c fix with sibling routes that act on the same /users/{username} target.

Update, create, and delete rejected a non-super caller acting on a super target. API-key creation, API-key deletion, 2FA generation, and 2FA disable did not apply the same relationship check.

I followed the new key into authentication. The requested scopes were stored but not enforced, so a key bound to an api.super user carried that user's full authority. A disposable local reproduction used the key to create a second synthetic super account, then the public patch confirmed the shared target guard and scope enforcement.

Root cause

The earlier GHSA-p97c fix blocked non-super callers from modifying or deleting a super target, but that policy was copied into selected handlers instead of applied to every mutation under /users/{username}.

requireApiKeyPermission() checked whether a non-self caller had api.users.write. It did not check whether the selected target granted super privileges.

The created key was bound to the selected user, while the authenticator returned that full user identity without applying the stored scopes. The super-admin short circuit then allowed privileged operations.

Source-to-sink trace

  1. 01
    User-controlled target/api/v1/users/{username}/api-keys

    The route username selects the account that will own the new API key.

  2. 02
    Incomplete policy gateUsersController::requireApiKeyPermission()

    A non-self caller needs api.users.write, but 1.0.5 does not compare a non-super caller with a super target.

  3. 03
    Credential sinkUsersController::createApiKey()

    The new credential is generated for the selected target user.

  4. 04
    Scope amplifierApiKeyAuthenticator and AbstractApiController

    Stored scopes do not cap the returned identity before the super-admin authorization shortcut.

Safe proof of concept

Prerequisites

  • Git and Python 3.
  • Network access only to clone the public grav-plugin-api repository; every analysis command after cloning runs locally.

Step-by-step reproduction

  1. Clone the public repository into a unique temporary directory and export UsersController.php from 1.0.5 and the fixed commit.
  2. Run the handler census below. Compare the super-target checks on the primary user mutators with the API-key and 2FA sibling methods.
  3. Inspect the affected authentication path to confirm that an API key returns the user bound to it and that stored scopes do not stop the super-admin shortcut.
  4. Run the synthetic policy model using a non-super caller with api.users.write and a super target. It represents the missing relationship decision without minting a real key.
  5. Confirm that the affected policy allows the action while the fixed policy denies it.
  6. Inspect the 1.0.6 patch and verify that the shared target guard is called by API-key and 2FA operations and that non-empty key scopes are checked before the super shortcut.

Compare the affected and fixed user-target gates

LAB_DIR=$(mktemp -d "\${TMPDIR:-/tmp}/grav-api-cve.XXXXXX")
git clone https://github.com/getgrav/grav-plugin-api.git "$LAB_DIR/api"

git -C "$LAB_DIR/api" show 1.0.5:classes/Api/Controllers/UsersController.php \
  > "$LAB_DIR/users-affected.php"
git -C "$LAB_DIR/api" show dfcc947f0d6758772caac290c68fe8d4c4a4874e:classes/Api/Controllers/UsersController.php \
  > "$LAB_DIR/users-fixed.php"

rg -n -C 8 \
  'function (createApiKey|deleteApiKey|generate2fa|disable2fa)|requireApiKeyPermission|requireNotSuperTarget|accessGrantsSuper' \
  "$LAB_DIR/users-affected.php" "$LAB_DIR/users-fixed.php"

Inspect the affected key and authorization path

git -C "$LAB_DIR/api" show 1.0.5:classes/Api/Auth/ApiKeyAuthenticator.php \
  > "$LAB_DIR/authenticator-affected.php"
git -C "$LAB_DIR/api" show 1.0.5:classes/Api/Controllers/AbstractApiController.php \
  > "$LAB_DIR/authorization-affected.php"

rg -n -C 7 'return \$user|scopes|isSuperAdmin|requirePermission' \
  "$LAB_DIR/authenticator-affected.php" "$LAB_DIR/authorization-affected.php"

Synthetic target-policy proof

from dataclasses import dataclass

@dataclass
class Identity:
    name: str
    is_super: bool
    users_write: bool

def affected(caller, target):
    return caller.is_super or caller.users_write

def fixed(caller, target):
    permission_ok = caller.is_super or caller.users_write
    target_ok = caller.name == target.name or caller.is_super or not target.is_super
    return permission_ok and target_ok

manager = Identity("lab-manager", is_super=False, users_write=True)
super_user = Identity("lab-super", is_super=True, users_write=True)

print("affected:", "ALLOW" if affected(manager, super_user) else "DENY")
print("fixed:   ", "ALLOW" if fixed(manager, super_user) else "DENY")

Expected evidence

  • The affected controller shows api.users.write checks on the sibling operations without the super-target guard used by the primary mutators.
  • The affected authentication path returns the key's bound user, while the authorization path reaches the super-admin shortcut without enforcing stored scopes.
  • The synthetic model prints affected: ALLOW and fixed: DENY. No API key or account is created.
Negative control

Change the target to a non-super account or the caller to an actual super-admin. The fixed model allows those legitimate management cases while still denying the non-super-to-super transition.

Fixed-version re-test

On 1.0.6, every API-key and 2FA action by a non-super manager against a super target must fail with 403. Self-service and actions by a real super-admin must continue to work, and non-empty key scopes must cap authorization before any super shortcut.

Impact

A non-super account with delegated api.users.write could mint a key for an api.super account and gain full API-level control of the instance, including creation of another super account.

The exploit required the API plugin, a delegated user-management account, and a target carrying api.super. It did not turn a completely unauthenticated visitor into an administrator.

The separate 2FA routes allowed rotation or removal of a super-admin's 2FA. That was a security-control downgrade, not account takeover by itself.

Fix and retest

Upgrade to grav-plugin-api 1.0.6. The patch added a shared requireNotSuperTarget() check to the API-key and 2FA operations and also enforced non-empty API-key scope lists before the super-admin shortcut.

Retest every username-targeted mutation. A non-super manager must receive 403 for API-key and 2FA actions against a super target, while legitimate self-service and actions performed by an actual super-admin must continue to work.

Engineering lesson

A fix for one controller action is incomplete when sibling actions mutate the same protected object. Centralize target-sensitive checks, enumerate the entire route family, and enforce credential scopes before privilege shortcuts.

References

From evidence to assessment

Need this kind of risk assessed in your environment?

Authorized engagements begin with a fit check, written scope, and the outcome your team needs from the assessment.

Explore security services Start an assessment enquiry