From 36c87d109a4d9a7d5f79056a38fd938197c4fe7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 13:45:18 +0100 Subject: [PATCH 1/8] Fix rule oauth flow --- app/controllers/api/account.php | 20 ++++++++++++++++++-- src/Appwrite/Network/Validator/Origin.php | 21 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index bc84ae7ef6..8d819d429d 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -64,7 +64,6 @@ use Utopia\Http; use Utopia\Locale\Locale; use Utopia\Storage\Validator\FileName; use Utopia\System\System; -use Utopia\Validator; use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; use Utopia\Validator\Boolean; @@ -1469,13 +1468,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, Redirect $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 +1512,22 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') $state = $defaultState; } + // Allow redirect to rule URL if related to project + $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('projectIntenralId', [$project->getSequence()]), + Query::limit(2) + ])); + + foreach ($rules as $rule) { + $allowedHostnames = $redirectValidator->getAllowedHostnames(); + $allowedHostnames[] = $rule['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. From 074ffad82624fffabec966075fce86e6cbb96287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 13:46:15 +0100 Subject: [PATCH 2/8] Improve origin unit tests --- tests/unit/Network/Validators/OriginTest.php | 56 ++++++++++++++++++++ 1 file changed, 56 insertions(+) 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://')); + } } From 525b929e54b668227b94b00d60574ca820ef9bf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 13:57:19 +0100 Subject: [PATCH 3/8] Fix implementation, add tests --- app/controllers/api/account.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 8d819d429d..afb45dbfb9 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1518,7 +1518,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') parse_url($state['success'], PHP_URL_HOST), parse_url($state['failure'], PHP_URL_HOST) ]), - Query::equal('projectIntenralId', [$project->getSequence()]), + Query::equal('projectInternalId', [$project->getSequence()]), Query::limit(2) ])); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index e2e5621662..abdcbcee24 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5190,6 +5190,25 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://domain-without-rule.com' ], 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, + 'referer' => 'https://' . $domain, + 'origin' => '', + 'referer' => 'https://mockserver.com', + ], [ + 'code' => 'any-code', + 'state' => \json_encode([ + 'success' => 'https://domain-without-rule.com', + 'failure' => 'https://domain-without-rule.com' + ]), + 'error' => '', + 'errorDescription' => '', + ], 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, [ @@ -5202,6 +5221,25 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://' . $domain ], 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, + 'referer' => 'https://' . $domain, + 'origin' => '', + 'referer' => 'https://mockserver.com', + ], [ + 'code' => 'any-code', + 'state' => \json_encode([ + 'success' => 'https://' . $domain, + 'failure' => 'https://' . $domain + ]), + 'error' => '', + 'errorDescription' => '', + ], 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', [ From 40ab50ec9de23fd28c48c9bdf1784fab83f84445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 14:34:52 +0100 Subject: [PATCH 4/8] formatting fix --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index abdcbcee24..c406632a2d 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5190,7 +5190,7 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://domain-without-rule.com' ], 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', @@ -5221,7 +5221,7 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://' . $domain ], 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', @@ -5232,8 +5232,8 @@ class ProjectsConsoleClientTest extends Scope ], [ 'code' => 'any-code', 'state' => \json_encode([ - 'success' => 'https://' . $domain, - 'failure' => 'https://' . $domain + 'success' => 'https://' . $domain, + 'failure' => 'https://' . $domain ]), 'error' => '', 'errorDescription' => '', From 96e85c0bab0c0d9719913cf90a750e572e338370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 14:35:47 +0100 Subject: [PATCH 5/8] AI pr review --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index c406632a2d..74d1aa9580 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5195,7 +5195,6 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, - 'referer' => 'https://' . $domain, 'origin' => '', 'referer' => 'https://mockserver.com', ], [ @@ -5226,7 +5225,6 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider . '/redirect', [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, - 'referer' => 'https://' . $domain, 'origin' => '', 'referer' => 'https://mockserver.com', ], [ From 7bf5f2d36074989d30c0f999f1cd0572fe014b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 15:55:35 +0100 Subject: [PATCH 6/8] Fix bug 5xx error --- .env | 2 +- app/controllers/api/account.php | 30 +++++++++++++++++------------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/.env b/.env index 7ac8fc25ef..c190e29c0b 100644 --- a/.env +++ b/.env @@ -18,7 +18,7 @@ _APP_EMAIL_SECURITY=security@appwrite.io _APP_EMAIL_CERTIFICATES=certificates@appwrite.io _APP_SYSTEM_RESPONSE_FORMAT= _APP_CUSTOM_DOMAIN_DENY_LIST= -_APP_OPTIONS_ABUSE=disabled +_APP_OPTIONS_ABUSE=enabled _APP_OPTIONS_ROUTER_PROTECTION=disabled _APP_OPTIONS_FORCE_HTTPS=disabled _APP_OPTIONS_ROUTER_FORCE_HTTPS=disabled diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index afb45dbfb9..1e2bb5aee0 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -64,6 +64,7 @@ use Utopia\Http; use Utopia\Locale\Locale; use Utopia\Storage\Validator\FileName; use Utopia\System\System; +use Utopia\Validator; use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; use Utopia\Validator\Boolean; @@ -1475,7 +1476,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->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, Redirect $redirectValidator, Document $devKey, User $user, Database $dbForProject, Database $dbForPlatform, 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(); @@ -1513,19 +1514,22 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') } // Allow redirect to rule URL if related to project - $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) - ])); + //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['domain']; - $redirectValidator->setAllowedHostnames($allowedHostnames); + foreach ($rules as $rule) { + $allowedHostnames = $redirectValidator->getAllowedHostnames(); + $allowedHostnames[] = $rule['domain']; + $redirectValidator->setAllowedHostnames($allowedHostnames); + } } if ($devKey->isEmpty() && !$redirectValidator->isValid($state['success'])) { From 3dc69ba62abb6acc4b2b58722071173bc037fd75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 15:55:49 +0100 Subject: [PATCH 7/8] Revert unwanted push --- .env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env b/.env index c190e29c0b..7ac8fc25ef 100644 --- a/.env +++ b/.env @@ -18,7 +18,7 @@ _APP_EMAIL_SECURITY=security@appwrite.io _APP_EMAIL_CERTIFICATES=certificates@appwrite.io _APP_SYSTEM_RESPONSE_FORMAT= _APP_CUSTOM_DOMAIN_DENY_LIST= -_APP_OPTIONS_ABUSE=enabled +_APP_OPTIONS_ABUSE=disabled _APP_OPTIONS_ROUTER_PROTECTION=disabled _APP_OPTIONS_FORCE_HTTPS=disabled _APP_OPTIONS_ROUTER_FORCE_HTTPS=disabled From a263afeff107dcda768c0703ff8a163e756bed6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 9 Feb 2026 17:10:00 +0100 Subject: [PATCH 8/8] AI quality fixes --- app/controllers/api/account.php | 2 +- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 1e2bb5aee0..17515fe949 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1527,7 +1527,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') foreach ($rules as $rule) { $allowedHostnames = $redirectValidator->getAllowedHostnames(); - $allowedHostnames[] = $rule['domain']; + $allowedHostnames[] = $rule->getAttribute('domain', ''); $redirectValidator->setAllowedHostnames($allowedHostnames); } } diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 74d1aa9580..5280509967 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5204,7 +5204,7 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://domain-without-rule.com' ]), 'error' => '', - 'errorDescription' => '', + 'error_description' => '', ], followRedirects: false); $this->assertEquals(400, $response['headers']['status-code']); $this->assertStringContainsString('project_invalid_success_url', $response['body']); @@ -5234,7 +5234,7 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://' . $domain ]), 'error' => '', - 'errorDescription' => '', + 'error_deescription' => '', ], followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); $this->assertStringContainsString('https://' . $domain, $response['headers']['location']);