High severity8.7GHSA Advisory· Published May 12, 2026· Updated May 13, 2026
CVE-2026-42844
CVE-2026-42844
Description
Grav is a file-based Web platform. In Grav 2.0.0-beta.2, a low-privileged authenticated API user with api.media.write can abuse /api/v1/blueprint-upload to write an arbitrary YAML file into user/accounts/, then log in as the newly created account with api.super privileges. This results in full administrative compromise of the Grav API. This vulnerability is fixed in API 1.0.0-beta.17.
Affected products
1Patches
197fc02844a35Harden API file upload security
4 files changed · +717 −11
classes/Api/Controllers/BlueprintUploadController.php+225 −11 modified@@ -6,6 +6,7 @@ use Grav\Common\Filesystem\Folder; use Grav\Common\Page\Interfaces\PageInterface; +use Grav\Plugin\Api\Exceptions\ForbiddenException; use Grav\Plugin\Api\Exceptions\ValidationException; use Grav\Plugin\Api\Response\ApiResponse; use Psr\Http\Message\ResponseInterface; @@ -30,6 +31,39 @@ class BlueprintUploadController extends AbstractApiController { private const MAX_UPLOAD_SIZE = 64 * 1_048_576; // 64 MB + /** + * Image-only allowlist for uploads landing in `user/accounts/` (avatars). + * + * `user/accounts/` doubles as the directory Grav reads as authoritative + * account YAML, so allowing arbitrary extensions there is a privilege + * escalation surface (GHSA-6xx2-m8wv-756h: a YAML file dropped here + * becomes a fully functional account, including `access.api.super`). + * The only legitimate blueprint-upload use case for this directory is + * avatars, so the endpoint hard-restricts it to image extensions. + */ + private const ACCOUNTS_IMAGE_EXTENSIONS = [ + 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico', + ]; + + /** + * Per-endpoint extension denylist on top of `security.uploads_dangerous_extensions`. + * + * Not all of these are "code" in the classic sense, but every one is a + * file Grav (or a sibling tool) parses as authoritative configuration if + * it lands in the right directory. Keeping them out of any blueprint- + * upload target — not just `user/accounts/` — closes a class of bugs + * where a future locator/scope edge case unexpectedly resolves into + * `user/config/`, `user/env/<x>/config/`, or a plugin's own config dir. + */ + private const FORBIDDEN_EXTENSIONS = [ + 'yaml', 'yml', // Grav account / config / blueprint + 'json', // generic config / data + 'twig', // template code + 'env', // env files + 'neon', // alt config format + 'lock', // composer/npm lockfiles + ]; + public function upload(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.media.write'); @@ -41,8 +75,10 @@ public function upload(ServerRequestInterface $request): ResponseInterface if ($destination === '') { throw new ValidationException('destination is required.'); } + $this->assertSafeDestination($destination); - $targetDir = $this->resolveDestination($destination, $scope); + $targetDir = $this->resolveDestination($destination, $scope, $request); + $this->guardConfigBearingTarget($targetDir); $files = $this->flattenUploadedFiles($request->getUploadedFiles()); if ($files === []) { @@ -53,9 +89,11 @@ public function upload(ServerRequestInterface $request): ResponseInterface Folder::create($targetDir); } + $isAccountsDir = $this->classifyTargetDir($targetDir) === 'accounts'; + $saved = []; foreach ($files as $file) { - $saved[] = $this->processUploadedFile($file, $targetDir); + $saved[] = $this->processUploadedFile($file, $targetDir, $isAccountsDir); } // Build a response payload describing each saved file in a Grav @@ -174,6 +212,23 @@ public function delete(ServerRequestInterface $request): ResponseInterface } $absolute = $this->resolveDeletePath($path); + $targetDir = dirname($absolute); + $filename = basename($absolute); + + $this->guardConfigBearingTarget($targetDir, $filename); + + // Symmetric to the upload path: deletes targeting `user/accounts/` may + // only act on image files (avatars). Without this gate, a holder of + // `api.media.write` could `unlink` arbitrary account YAMLs. + if ($this->classifyTargetDir($targetDir) === 'accounts') { + $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + if (!in_array($extension, self::ACCOUNTS_IMAGE_EXTENSIONS, true)) { + throw new ForbiddenException( + "Deletes under user/accounts/ are restricted to avatar image files." + ); + } + } + $this->assertSafeExtension($filename, false); // Idempotent: a file that's already gone is indistinguishable from a // file we just deleted, so don't pollute the client with a 404 that @@ -199,6 +254,35 @@ public function delete(ServerRequestInterface $request): ResponseInterface return ApiResponse::noContent(); } + /** + * Reject traversal/null-byte destination strings before handing them to + * Grav's stream locator. The locator is still responsible for resolving + * streams and symlinks, but API input should never contain path-control + * segments. + */ + private function assertSafeDestination(string $destination): void + { + if (str_contains($destination, "\0") || str_contains($destination, '\\')) { + throw new ValidationException('Invalid destination.'); + } + + $path = $destination; + if (preg_match('/^(?:self@|@self)(?::(.*))?$/', $destination, $m)) { + $path = $m[1] ?? ''; + } elseif (preg_match('#^[A-Za-z][A-Za-z0-9+.-]*://(.*)$#', $destination, $m)) { + $path = $m[1] ?? ''; + } + + foreach (explode('/', trim($path, '/')) as $segment) { + if ($segment === '') { + continue; + } + if ($segment === '.' || $segment === '..') { + throw new ValidationException('Traversal not allowed in destination.'); + } + } + } + /** * Resolve a blueprint `destination` + `scope` to an absolute filesystem * directory. @@ -211,7 +295,7 @@ public function delete(ServerRequestInterface $request): ResponseInterface * strictly gated: they resolve from the user root and are rejected if * they try to escape via `..` or absolute prefixes. */ - private function resolveDestination(string $destination, string $scope): string + private function resolveDestination(string $destination, string $scope, ServerRequestInterface $request): string { /** @var \RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator $locator */ $locator = $this->grav['locator']; @@ -223,7 +307,7 @@ private function resolveDestination(string $destination, string $scope): string if (str_contains($sub, '..')) { throw new ValidationException('Traversal not allowed in self@: subpath.'); } - $base = $this->resolveScopeRoot($scope); + $base = $this->resolveScopeRoot($scope, $request); if ($base === null) { throw new ValidationException( "Cannot resolve 'self@:' destination: scope '{$scope}' is not a supported owner." @@ -258,7 +342,7 @@ private function resolveDestination(string $destination, string $scope): string * to its filesystem root. Returns null for scopes that don't have a * natural `self@:` owner (e.g. `config/system`). */ - private function resolveScopeRoot(string $scope): ?string + private function resolveScopeRoot(string $scope, ServerRequestInterface $request): ?string { if ($scope === '') return null; @@ -273,7 +357,7 @@ private function resolveScopeRoot(string $scope): ?string 'plugins' => $this->resolveStreamOrNull($locator, 'plugins://', $name), 'themes' => $this->resolveStreamOrNull($locator, 'themes://', $name), 'pages' => $this->resolvePageScope($name), - 'users' => $name !== '' ? $this->resolveUserScope() : null, + 'users' => $name !== '' ? $this->resolveUserScope($name, $request) : null, default => null, }; } @@ -299,13 +383,42 @@ private function resolvePageScope(string $route): ?string return $page?->path() ?: null; } - private function resolveUserScope(): ?string + /** + * Resolve `users/<username>` scope to the accounts directory. + * + * Avatars (the only legitimate use case for this scope) live next to the + * account YAML in `user/accounts/`, not in a per-user subfolder. Because + * this directory is the same one Grav reads as authoritative account + * configuration, the scope must be tightly gated: + * + * - `<username>` must match the Grav username pattern (no path tricks) + * - The caller must be editing their own account, OR hold + * `api.users.write` (the user-management permission) + * + * Without this gate, any holder of `api.media.write` could target any + * other user's avatar slot — and combined with a directory-classification + * miss, that's the GHSA-6xx2-m8wv-756h primitive. Per-extension filtering + * happens later in `processUploadedFile()`; this method's job is to stop + * cross-user writes at the scope-resolution layer. + */ + private function resolveUserScope(string $name, ServerRequestInterface $request): ?string { + if (!preg_match('/^[A-Za-z0-9_.-]+$/', $name)) { + throw new ValidationException("Invalid users scope: '{$name}'."); + } + + $caller = $this->getUser($request); + $isSelf = strcasecmp($caller->username, $name) === 0; + if (!$isSelf && !$this->isSuperAdmin($caller) && !$this->hasPermission($caller, 'api.users.write')) { + throw new ForbiddenException( + "The 'users/{$name}' scope requires editing your own account or holding the 'api.users.write' permission." + ); + } + /** @var \RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator $locator */ $locator = $this->grav['locator']; $accounts = $locator->findResource('account://', true, true); if (!$accounts) return null; - // Avatars and similar live next to the account yaml, not in a per-user folder. return is_string($accounts) ? $accounts : null; } @@ -381,7 +494,7 @@ private function buildPublicUrl(string $relative): ?string return rtrim($base, '/') . '/' . ltrim($relative, '/'); } - private function processUploadedFile(UploadedFileInterface $file, string $targetDir): string + private function processUploadedFile(UploadedFileInterface $file, string $targetDir, bool $isAccountsDir): string { if ($file->getError() !== UPLOAD_ERR_OK) { throw new ValidationException('File upload failed.'); @@ -397,6 +510,18 @@ private function processUploadedFile(UploadedFileInterface $file, string $target $originalName = $file->getClientFilename() ?? 'upload'; $filename = basename($originalName); + $this->assertSafeFilename($filename); + $this->assertSafeExtension($filename, $isAccountsDir); + + $file->moveTo($targetDir . '/' . $filename); + return $filename; + } + + /** + * Reject filenames that would escape the target dir or hide as a dotfile. + */ + private function assertSafeFilename(string $filename): void + { if ( $filename === '' || str_contains($filename, '..') @@ -405,7 +530,21 @@ private function processUploadedFile(UploadedFileInterface $file, string $target ) { throw new ValidationException("Invalid filename: '{$filename}'."); } + } + /** + * Apply layered extension policy: + * + * 1. `security.uploads_dangerous_extensions` (Grav-wide denylist: php, js, exe, ...) + * 2. Per-endpoint denylist for known-config formats (yaml, json, twig, ...) + * 3. If target is `user/accounts/`, restrict to image extensions only — + * the directory doubles as Grav's authoritative account store, so + * anything non-image is a privesc surface (GHSA-6xx2-m8wv-756h). + * + * Returns the lowercased extension for callers that want it. + */ + private function assertSafeExtension(string $filename, bool $isAccountsDir): string + { $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); if ($extension === '') { throw new ValidationException('Uploaded file must have a file extension.'); @@ -416,8 +555,83 @@ private function processUploadedFile(UploadedFileInterface $file, string $target throw new ValidationException("File extension '.{$extension}' is not allowed for security reasons."); } - $file->moveTo($targetDir . '/' . $filename); - return $filename; + if (in_array($extension, self::FORBIDDEN_EXTENSIONS, true)) { + throw new ValidationException("File extension '.{$extension}' is not allowed for blueprint uploads."); + } + + if ($isAccountsDir && !in_array($extension, self::ACCOUNTS_IMAGE_EXTENSIONS, true)) { + throw new ValidationException( + "Only image files (" . implode(', ', self::ACCOUNTS_IMAGE_EXTENSIONS) . ") may be uploaded to user/accounts/." + ); + } + + return $extension; + } + + /** + * Hard-deny writes resolving to directories that Grav reads as + * authoritative configuration: `user/config/` and any `user/env/.../config/`. + * `user/accounts/` is allowed (avatars) but extension-restricted in + * `assertSafeExtension()`. + * + * `$filename` is optional — pass it for delete-path checks (where we + * have the final filename) so the error message can name the target; + * for upload checks the per-file extension policy fires later anyway. + */ + private function guardConfigBearingTarget(string $absoluteDir, ?string $filename = null): void + { + $classification = $this->classifyTargetDir($absoluteDir); + if ($classification === 'config' || $classification === 'env') { + $where = $filename !== null ? "'{$filename}' under" : 'into'; + throw new ForbiddenException( + "Uploads {$where} the '{$classification}' directory are not allowed via this endpoint." + ); + } + } + + /** + * Classify a resolved absolute directory against the config-bearing + * directories under `user/`. Returns `'accounts'`, `'config'`, `'env'`, + * or null for "anything else". + * + * Uses `realpath` of the nearest existing parent so the classification + * survives symlinks (common in dev setups where `user/themes/<x>` points + * elsewhere on disk) without requiring the target to already exist. + */ + private function classifyTargetDir(string $absoluteDir): ?string + { + $userRoot = $this->userRoot(); + if ($userRoot === null) return null; + + $probe = $absoluteDir; + while ($probe !== '' && !file_exists($probe)) { + $parent = dirname($probe); + if ($parent === $probe) break; + $probe = $parent; + } + $real = realpath($probe !== '' ? $probe : $absoluteDir); + if ($real === false) { + $real = $absoluteDir; + } + + $normalizedTarget = rtrim(str_replace('\\', '/', $absoluteDir), '/'); + $map = [ + 'accounts' => $userRoot . '/accounts', + 'config' => $userRoot . '/config', + 'env' => $userRoot . '/env', + ]; + foreach ($map as $label => $forbidden) { + $normalizedForbidden = rtrim(str_replace('\\', '/', $forbidden), '/'); + if ( + $real === $forbidden + || str_starts_with($real, $forbidden . '/') + || $normalizedTarget === $normalizedForbidden + || str_starts_with($normalizedTarget, $normalizedForbidden . '/') + ) { + return $label; + } + } + return null; } /**
classes/Api/Controllers/GpmController.php+11 −0 modified@@ -1161,6 +1161,17 @@ public function changelog(ServerRequestInterface $request): ResponseInterface */ private function resolvePackagePath(string $slug, string $type): string { + if ( + $slug === '' + || $slug === '.' + || $slug === '..' + || str_contains($slug, '/') + || str_contains($slug, '\\') + || str_contains($slug, "\0") + ) { + throw new ValidationException("Invalid package slug '{$slug}'."); + } + $base = $type === 'themes' ? 'themes' : 'plugins'; $path = $this->grav['locator']->findResource("user://{$base}/{$slug}", true);
tests/Unit/Controllers/BlueprintUploadControllerSecurityTest.php+386 −0 added@@ -0,0 +1,386 @@ +<?php + +declare(strict_types=1); + +namespace Grav\Plugin\Api\Tests\Unit\Controllers; + +use Grav\Common\Config\Config; +use Grav\Framework\Acl\Permissions; +use Grav\Plugin\Api\Controllers\BlueprintUploadController; +use Grav\Plugin\Api\Exceptions\ForbiddenException; +use Grav\Plugin\Api\Exceptions\ValidationException; +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\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamInterface; +use Psr\Http\Message\UploadedFileInterface; +use Psr\Http\Message\UriInterface; + +/** + * Regression coverage for GHSA-6xx2-m8wv-756h and adjacent file-write risks. + */ +#[CoversClass(BlueprintUploadController::class)] +class BlueprintUploadControllerSecurityTest extends TestCase +{ + private string $tempDir; + private Config $config; + + protected function setUp(): void + { + $this->tempDir = sys_get_temp_dir() . '/grav_api_blueprint_upload_' . uniqid(); + mkdir($this->tempDir . '/accounts', 0775, true); + mkdir($this->tempDir . '/config', 0775, true); + mkdir($this->tempDir . '/media', 0775, true); + mkdir($this->tempDir . '/plugins/api', 0775, true); + mkdir($this->tempDir . '/themes/quark', 0775, true); + + $this->config = new Config([ + 'system' => ['pages' => ['theme' => 'quark']], + 'security' => ['uploads_dangerous_extensions' => ['php', 'phtml', 'phar', 'js', 'html']], + 'plugins' => ['api' => ['route' => '/api', 'version_prefix' => 'v1']], + ]); + } + + protected function tearDown(): void + { + $this->rmrf($this->tempDir); + } + + #[Test] + public function account_yaml_upload_is_rejected_for_media_write_user(): void + { + $controller = $this->buildController('alice', ['media' => ['write' => true]]); + $request = $this->uploadRequest('alice', 'self@:', 'users/alice', 'evil.yaml', "access:\n api:\n super: true\n"); + + $this->expectException(ValidationException::class); + + try { + $controller->upload($request); + } finally { + self::assertFileDoesNotExist($this->tempDir . '/accounts/evil.yaml'); + } + } + + #[Test] + public function account_scope_accepts_avatar_image_for_self(): void + { + $controller = $this->buildController('alice', ['media' => ['write' => true]]); + $request = $this->uploadRequest('alice', 'self@:', 'users/alice', 'avatar.png', 'png'); + + $response = $controller->upload($request); + + self::assertSame(201, $response->getStatusCode()); + self::assertFileExists($this->tempDir . '/accounts/avatar.png'); + $payload = json_decode((string) $response->getBody(), true); + self::assertSame('user/accounts/avatar.png', $payload['data'][0]['path'] ?? null); + } + + #[Test] + public function account_scope_rejects_cross_user_upload_without_users_write(): void + { + $controller = $this->buildController('alice', ['media' => ['write' => true]]); + $request = $this->uploadRequest('alice', 'self@:', 'users/bob', 'avatar.png', 'png'); + + $this->expectException(ForbiddenException::class); + $controller->upload($request); + } + + #[Test] + public function config_directory_upload_is_rejected_even_for_images(): void + { + $controller = $this->buildController('alice', ['media' => ['write' => true]]); + $request = $this->uploadRequest('alice', 'user://config/images', 'plugins/api', 'logo.png', 'png'); + + $this->expectException(ForbiddenException::class); + + try { + $controller->upload($request); + } finally { + self::assertFileDoesNotExist($this->tempDir . '/config/images/logo.png'); + } + } + + #[Test] + public function config_bearing_extension_is_rejected_outside_config_directories(): void + { + $controller = $this->buildController('alice', ['media' => ['write' => true]]); + $request = $this->uploadRequest('alice', 'user://media', 'plugins/api', 'settings.yaml', 'enabled: true'); + + $this->expectException(ValidationException::class); + + try { + $controller->upload($request); + } finally { + self::assertFileDoesNotExist($this->tempDir . '/media/settings.yaml'); + } + } + + #[Test] + public function delete_rejects_account_yaml_and_leaves_file_intact(): void + { + file_put_contents($this->tempDir . '/accounts/admin.yaml', "access:\n api:\n super: true\n"); + $controller = $this->buildController('alice', ['media' => ['write' => true]]); + + $this->expectException(ForbiddenException::class); + + try { + $controller->delete($this->deleteRequest('alice', 'user/accounts/admin.yaml')); + } finally { + self::assertFileExists($this->tempDir . '/accounts/admin.yaml'); + } + } + + #[Test] + public function delete_rejects_config_bearing_extension_outside_accounts(): void + { + file_put_contents($this->tempDir . '/plugins/api/blueprints.yaml', 'name: API'); + $controller = $this->buildController('alice', ['media' => ['write' => true]]); + + $this->expectException(ValidationException::class); + + try { + $controller->delete($this->deleteRequest('alice', 'user/plugins/api/blueprints.yaml')); + } finally { + self::assertFileExists($this->tempDir . '/plugins/api/blueprints.yaml'); + } + } + + #[Test] + public function symlinked_theme_upload_remains_allowed_for_safe_image(): void + { + $external = $this->tempDir . '-theme'; + mkdir($external . '/images', 0775, true); + $this->rmrf($this->tempDir . '/themes/quark'); + symlink($external, $this->tempDir . '/themes/quark'); + + $controller = $this->buildController('alice', ['media' => ['write' => true]]); + $response = $controller->upload( + $this->uploadRequest('alice', 'themes://quark/images', 'themes/quark', 'logo.png', 'png') + ); + + self::assertSame(201, $response->getStatusCode()); + self::assertFileExists($external . '/images/logo.png'); + + $this->rmrf($external); + } + + private function buildController(string $username, array $apiAccess): BlueprintUploadController + { + $user = TestHelper::createMockUser($username, [ + 'access' => ['api' => ['access' => true] + $apiAccess], + ]); + + TestHelper::createMockGrav([ + 'config' => $this->config, + 'locator' => new BlueprintUploadTestLocator($this->tempDir), + 'uri' => new class { + public function rootUrl(): string { return 'https://example.test'; } + }, + 'permissions' => new Permissions(), + 'accounts' => TestHelper::createMockAccounts([$username => $user]), + ]); + + return new BlueprintUploadController(\Grav\Common\Grav::instance(), $this->config); + } + + private function uploadRequest( + string $username, + string $destination, + string $scope, + string $filename, + string $contents, + ): ServerRequestInterface { + $user = TestHelper::createMockUser($username, [ + 'access' => ['api' => ['access' => true, 'media' => ['write' => true]]], + ]); + + return new BlueprintUploadTestRequest( + 'POST', + ['destination' => $destination, 'scope' => $scope], + ['file' => new BlueprintUploadTestFile($filename, $contents)], + ['api_user' => $user], + ); + } + + private function deleteRequest(string $username, string $path): ServerRequestInterface + { + $user = TestHelper::createMockUser($username, [ + 'access' => ['api' => ['access' => true, 'media' => ['write' => true]]], + ]); + + return new BlueprintUploadTestRequest( + 'DELETE', + ['path' => $path], + [], + ['api_user' => $user, 'json_body' => ['path' => $path]], + ); + } + + private function rmrf(string $path): void + { + if (is_link($path) || is_file($path)) { + unlink($path); + return; + } + if (!is_dir($path)) { + return; + } + foreach (scandir($path) ?: [] as $item) { + if ($item === '.' || $item === '..') continue; + $this->rmrf($path . '/' . $item); + } + rmdir($path); + } +} + +final class BlueprintUploadTestLocator +{ + public function __construct(private readonly string $base) {} + + public function isStream(string $path): bool + { + return preg_match('#^[A-Za-z][A-Za-z0-9+.-]*://#', $path) === 1; + } + + public function findResource(string $uri, bool $absolute = false, bool $createDir = false): string|false + { + $map = [ + 'user://' => $this->base, + 'account://' => $this->base . '/accounts', + 'plugins://' => $this->base . '/plugins', + 'themes://' => $this->base . '/themes', + 'theme://' => $this->base . '/themes/quark', + 'image://' => $this->base . '/images', + 'asset://' => $this->base . '/assets', + 'page://' => $this->base . '/pages', + ]; + + foreach ($map as $prefix => $root) { + if (str_starts_with($uri, $prefix)) { + $path = rtrim($root . '/' . ltrim(substr($uri, strlen($prefix)), '/'), '/'); + if ($createDir && !is_dir($path)) { + mkdir($path, 0775, true); + } + return $path; + } + } + + return false; + } +} + +final class BlueprintUploadTestFile implements UploadedFileInterface +{ + private readonly string $source; + private bool $moved = false; + + public function __construct( + private readonly string $filename, + string $contents, + ) { + $this->source = tempnam(sys_get_temp_dir(), 'grav_api_upload_') ?: ''; + file_put_contents($this->source, $contents); + } + + public function getStream(): StreamInterface { throw new \RuntimeException('Not needed in tests.'); } + public function moveTo(string $targetPath): void + { + if ($this->moved) { + throw new \RuntimeException('File already moved.'); + } + $dir = dirname($targetPath); + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + rename($this->source, $targetPath); + $this->moved = true; + } + public function getSize(): ?int { return file_exists($this->source) ? filesize($this->source) : null; } + public function getError(): int { return UPLOAD_ERR_OK; } + public function getClientFilename(): ?string { return $this->filename; } + public function getClientMediaType(): ?string { return 'application/octet-stream'; } +} + +final class BlueprintUploadTestRequest implements ServerRequestInterface +{ + public function __construct( + private readonly string $method, + private readonly array $parsedBody, + private readonly array $uploadedFiles, + private array $attributes, + ) {} + + public function getParsedBody(): mixed { return $this->parsedBody; } + public function getUploadedFiles(): array { return $this->uploadedFiles; } + public function getAttribute(string $name, mixed $default = null): mixed { return $this->attributes[$name] ?? $default; } + public function withAttribute(string $name, mixed $value): static { $clone = clone $this; $clone->attributes[$name] = $value; return $clone; } + public function withoutAttribute(string $name): static { $clone = clone $this; unset($clone->attributes[$name]); return $clone; } + public function getAttributes(): array { return $this->attributes; } + public function getMethod(): string { return $this->method; } + public function withMethod(string $method): static { return clone $this; } + public function getQueryParams(): array { return []; } + public function getBody(): StreamInterface { return new BlueprintUploadTestStream(json_encode($this->parsedBody)); } + public function getHeaderLine(string $name): string { return ''; } + public function getHeader(string $name): array { return []; } + public function getHeaders(): array { return []; } + public function hasHeader(string $name): bool { return false; } + public function getRequestTarget(): string { return '/api/v1/blueprint-upload'; } + public function withRequestTarget(string $requestTarget): static { return clone $this; } + public function getUri(): UriInterface { return new BlueprintUploadTestUri(); } + public function withUri(UriInterface $uri, bool $preserveHost = false): static { return clone $this; } + public function getProtocolVersion(): string { return '1.1'; } + public function withProtocolVersion(string $version): static { return clone $this; } + public function withHeader(string $name, $value): static { return clone $this; } + public function withAddedHeader(string $name, $value): static { return clone $this; } + public function withoutHeader(string $name): static { return clone $this; } + public function withBody(StreamInterface $body): static { return clone $this; } + public function getServerParams(): array { return []; } + public function getCookieParams(): array { return []; } + public function withCookieParams(array $cookies): static { return clone $this; } + public function withQueryParams(array $query): static { return clone $this; } + public function withUploadedFiles(array $uploadedFiles): static { return clone $this; } + public function withParsedBody($data): static { return clone $this; } +} + +final class BlueprintUploadTestStream implements StreamInterface +{ + public function __construct(private readonly string $contents) {} + public function __toString(): string { return $this->contents; } + public function close(): void {} + public function detach() { return null; } + public function getSize(): ?int { return strlen($this->contents); } + public function tell(): int { return 0; } + public function eof(): bool { return true; } + public function isSeekable(): bool { return false; } + public function seek(int $offset, int $whence = SEEK_SET): void {} + public function rewind(): void {} + public function isWritable(): bool { return false; } + public function write(string $string): int { return 0; } + public function isReadable(): bool { return true; } + public function read(int $length): string { return $this->contents; } + public function getContents(): string { return $this->contents; } + public function getMetadata(?string $key = null): mixed { return null; } +} + +final class BlueprintUploadTestUri implements UriInterface +{ + public function getScheme(): string { return 'https'; } + public function getAuthority(): string { return 'example.test'; } + public function getUserInfo(): string { return ''; } + public function getHost(): string { return 'example.test'; } + public function getPort(): ?int { return null; } + public function getPath(): string { return '/api/v1/blueprint-upload'; } + public function getQuery(): string { return ''; } + public function getFragment(): string { return ''; } + public function withScheme(string $scheme): static { return clone $this; } + public function withUserInfo(string $user, ?string $password = null): static { return clone $this; } + public function withHost(string $host): static { return clone $this; } + public function withPort(?int $port): static { return clone $this; } + public function withPath(string $path): static { return clone $this; } + public function withQuery(string $query): static { return clone $this; } + public function withFragment(string $fragment): static { return clone $this; } + public function __toString(): string { return 'https://example.test/api/v1/blueprint-upload'; } +}
tests/Unit/Controllers/GpmControllerSecurityTest.php+95 −0 added@@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +namespace Grav\Plugin\Api\Tests\Unit\Controllers; + +use Grav\Common\Config\Config; +use Grav\Framework\Acl\Permissions; +use Grav\Plugin\Api\Controllers\GpmController; +use Grav\Plugin\Api\Exceptions\ValidationException; +use Grav\Plugin\Api\Tests\Unit\TestHelper; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +#[CoversClass(GpmController::class)] +class GpmControllerSecurityTest extends TestCase +{ + private string $tempDir; + + protected function setUp(): void + { + $this->tempDir = sys_get_temp_dir() . '/grav_api_gpm_security_' . uniqid(); + mkdir($this->tempDir . '/cache', 0775, true); + mkdir($this->tempDir . '/plugins/api', 0775, true); + file_put_contents($this->tempDir . '/README.md', 'root readme must not be exposed'); + } + + protected function tearDown(): void + { + $this->rmrf($this->tempDir); + } + + #[Test] + public function readme_rejects_dot_dot_package_slug(): void + { + $user = TestHelper::createMockUser('auditor', [ + 'access' => ['api' => ['access' => true, 'gpm' => ['read' => true]]], + ]); + + $config = new Config(['plugins' => ['api' => ['route' => '/api', 'version_prefix' => 'v1']]]); + TestHelper::createMockGrav([ + 'config' => $config, + 'locator' => new GpmSecurityTestLocator($this->tempDir), + 'permissions' => new Permissions(), + ]); + + $controller = new GpmController(\Grav\Common\Grav::instance(), $config); + $request = TestHelper::createMockRequest( + method: 'GET', + path: '/api/v1/gpm/plugins/../readme', + attributes: [ + 'api_user' => $user, + 'route_params' => ['slug' => '..'], + ], + ); + + $this->expectException(ValidationException::class); + $controller->readme($request); + } + + private function rmrf(string $path): void + { + if (is_file($path) || is_link($path)) { + unlink($path); + return; + } + if (!is_dir($path)) { + return; + } + foreach (scandir($path) ?: [] as $item) { + if ($item === '.' || $item === '..') continue; + $this->rmrf($path . '/' . $item); + } + rmdir($path); + } +} + +final class GpmSecurityTestLocator +{ + public function __construct(private readonly string $base) {} + + public function findResource(string $uri, bool $absolute = false, bool $createDir = false): string|false + { + if (str_starts_with($uri, 'cache://')) { + return $this->base . '/cache'; + } + + if (str_starts_with($uri, 'user://')) { + return rtrim($this->base . '/' . ltrim(substr($uri, strlen('user://')), '/'), '/'); + } + + return false; + } +}
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.