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
- 01User-controlled target
/api/v1/users/{username}/api-keysThe route username selects the account that will own the new API key.
- 02Incomplete policy gate
UsersController::requireApiKeyPermission()A non-self caller needs
api.users.write, but 1.0.5 does not compare a non-super caller with a super target. - 03Credential sink
UsersController::createApiKey()The new credential is generated for the selected target user.
- 04Scope amplifier
ApiKeyAuthenticator and AbstractApiControllerStored 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
- Clone the public repository into a unique temporary directory and export
UsersController.phpfrom 1.0.5 and the fixed commit. - Run the handler census below. Compare the super-target checks on the primary user mutators with the API-key and 2FA sibling methods.
- 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.
- Run the synthetic policy model using a non-super caller with
api.users.writeand a super target. It represents the missing relationship decision without minting a real key. - Confirm that the affected policy allows the action while the fixed policy denies it.
- 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.writechecks 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: ALLOWandfixed: DENY. No API key or account is created.
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.
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.