diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index bc84ae7ef6..17515fe949 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1469,13 +1469,14 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->inject('devKey') ->inject('user') ->inject('dbForProject') + ->inject('dbForPlatform') ->inject('geodb') ->inject('queueForEvents') ->inject('store') ->inject('proofForPassword') ->inject('proofForToken') ->inject('authorization') - ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) use ($oauthDefaultSuccess) { + ->action(function (string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response, Document $project, Validator $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, Reader $geodb, Event $queueForEvents, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, Authorization $authorization) use ($oauthDefaultSuccess) { $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; $port = $request->getPort(); $callbackBase = $protocol . '://' . $request->getHostname(); @@ -1512,6 +1513,25 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') $state = $defaultState; } + // Allow redirect to rule URL if related to project + //Check if $redirectValidator is instance of Redirect class + if ($redirectValidator instanceof Redirect) { + $rules = $authorization->skip(fn () => $dbForPlatform->find('rules', [ + Query::equal('domain', [ + parse_url($state['success'], PHP_URL_HOST), + parse_url($state['failure'], PHP_URL_HOST) + ]), + Query::equal('projectInternalId', [$project->getSequence()]), + Query::limit(2) + ])); + + foreach ($rules as $rule) { + $allowedHostnames = $redirectValidator->getAllowedHostnames(); + $allowedHostnames[] = $rule->getAttribute('domain', ''); + $redirectValidator->setAllowedHostnames($allowedHostnames); + } + } + if ($devKey->isEmpty() && !$redirectValidator->isValid($state['success'])) { throw new Exception(Exception::PROJECT_INVALID_SUCCESS_URL); } diff --git a/src/Appwrite/Network/Validator/Origin.php b/src/Appwrite/Network/Validator/Origin.php index 02d5d8e83d..2f76aa2f86 100644 --- a/src/Appwrite/Network/Validator/Origin.php +++ b/src/Appwrite/Network/Validator/Origin.php @@ -22,6 +22,27 @@ class Origin extends Validator { } + public function setAllowedHostnames(array $allowedHostnames): self + { + $this->allowedHostnames = $allowedHostnames; + return $this; + } + + public function setAllowedSchemes(array $allowedSchemes): self + { + $this->allowedSchemes = $allowedSchemes; + return $this; + } + + public function getAllowedHostnames(): array + { + return $this->allowedHostnames; + } + + public function getAllowedSchemes(): array + { + return $this->allowedSchemes; + } /** * Check if Origin is valid. diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index e2e5621662..5280509967 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5191,6 +5191,24 @@ class ProjectsConsoleClientTest extends Scope ], followRedirects: false); $this->assertEquals(400, $response['headers']['status-code']); + // Also ensure final step blocks unknown redirect URL + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'origin' => '', + 'referer' => 'https://mockserver.com', + ], [ + 'code' => 'any-code', + 'state' => \json_encode([ + 'success' => 'https://domain-without-rule.com', + 'failure' => 'https://domain-without-rule.com' + ]), + 'error' => '', + 'error_description' => '', + ], followRedirects: false); + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertStringContainsString('project_invalid_success_url', $response['body']); + // Ensure rule's domain can be redirect URL $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, [ 'content-type' => 'application/json', @@ -5203,6 +5221,24 @@ class ProjectsConsoleClientTest extends Scope ], followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); + // Also ensure final step allows redirect URL + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'origin' => '', + 'referer' => 'https://mockserver.com', + ], [ + 'code' => 'any-code', + 'state' => \json_encode([ + 'success' => 'https://' . $domain, + 'failure' => 'https://' . $domain + ]), + 'error' => '', + 'error_deescription' => '', + ], followRedirects: false); + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringContainsString('https://' . $domain, $response['headers']['location']); + // Ensure unknown domain cannot be redirect URL $response = $this->client->call(Client::METHOD_POST, '/account/sessions/magic-url', [ 'content-type' => 'application/json', diff --git a/tests/unit/Network/Validators/OriginTest.php b/tests/unit/Network/Validators/OriginTest.php index a4c235f755..aa3ab65e5a 100644 --- a/tests/unit/Network/Validators/OriginTest.php +++ b/tests/unit/Network/Validators/OriginTest.php @@ -74,4 +74,60 @@ class OriginTest extends TestCase $this->assertEquals(false, $validator->isValid('random-scheme://localhost')); $this->assertEquals('Invalid Scheme. The scheme used (random-scheme) in the Origin (random-scheme://localhost) is not supported. If you are using a custom scheme, please change it to `appwrite-callback-`', $validator->getDescription()); } + + public function testGetAllowedHostnames(): void + { + $validator = new Origin( + allowedHostnames: ['appwrite.io', 'localhost'], + allowedSchemes: ['exp'] + ); + + $this->assertEquals(['appwrite.io', 'localhost'], $validator->getAllowedHostnames()); + } + + public function testGetAllowedSchemes(): void + { + $validator = new Origin( + allowedHostnames: ['appwrite.io'], + allowedSchemes: ['exp', 'appwrite-callback-123'] + ); + + $this->assertEquals(['exp', 'appwrite-callback-123'], $validator->getAllowedSchemes()); + } + + public function testSetAllowedHostnames(): void + { + $validator = new Origin( + allowedHostnames: ['appwrite.io'], + allowedSchemes: ['exp'] + ); + + $this->assertEquals(true, $validator->isValid('https://appwrite.io')); + $this->assertEquals(false, $validator->isValid('https://example.com')); + + $result = $validator->setAllowedHostnames(['example.com']); + + $this->assertSame($validator, $result); + $this->assertEquals(['example.com'], $validator->getAllowedHostnames()); + $this->assertEquals(true, $validator->isValid('https://example.com')); + $this->assertEquals(false, $validator->isValid('https://appwrite.io')); + } + + public function testSetAllowedSchemes(): void + { + $validator = new Origin( + allowedHostnames: ['appwrite.io'], + allowedSchemes: ['exp'] + ); + + $this->assertEquals(true, $validator->isValid('exp://')); + $this->assertEquals(false, $validator->isValid('appwrite-callback-456://')); + + $result = $validator->setAllowedSchemes(['appwrite-callback-456']); + + $this->assertSame($validator, $result); + $this->assertEquals(['appwrite-callback-456'], $validator->getAllowedSchemes()); + $this->assertEquals(true, $validator->isValid('appwrite-callback-456://')); + $this->assertEquals(false, $validator->isValid('exp://')); + } }