Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/Executor/StorageFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,20 @@ class StorageFactory
* @param string $root Root path for storage
* @param ?string $connection DSN connection string. An empty or null connection means no object storage is configured for this deployment, and a `file` or `local` scheme asks for the local device outright. Both yield the local device rooted at $root.
* @param (ClientInterface&StreamingClientInterface)|null $client HTTP client for the remote devices, defaulting to the one utopia-php/storage builds
* @param bool $trusted False when the connection holds a tenant's values: its host is then final, so the `url` and `insecure` parameters, which replace it, are refused, and so is the local device
*
* @throws InvalidArgumentException When the connection string cannot be parsed or names a scheme with no device
* @throws InvalidArgumentException When the connection string cannot be parsed, names a scheme with no device, or is untrusted and asks for what only an operator may
*/
public static function getDevice(string $root, #[\SensitiveParameter] ?string $connection = '', (ClientInterface&StreamingClientInterface)|null $client = null): Device
public static function getDevice(string $root, #[\SensitiveParameter] ?string $connection = '', (ClientInterface&StreamingClientInterface)|null $client = null, bool $trusted = true): Device
{
$connection ??= '';
$localSchemes = ['file', DeviceType::Local->value];

if ($connection === '' || \in_array(\strtolower((string) \strstr($connection, '://', true)), $localSchemes, true)) {
if (!$trusted) {
throw new InvalidArgumentException('Untrusted storage DSN must name a remote device');
}

return new Local($root);
}

Expand All @@ -44,6 +49,10 @@ public static function getDevice(string $root, #[\SensitiveParameter] ?string $c
throw new InvalidArgumentException('Unable to parse storage DSN');
}

if (!$trusted && ($dsn->getParam('url') !== '' || $dsn->getParam('insecure') !== '')) {
throw new InvalidArgumentException('Untrusted storage DSN cannot replace its endpoint');
}

$scheme = $dsn->getScheme();
$deviceType = DeviceType::tryFrom($scheme)
?? throw new InvalidArgumentException(sprintf("Storage DSN scheme '%s' has no device", $scheme));
Expand Down
46 changes: 46 additions & 0 deletions tests/unit/Executor/StorageFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,52 @@ public function testUnusableConnectionIsRefused(string $connection): void
StorageFactory::getDevice('/storage/builds/app-test', $connection);
}

/**
* @return \Iterator<string, array{?string}>
*/
public static function untrustedOverrides(): \Iterator
{
yield 'url' => ['s3://key:secret@minio.example.com/b?region=us-east-1&url=' . \urlencode('http://169.254.169.254/latest')];
yield 'insecure' => ['s3://key:secret@minio.example.com/b?region=us-east-1&insecure=true'];
yield 'url spliced in through the bucket' => ['s3://key:secret@minio.example.com/b?url=http://169.254.169.254/latest&insecure=true'];
yield 'local scheme' => ['local://localhost'];
yield 'file scheme' => ['file:///etc'];
yield 'empty' => [''];
yield 'null' => [null];
}

#[DataProvider('untrustedOverrides')]
public function testUntrustedConnectionCannotReplaceItsEndpoint(?string $connection): void
{
$this->expectException(InvalidArgumentException::class);

StorageFactory::getDevice('/', $connection, trusted: false);
}

public function testUntrustedConnectionReachesItsOwnHost(): void
{
$client = new class () implements ClientInterface, StreamingClientInterface {
public string $url = '';

public function sendRequest(RequestInterface $request): ResponseInterface
{
$this->url = (string) $request->getUri();

return new Response(200)->withHeader('ETag', '"etag"');
}

public function stream(RequestInterface $request, callable $sink): ResponseInterface
{
return $this->sendRequest($request);
}
};

StorageFactory::getDevice('/', 's3://key:secret@minio.example.com:9000/b?region=us-east-1', $client, trusted: false)
->write(self::OBJECT_KEY, new Stream('wal'), 'application/octet-stream');

$this->assertSame('https://minio.example.com:9000/b/' . self::OBJECT_KEY, $client->url);
}

public function testRefusalDoesNotPrintTheConnection(): void
{
$ignoreArgs = \ini_set('zend.exception_ignore_args', '0');
Expand Down
Loading