CVE-2026-42843
Description
Grav API Plugin is a RESTful API for Grav CMS that provides full headless access to your site's content, media, configuration, users, and system management. Prior to 1.0.0-beta.15, an insecure direct object reference and logic flaw in the Grav API plugin (UsersController::update) allows any authenticated user with basic API access (api.access) to modify their own permission configuration. An attacker can exploit this to escalate their privileges to Super Administrator (admin.super and api.super), leading to full system compromise and potential RCE. This vulnerability is fixed in 1.0.0-beta.15.
Affected products
1Patches
126f529c7d438fix for GHSA-r945-h4vm-h736
4 files changed · +309 −2
CHANGELOG.md+6 −0 modified@@ -1,3 +1,9 @@ +# v1.0.0-beta.15 +## 04/27/2026 + +1. [](#bugfix) + * **Security: privilege escalation via self-edit (GHSA-r945-h4vm-h736).** `PATCH /users/{username}` allowed any authenticated user with `api.access` to send an `access` payload against their own profile and self-promote to super admin. The self-edit branch only required `api.access` (not `api.users.write`), but the field whitelist still included `access` (and `state`) for everyone — overwriting `access.api.super` / `access.admin.super` on yourself granted full system control and a Twig-template path to RCE. `UsersController::update` now splits the whitelist into self-editable fields (email, fullname, title, language, content_editor, twofa_enabled) and admin-only fields (state, access); a non-manager that sends `access` or `state` in the body now gets a `403 Forbidden` with an explicit "requires the 'api.users.write' permission" message instead of having the field silently land. Managers (super-admin or `api.users.write`) keep full control over both fields, including on their own account. New regression test `UsersControllerUpdatePrivescTest` pins the boundary across five cases: low-priv self-edit of `access` rejected (and access map verified untouched), low-priv self-edit of `state` rejected, low-priv self-edit of plain profile fields succeeds, an admin updates another user's `access` field, and a user holding `api.users.write` self-edits their own `access`. `Grav\Framework\Acl\Permissions` and `Grav\Common\Utils::arrayFlattenDotNotation` were added as minimal stubs in `tests/Stubs/GravStubs.php` so `PermissionResolver` can be exercised in unit tests without the Grav core on the classpath. + # v1.0.0-beta.14 ## 04/25/2026
classes/Api/Controllers/UsersController.php+19 −2 modified@@ -194,6 +194,8 @@ public function update(ServerRequestInterface $request): ResponseInterface // Users can update themselves with just api.access, otherwise need api.users.write $isSelf = $currentUser->username === $username; + $canManageUsers = $this->isSuperAdmin($currentUser) + || $this->hasPermission($currentUser, 'api.users.write'); if (!$isSelf) { $this->requirePermission($request, 'api.users.write'); } else { @@ -211,8 +213,23 @@ public function update(ServerRequestInterface $request): ResponseInterface throw new ValidationException('Request body must contain fields to update.'); } - // Partial update - only update provided fields - $allowedFields = ['email', 'fullname', 'title', 'state', 'language', 'content_editor', 'access', 'twofa_enabled']; + // Privilege-sensitive fields are gated on api.users.write. Without this + // split a self-edit (api.access only) could PATCH `access` and grant + // itself api.super / admin.super — see GHSA-r945-h4vm-h736. + $selfFields = ['email', 'fullname', 'title', 'language', 'content_editor', 'twofa_enabled']; + $adminFields = ['state', 'access']; + + if (!$canManageUsers) { + foreach ($adminFields as $field) { + if (array_key_exists($field, $body)) { + throw new ForbiddenException( + "Modifying '{$field}' requires the 'api.users.write' permission." + ); + } + } + } + + $allowedFields = $canManageUsers ? array_merge($selfFields, $adminFields) : $selfFields; foreach ($allowedFields as $field) { if (array_key_exists($field, $body)) { $user->set($field, $body[$field]);
tests/Stubs/GravStubs.php+39 −0 modified@@ -544,6 +544,45 @@ public function getDependencies(array $slugs): array { return []; } } } +namespace Grav\Common { + if (!class_exists(\Grav\Common\Utils::class, false)) { + /** + * Minimal Utils stub. Currently only arrayFlattenDotNotation() is + * exercised by unit tests (via PermissionResolver). + */ + class Utils + { + public static function arrayFlattenDotNotation(array $array, string $prepend = ''): array + { + $results = []; + foreach ($array as $key => $value) { + if (is_array($value) && !empty($value)) { + $results = array_merge($results, self::arrayFlattenDotNotation($value, $prepend . $key . '.')); + } else { + $results[$prepend . $key] = $value; + } + } + return $results; + } + } + } +} + +namespace Grav\Framework\Acl { + if (!class_exists(\Grav\Framework\Acl\Permissions::class, false)) { + /** + * Minimal Permissions stub so PermissionResolver can be constructed. + * Only resolvedMap() touches getInstances(); resolve() reads only the + * user's access array, so most unit tests get away with an empty stub. + */ + class Permissions + { + /** @return array<string, object> */ + public function getInstances(): array { return []; } + } + } +} + namespace Grav\Common\Data { if (!class_exists(\Grav\Common\Data\Data::class, false)) { /**
tests/Unit/Controllers/UsersControllerUpdatePrivescTest.php+245 −0 added@@ -0,0 +1,245 @@ +<?php + +declare(strict_types=1); + +namespace Grav\Plugin\Api\Tests\Unit\Controllers; + +use Grav\Common\Config\Config; +use Grav\Common\User\Interfaces\UserInterface; +use Grav\Framework\Acl\Permissions; +use Grav\Plugin\Api\Controllers\UsersController; +use Grav\Plugin\Api\Exceptions\ForbiddenException; +use Grav\Plugin\Api\Tests\Unit\TestHelper; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ServerRequestInterface; + +/** + * Regression tests for GHSA-r945-h4vm-h736. + * + * The advisory describes a self-edit IDOR where any user holding `api.access` + * could PATCH /users/{self} with an `access` payload and self-promote to + * super-admin. The fix gates `state` and `access` on `api.users.write`; these + * tests pin that boundary. + */ +#[CoversClass(UsersController::class)] +class UsersControllerUpdatePrivescTest extends TestCase +{ + private string $tempDir; + + protected function setUp(): void + { + $this->tempDir = sys_get_temp_dir() . '/grav_api_users_privesc_test_' . uniqid(); + @mkdir($this->tempDir . '/cache/api/thumbnails', 0775, true); + } + + protected function tearDown(): void + { + $this->rmrf($this->tempDir); + } + + private function rmrf(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) as $item) { + if ($item === '.' || $item === '..') continue; + $path = $dir . '/' . $item; + is_dir($path) ? $this->rmrf($path) : unlink($path); + } + rmdir($dir); + } + + private function buildController(UserInterface $targetUser): UsersController + { + $tempDir = $this->tempDir; + + $config = new Config([ + 'plugins' => ['api' => [ + 'route' => '/api', + 'version_prefix' => 'v1', + 'pagination' => ['default_per_page' => 20, 'max_per_page' => 100], + ], 'login' => ['twofa_enabled' => false]], + ]); + + $locator = new class ($tempDir) { + public function __construct(private string $base) {} + public function findResource(string $uri, bool $absolute = false, bool $createDir = false): ?string + { + if (str_starts_with($uri, 'cache://')) { + return $this->base . '/cache'; + } + return $this->base; + } + }; + + $accounts = TestHelper::createMockAccounts([$targetUser->username => $targetUser]); + + TestHelper::createMockGrav([ + 'config' => $config, + 'locator' => $locator, + 'accounts' => $accounts, + // PermissionResolver::resolve() reads only from $user->get('access'), + // so an empty Permissions() instance is enough to satisfy the type. + 'permissions' => new Permissions(), + ]); + + return new UsersController(\Grav\Common\Grav::instance(), $config); + } + + /** @param array<string, mixed> $body */ + private function makeRequest(UserInterface $caller, string $targetUsername, array $body): ServerRequestInterface + { + return TestHelper::createMockRequest( + method: 'PATCH', + path: '/api/v1/users/' . $targetUsername, + headers: ['Content-Type' => 'application/json'], + body: json_encode($body), + attributes: [ + 'api_user' => $caller, + 'json_body' => $body, + 'route_params' => ['username' => $targetUsername], + ], + ); + } + + // ------------------------------------------------------------------- + // GHSA-r945-h4vm-h736 — privilege escalation via self-edit `access` + // ------------------------------------------------------------------- + + #[Test] + public function self_edit_with_access_payload_is_rejected_for_low_priv_user(): void + { + $user = TestHelper::createMockUser('user1', [ + 'access' => ['api' => ['access' => true], 'site' => ['login' => true]], + 'email' => 'user1@example.com', + ]); + + $controller = $this->buildController($user); + + $payload = [ + 'access' => [ + 'admin' => ['login' => true, 'super' => true], + 'api' => ['access' => true, 'super' => true], + 'site' => ['login' => true], + ], + ]; + + $threw = false; + try { + $controller->update($this->makeRequest($user, 'user1', $payload)); + } catch (ForbiddenException $e) { + $threw = true; + $this->assertStringContainsString("'access'", $e->getMessage()); + $this->assertStringContainsString('api.users.write', $e->getMessage()); + } + + $this->assertTrue($threw, 'Self-edit with access payload must throw ForbiddenException.'); + + // Defense in depth: even if the exception path were skipped, the user's + // access map must not have been mutated to grant super-admin. + $access = $user->get('access'); + $this->assertArrayNotHasKey('admin', $access ?? [], 'admin.* must not be added by the rejected request'); + $this->assertArrayNotHasKey('super', ($access['api'] ?? []), 'api.super must not be added by the rejected request'); + } + + #[Test] + public function self_edit_with_state_payload_is_rejected_for_low_priv_user(): void + { + $user = TestHelper::createMockUser('user1', [ + 'access' => ['api' => ['access' => true]], + 'state' => 'enabled', + ]); + + $controller = $this->buildController($user); + + $this->expectException(ForbiddenException::class); + $controller->update($this->makeRequest($user, 'user1', ['state' => 'disabled'])); + } + + #[Test] + public function self_edit_of_profile_fields_succeeds_for_low_priv_user(): void + { + $user = TestHelper::createMockUser('user1', [ + 'access' => ['api' => ['access' => true]], + 'email' => 'old@example.com', + 'fullname' => 'Old Name', + ]); + + $controller = $this->buildController($user); + + $controller->update($this->makeRequest($user, 'user1', [ + 'email' => 'new@example.com', + 'fullname' => 'New Name', + 'title' => 'Editor', + 'language' => 'en', + ])); + + $this->assertSame('new@example.com', $user->get('email')); + $this->assertSame('New Name', $user->get('fullname')); + $this->assertSame('Editor', $user->get('title')); + $this->assertSame('en', $user->get('language')); + } + + #[Test] + public function admin_can_update_access_field_on_other_user(): void + { + // Nested access map so PermissionResolver sees api.users.write as + // granted (the test mock's get() doesn't traverse dot notation, so + // the flat-key shortcut wouldn't work for the resolver). + $admin = TestHelper::createMockUser('admin', [ + 'access' => ['api' => ['access' => true, 'users' => ['write' => true]]], + ]); + $target = TestHelper::createMockUser('user1', [ + 'access' => ['api' => ['access' => true]], + ]); + + $config = new Config([ + 'plugins' => ['api' => [ + 'route' => '/api', + 'version_prefix' => 'v1', + 'pagination' => ['default_per_page' => 20, 'max_per_page' => 100], + ], 'login' => ['twofa_enabled' => false]], + ]); + $tempDir = $this->tempDir; + $locator = new class ($tempDir) { + public function __construct(private string $base) {} + public function findResource(string $uri, bool $absolute = false, bool $createDir = false): ?string + { + if (str_starts_with($uri, 'cache://')) return $this->base . '/cache'; + return $this->base; + } + }; + TestHelper::createMockGrav([ + 'config' => $config, + 'locator' => $locator, + 'accounts' => TestHelper::createMockAccounts(['admin' => $admin, 'user1' => $target]), + 'permissions' => new Permissions(), + ]); + $controller = new UsersController(\Grav\Common\Grav::instance(), $config); + + $newAccess = ['api' => ['access' => true, 'users' => ['read' => true]]]; + $controller->update($this->makeRequest($admin, 'user1', ['access' => $newAccess])); + + $this->assertSame($newAccess, $target->get('access')); + } + + #[Test] + public function user_with_users_write_can_self_edit_access_field(): void + { + // A user-manager editing their own profile is allowed to touch `access` + // because they already hold api.users.write. + $manager = TestHelper::createMockUser('manager', [ + 'access' => ['api' => ['access' => true, 'users' => ['write' => true]]], + ]); + + $controller = $this->buildController($manager); + + $newAccess = ['api' => ['access' => true, 'users' => ['write' => true, 'read' => true]]]; + $controller->update($this->makeRequest($manager, 'manager', ['access' => $newAccess])); + + $this->assertSame($newAccess, $manager->get('access')); + } +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.