VYPR
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

1

Patches

1
97fc02844a35

Harden API file upload security

https://github.com/getgrav/grav-plugin-apiAndy MillerApr 28, 2026via ghsa
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

4

News mentions

0

No linked articles in our index yet.