Files
intotheeast-com-content/plugins/api/tests/Unit/Controllers/BlueprintUploadPrivescTest.php
T

407 lines
18 KiB
PHP

<?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\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\ServerRequestInterface;
use Psr\Http\Message\UploadedFileInterface;
/**
* Regression tests for GHSA-6xx2-m8wv-756h.
*
* The advisory describes a privilege escalation where a low-priv API user
* holding `api.media.write` could POST to /blueprint-upload with
* `destination=self@:` + `scope=users/anything` + a YAML file, drop
* `pwned.yaml` straight into `user/accounts/`, and log in as a brand-new
* super-admin. These tests pin the four layers that close the chain:
*
* 1. The `users/<x>` scope is gated to self-or-admin (no cross-user writes).
* 2. `user/accounts/` accepts image extensions only (avatars).
* 3. `user/config/` and `user/env/` reject every blueprint upload.
* 4. YAML/JSON/Twig and similar config formats are denied at any target.
*/
#[CoversClass(BlueprintUploadController::class)]
class BlueprintUploadPrivescTest extends TestCase
{
private string $tempDir;
private string $userRoot;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/grav_api_blueprint_upload_test_' . uniqid();
$this->userRoot = $this->tempDir . '/user';
@mkdir($this->userRoot . '/accounts', 0775, true);
@mkdir($this->userRoot . '/config', 0775, true);
@mkdir($this->userRoot . '/env/dev/config', 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(): BlueprintUploadController
{
$userRoot = $this->userRoot;
$config = new Config([
'plugins' => ['api' => [
'route' => '/api',
'version_prefix' => 'v1',
'pagination' => ['default_per_page' => 20, 'max_per_page' => 100],
]],
'security' => [
// A representative subset of Grav's default dangerous list. The
// GHSA-6xx2-m8wv-756h PoC relies on `.yaml` *not* being here —
// tests pin the per-endpoint denylist that closes that gap.
'uploads_dangerous_extensions' => ['php', 'phar', 'phtml', 'js', 'exe', 'html', 'htm'],
],
]);
$locator = new class ($userRoot) {
public function __construct(private string $userRoot) {}
public function findResource(string $uri, bool $absolute = false, bool $createDir = false): mixed
{
return match (true) {
$uri === 'user://' => $this->userRoot,
$uri === 'account://' => $this->userRoot . '/accounts',
str_starts_with($uri, 'user://') => $this->userRoot . '/' . substr($uri, 7),
default => false,
};
}
public function isStream(string $uri): bool
{
return (bool) preg_match('#^[a-z][a-z0-9+.\-]*://#i', $uri);
}
};
TestHelper::createMockGrav([
'config' => $config,
'locator' => $locator,
'permissions' => new Permissions(),
'uri' => new class { public function rootUrl(): string { return ''; } },
]);
return new BlueprintUploadController(\Grav\Common\Grav::instance(), $config);
}
/**
* @param array<string, string> $body
* @param array<string, UploadedFileInterface> $files
*/
private function makeRequest(
UserInterface $caller,
array $body,
array $files = [],
): ServerRequestInterface {
$request = TestHelper::createMockRequest(
method: 'POST',
path: '/api/v1/blueprint-upload',
attributes: ['api_user' => $caller, 'json_body' => $body],
);
// PSR-7 ServerRequestInterface expects getParsedBody() and
// getUploadedFiles() to drive the upload path; the TestHelper
// request stub returns null/[] for these. Wrap it.
return new class ($request, $body, $files) implements ServerRequestInterface {
public function __construct(
private readonly ServerRequestInterface $inner,
private readonly array $parsedBody,
private readonly array $uploadedFiles,
) {}
public function getParsedBody(): array { return $this->parsedBody; }
public function getUploadedFiles(): array { return $this->uploadedFiles; }
public function getMethod(): string { return $this->inner->getMethod(); }
public function getUri(): \Psr\Http\Message\UriInterface { return $this->inner->getUri(); }
public function getBody(): \Psr\Http\Message\StreamInterface { return $this->inner->getBody(); }
public function getQueryParams(): array { return $this->inner->getQueryParams(); }
public function getServerParams(): array { return $this->inner->getServerParams(); }
public function getHeaderLine(string $n): string { return $this->inner->getHeaderLine($n); }
public function getHeader(string $n): array { return $this->inner->getHeader($n); }
public function hasHeader(string $n): bool { return $this->inner->hasHeader($n); }
public function getHeaders(): array { return $this->inner->getHeaders(); }
public function getAttribute(string $n, mixed $d = null): mixed { return $this->inner->getAttribute($n, $d); }
public function withAttribute(string $n, mixed $v): static { return clone $this; }
public function getRequestTarget(): string { return $this->inner->getRequestTarget(); }
public function withRequestTarget(string $r): static { return clone $this; }
public function withMethod(string $m): static { return clone $this; }
public function withUri(\Psr\Http\Message\UriInterface $u, bool $p = false): static { return clone $this; }
public function getProtocolVersion(): string { return $this->inner->getProtocolVersion(); }
public function withProtocolVersion(string $v): static { return clone $this; }
public function withHeader(string $n, $v): static { return clone $this; }
public function withAddedHeader(string $n, $v): static { return clone $this; }
public function withoutHeader(string $n): static { return clone $this; }
public function withBody(\Psr\Http\Message\StreamInterface $b): static { return clone $this; }
public function getCookieParams(): array { return []; }
public function withCookieParams(array $c): static { return clone $this; }
public function withQueryParams(array $q): static { return clone $this; }
public function withUploadedFiles(array $u): static { return clone $this; }
public function withParsedBody($d): static { return clone $this; }
public function getAttributes(): array { return $this->inner->getAttributes(); }
public function withoutAttribute(string $n): static { return clone $this; }
};
}
/**
* Build a stub UploadedFile that records moveTo() destinations to a sink
* we can inspect from tests.
*/
private function makeUpload(string $clientFilename, string $content = 'fake'): UploadedFileInterface
{
return new class ($clientFilename, $content) implements UploadedFileInterface {
public ?string $movedTo = null;
public function __construct(
private readonly string $name,
private readonly string $body,
) {}
public function getStream(): \Psr\Http\Message\StreamInterface
{ throw new \RuntimeException('not used'); }
public function moveTo(string $targetPath): void
{
$this->movedTo = $targetPath;
file_put_contents($targetPath, $this->body);
}
public function getSize(): ?int { return strlen($this->body); }
public function getError(): int { return UPLOAD_ERR_OK; }
public function getClientFilename(): ?string { return $this->name; }
public function getClientMediaType(): ?string { return 'application/octet-stream'; }
};
}
// ------------------------------------------------------------------
// Layer 1: `users/<x>` scope is gated to self-or-admin.
// ------------------------------------------------------------------
#[Test]
public function ghsa_6xx2_self_at_users_other_is_forbidden_for_low_priv_caller(): void
{
// The exact PoC shape: caller has only api.media.write, and aims a
// `self@:` write at `users/<arbitrary>` to land in user/accounts/.
$caller = TestHelper::createMockUser('uploader', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
$controller = $this->buildController();
$request = $this->makeRequest($caller, [
'destination' => 'self@:',
'scope' => 'users/anything',
], ['file' => $this->makeUpload('pwned.yaml', "password: hunter2\naccess:\n api:\n super: true\n")]);
$this->expectException(ForbiddenException::class);
$this->expectExceptionMessageMatches('/users\/anything.*api\.users\.write/i');
$controller->upload($request);
}
#[Test]
public function users_scope_succeeds_for_caller_targeting_own_account(): void
{
$caller = TestHelper::createMockUser('uploader', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
$controller = $this->buildController();
$request = $this->makeRequest($caller, [
'destination' => 'self@:',
'scope' => 'users/uploader',
], ['file' => $this->makeUpload('avatar.png', "\x89PNG\r\n\x1a\n")]);
$response = $controller->upload($request);
$this->assertSame(201, $response->getStatusCode());
$this->assertFileExists($this->userRoot . '/accounts/avatar.png');
}
#[Test]
public function users_scope_allows_admin_to_target_other_user(): void
{
$admin = TestHelper::createMockUser('admin', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true], 'users' => ['write' => true]]],
]);
$controller = $this->buildController();
$request = $this->makeRequest($admin, [
'destination' => 'self@:',
'scope' => 'users/someone-else',
], ['file' => $this->makeUpload('avatar.png', "\x89PNG\r\n\x1a\n")]);
$response = $controller->upload($request);
$this->assertSame(201, $response->getStatusCode());
}
// ------------------------------------------------------------------
// Layer 2: `user/accounts/` accepts image extensions only.
// ------------------------------------------------------------------
#[Test]
public function ghsa_6xx2_yaml_into_accounts_via_account_stream_is_rejected(): void
{
// Bypasses the scope check by using `account://` directly. Must still
// be blocked by the per-endpoint extension policy.
$caller = TestHelper::createMockUser('uploader', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
$controller = $this->buildController();
$request = $this->makeRequest($caller, [
'destination' => 'account://',
'scope' => '',
], ['file' => $this->makeUpload('pwned.yaml', "access:\n api:\n super: true\n")]);
$this->expectException(ValidationException::class);
$this->expectExceptionMessageMatches('/(\.yaml.*not allowed|user\/accounts)/i');
$controller->upload($request);
$this->assertFileDoesNotExist($this->userRoot . '/accounts/pwned.yaml');
}
#[Test]
public function image_into_accounts_via_account_stream_is_allowed(): void
{
$caller = TestHelper::createMockUser('uploader', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
$controller = $this->buildController();
$request = $this->makeRequest($caller, [
'destination' => 'account://',
'scope' => '',
], ['file' => $this->makeUpload('avatar.png', "\x89PNG\r\n\x1a\n")]);
$response = $controller->upload($request);
$this->assertSame(201, $response->getStatusCode());
}
// ------------------------------------------------------------------
// Layer 3: `user/config/` and `user/env/` reject every upload.
// ------------------------------------------------------------------
#[Test]
public function uploads_into_user_config_are_rejected(): void
{
$caller = TestHelper::createMockUser('uploader', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
$controller = $this->buildController();
$request = $this->makeRequest($caller, [
'destination' => 'config',
'scope' => '',
], ['file' => $this->makeUpload('site.png', 'png-bytes')]);
$this->expectException(ForbiddenException::class);
$this->expectExceptionMessageMatches('/config.*not allowed/i');
$controller->upload($request);
}
#[Test]
public function uploads_into_user_env_are_rejected(): void
{
$caller = TestHelper::createMockUser('uploader', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
$controller = $this->buildController();
$request = $this->makeRequest($caller, [
'destination' => 'env/dev/config',
'scope' => '',
], ['file' => $this->makeUpload('logo.png', 'png-bytes')]);
$this->expectException(ForbiddenException::class);
$this->expectExceptionMessageMatches('/env.*not allowed/i');
$controller->upload($request);
}
// ------------------------------------------------------------------
// Layer 4: yaml/json/twig denied even outside user/accounts/.
// ------------------------------------------------------------------
#[Test]
public function yaml_into_arbitrary_dir_is_rejected_by_endpoint_denylist(): void
{
// Even outside user/accounts/, .yaml has no legitimate use as a
// blueprint media upload — the per-endpoint denylist guards against
// future scope/locator edge cases that bypass layer 2.
$caller = TestHelper::createMockUser('uploader', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
@mkdir($this->userRoot . '/data', 0775, true);
$controller = $this->buildController();
$request = $this->makeRequest($caller, [
'destination' => 'data',
'scope' => '',
], ['file' => $this->makeUpload('payload.yaml', "x: 1\n")]);
$this->expectException(ValidationException::class);
$this->expectExceptionMessageMatches('/\.yaml.*not allowed/i');
$controller->upload($request);
}
// ------------------------------------------------------------------
// Delete-side coverage: deleting an account YAML via the same endpoint.
// ------------------------------------------------------------------
#[Test]
public function delete_of_account_yaml_is_rejected(): void
{
// Place a real account YAML so the endpoint can't claim "already
// gone" — the rejection must fire on the path classification, not
// the file-existence shortcut.
file_put_contents($this->userRoot . '/accounts/admin.yaml', "fullname: Admin\n");
$caller = TestHelper::createMockUser('uploader', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
$controller = $this->buildController();
$request = $this->makeRequest($caller, [
'path' => 'accounts/admin.yaml',
]);
$threw = false;
try {
$controller->delete($request);
} catch (ForbiddenException $e) {
$threw = true;
$this->assertMatchesRegularExpression('/avatar image|user\/accounts/i', $e->getMessage());
}
$this->assertTrue($threw, 'Delete must be rejected by Forbidden, not silently succeed.');
$this->assertFileExists($this->userRoot . '/accounts/admin.yaml', 'YAML must not be unlinked.');
}
#[Test]
public function delete_into_user_config_is_rejected(): void
{
file_put_contents($this->userRoot . '/config/system.yaml', "site:\n title: x\n");
$caller = TestHelper::createMockUser('uploader', [
'access' => ['api' => ['access' => true, 'media' => ['write' => true]]],
]);
$controller = $this->buildController();
$request = $this->makeRequest($caller, ['path' => 'config/system.yaml']);
$this->expectException(ForbiddenException::class);
$controller->delete($request);
$this->assertFileExists($this->userRoot . '/config/system.yaml');
}
}