Skip to content
Closed
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 3.2.0

### Added

- `Target::setDestinations()` confines a target's requests to a `Utopia\Client\Destinations`. Each connection and
redirect hop is checked against the address it actually reached, so DNS cannot answer differently between a check
and the connection. Without it, requests connect anywhere, as before.
- The Appwrite source applies its destinations to its SDK client, `Sources\Appwrite\Client`, as well as to its own
requests.

## 3.1.2

3.1.2 reverses the 3.0.0 notes below on `Aborted`, `markAborted()` and finalizing with `success()`.
Expand Down
14 changes: 12 additions & 2 deletions src/Migration/Sources/Appwrite.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
namespace Utopia\Migration\Sources;

use Appwrite\AppwriteException;
use Appwrite\Client;
use Appwrite\Enums\ProjectAuthMethodId;
use Appwrite\Enums\ProjectPolicyId;
use Appwrite\Enums\ProjectProtocolId;
Expand All @@ -19,6 +18,7 @@
use Appwrite\Services\Users;
use Appwrite\Services\Webhooks;
use Override;
use Utopia\Client\Destinations;
use Utopia\Database\Database as UtopiaDatabase;
use Utopia\Database\DateTime as UtopiaDateTime;
use Utopia\Database\Document as UtopiaDocument;
Expand Down Expand Up @@ -85,6 +85,7 @@
use Utopia\Migration\Resources\Storage\File;
use Utopia\Migration\Resources\Templates\EmailTemplate;
use Utopia\Migration\Source;
use Utopia\Migration\Sources\Appwrite\Client;
use Utopia\Migration\Sources\Appwrite\Reader;
use Utopia\Migration\Sources\Appwrite\Reader\API as APIReader;
use Utopia\Migration\Sources\Appwrite\Reader\Database as DatabaseReader;
Expand Down Expand Up @@ -139,7 +140,8 @@ public function __construct(
protected ?UtopiaDatabase $dbForProject = null,
protected array $queries = [],
) {
$this->client = (new Client())
$this->client = new Client();
$this->client
->setEndpoint($endpoint)
->setProject($projectId)
->setKey($key)
Expand Down Expand Up @@ -187,6 +189,14 @@ public static function getName(): string
return 'Appwrite';
}

#[Override]
public function setDestinations(Destinations $destinations): static
{
$this->client->setDestinations($destinations);

return parent::setDestinations($destinations);
}

#[Override]
public function run(
array $resources,
Expand Down
36 changes: 36 additions & 0 deletions src/Migration/Sources/Appwrite/Client.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace Utopia\Migration\Sources\Appwrite;

use Appwrite\Client as Base;
use Override;
use Utopia\Client\Client as HttpClient;
use Utopia\Client\Destinations;

/**
* The Appwrite SDK client, confined to the destinations its source may connect to.
*/
class Client extends Base
{
private ?Destinations $destinations = null;

public function setDestinations(Destinations $destinations): static
{
$this->destinations = $destinations;
$this->resetHttpClient();

return $this;
}

#[Override]
protected function createHttpClient(bool $followRedirects): HttpClient
{
$client = parent::createHttpClient($followRedirects);

if ($this->destinations === null) {
return $client;
}

return $client->withDestinations($this->destinations);
}
}
18 changes: 17 additions & 1 deletion src/Migration/Target.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Psr\Http\Client\ClientExceptionInterface;
use Utopia\Client\Adapter\Curl\Client as CurlAdapter;
use Utopia\Client\Client;
use Utopia\Client\Destinations;
use Utopia\Psr7\ContentType;
use Utopia\Psr7\Request\Factory as RequestFactory;

Expand Down Expand Up @@ -56,6 +57,8 @@ abstract class Target

private ?Client $client = null;

private ?Destinations $destinations = null;

protected string $rootResourceId = '';

protected string $rootResourceType = '';
Expand Down Expand Up @@ -157,6 +160,19 @@ protected function call(
return $responseBody;
}

/**
* Where this target's requests may connect. Each connection and redirect hop is
* checked against the address it actually reached, so DNS cannot answer differently
* between a check and the connection. Unset, requests may connect anywhere.
*/
public function setDestinations(Destinations $destinations): static
{
$this->destinations = $destinations;
$this->client = null;

return $this;
}

/**
* The HTTP client for {@see call()}, built once per target so connections are reused.
*/
Expand All @@ -166,7 +182,7 @@ protected function client(): Client
\CURLOPT_TIMEOUT_MS => 0,
\CURLOPT_LOW_SPEED_LIMIT => 1,
\CURLOPT_LOW_SPEED_TIME => static::STALL_TIMEOUT,
])))
]), $this->destinations))
->withFollowRedirects()
->withConnectTimeout(static::CONNECT_TIMEOUT);
}
Expand Down
42 changes: 42 additions & 0 deletions tests/Migration/Unit/General/RequestTarget.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace Utopia\Tests\Unit\General;

use Override;
use Utopia\Migration\Target;

final class RequestTarget extends Target
{
#[Override]
public static function getName(): string
{
return 'Request';
}

#[Override]
public static function getSupportedResources(): array
{
return [];
}

#[Override]
public function run(array $resources, callable $callback, string $rootResourceId = ''): void
{
}

#[Override]
public function report(array $resources = [], array $resourceIds = []): array
{
return [];
}

/**
* @return array<mixed>|string
*
* @throws \Exception
*/
public function fetch(string $url): array|string
{
return $this->call('GET', $url);
}
}
121 changes: 121 additions & 0 deletions tests/Migration/Unit/General/TargetDestinationsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

namespace Utopia\Tests\Unit\General;

use Appwrite\AppwriteException;
use Override;
use PHPUnit\Framework\TestCase;
use Utopia\Client\Destinations\IPRange;
use Utopia\Client\Destinations\PublicInternet;
use Utopia\Migration\Exception;
use Utopia\Migration\Sources\Appwrite;
use Utopia\Migration\Sources\Appwrite\Client;

final class TargetDestinationsTest extends TestCase
{
private const string REFUSED = 'not an allowed destination';

/** @var resource|null */
private $server = null;

#[Override]
protected function tearDown(): void
{
if (\is_resource($this->server)) {
\proc_terminate($this->server, 9);
\proc_close($this->server);
}

$this->server = null;
}

public function testTargetConnectsAnywhereByDefault(): void
{
$this->assertSame(['reached' => true], $this->target()->fetch($this->serve()));
}

public function testTargetRefusesAnAddressItsDestinationsDoNotAllow(): void
{
$target = $this->target()->setDestinations(new PublicInternet());

try {
$target->fetch($this->serve());
$this->fail('A loopback address must be refused under PublicInternet.');
} catch (\Exception $error) {
$this->assertSame(Exception::CODE_INTERNAL, $error->getCode());
$this->assertStringContainsString(self::REFUSED, $error->getMessage());
}
}

public function testTargetReachesARangeItsDestinationsAdmit(): void
{
$target = $this->target()->setDestinations(new PublicInternet(new IPRange('127.0.0.0/8')));

$this->assertSame(['reached' => true], $target->fetch($this->serve()));
}

public function testTargetAppliesDestinationsSetAfterARequest(): void
{
$url = $this->serve();
$target = $this->target();
$target->fetch($url);

$target->setDestinations(new PublicInternet());

$this->expectExceptionMessage(self::REFUSED);
$target->fetch($url);
}

public function testSdkClientRefusesAnAddressItsDestinationsDoNotAllow(): void
{
$client = (new Client())->setDestinations(new PublicInternet());
$client->setEndpoint($this->serve());

$this->expectException(AppwriteException::class);
$this->expectExceptionMessage(self::REFUSED);
$client->call(Client::METHOD_GET, '/health');
}

public function testSdkClientReachesARangeItsDestinationsAdmit(): void
{
$client = (new Client())->setDestinations(new PublicInternet(new IPRange('127.0.0.0/8')));
$client->setEndpoint($this->serve());

$this->assertSame(['reached' => true], $client->call(Client::METHOD_GET, '/health'));
}

public function testAppwriteSourcePassesItsDestinationsToTheSdkClient(): void
{
$source = new class ('project', $this->serve(), 'key', fn () => null) extends Appwrite {
public function sdk(): Client
{
return $this->client;
}
};

$this->assertSame(['reached' => true], $source->sdk()->call(Client::METHOD_GET, '/health'));

$source->setDestinations(new PublicInternet());

$this->expectExceptionMessage(self::REFUSED);
$source->sdk()->call(Client::METHOD_GET, '/health');
}

private function target(): RequestTarget
{
return new RequestTarget();
}

private function serve(): string
{
$script = \dirname(__DIR__, 2).'/resources/destinations/server.php';
$server = \proc_open([PHP_BINARY, $script], [1 => ['pipe', 'w']], $pipes);
$this->assertIsResource($server);
$this->server = $server;

$port = \trim((string) \fgets($pipes[1]));
$this->assertMatchesRegularExpression('/^\d+$/', $port, 'The test server did not report its port.');

return "http://127.0.0.1:{$port}";
}
}
26 changes: 26 additions & 0 deletions tests/Migration/resources/destinations/server.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

const LIFETIME_SECONDS = 60;

const BODY = '{"reached":true}';

$server = \stream_socket_server('tcp://127.0.0.1:0', $errorCode, $errorMessage);

if ($server === false) {
\fwrite(STDERR, $errorMessage);
exit(1);
}

$address = (string) \stream_socket_get_name($server, false);
\fwrite(STDOUT, \substr($address, \strrpos($address, ':') + 1).PHP_EOL);
\fflush(STDOUT);

while ($connection = @\stream_socket_accept($server, LIFETIME_SECONDS)) {
$request = '';
while (!\str_contains($request, "\r\n\r\n") && !\feof($connection)) {
$request .= (string) \fread($connection, 8192);
}

\fwrite($connection, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ".\strlen(BODY)."\r\nConnection: close\r\n\r\n".BODY);
\fclose($connection);
}
Loading