From bed46a6bef1709002eb25c08e8673de31a21b7b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 8 Feb 2026 14:12:23 +0100 Subject: [PATCH 1/5] Fix redirect url approval for oauth flow --- app/init/resources.php | 77 +++++++++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/app/init/resources.php b/app/init/resources.php index ccbb703f50..9227b0fe99 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -236,45 +236,68 @@ Http::setResource('allowedSchemes', function (Document $project) { * Rule associated with a request origin. */ Http::setResource('rule', function (Request $request, Database $dbForPlatform, Document $project, Authorization $authorization) { - $domain = \parse_url($request->getOrigin(), PHP_URL_HOST); - if (empty($domain)) { + $domains = []; + + $originDomain = \parse_url($request->getOrigin(), PHP_URL_HOST); + if (!empty($originDomain)) { + $domains[] = $originDomain; + } + + $refererDomain = \parse_url($request->getReferer(), PHP_URL_HOST); + if (!empty($refererDomain)) { + $domains[] = $refererDomain; + } + + if (\count($domains) === 0) { return new Document(); } - // TODO: (@Meldiron) Remove after 1.7.x migration - $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; - $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) { - if ($isMd5) { - return $dbForPlatform->getDocument('rules', md5($domain)); - } + $permittedRule = null; - return $dbForPlatform->findOne('rules', [ - Query::equal('domain', [$domain]), - ]) ?? new Document(); - }); - - $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); - - // Temporary implementation until custom wildcard domains are an official feature - // Allow trusted projects; Used for Console (website) previews - if (!$permitsCurrentProject && !$rule->isEmpty() && !empty($rule->getAttribute('projectId', ''))) { - $trustedProjects = []; - foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) { - if (empty($trustedProject)) { - continue; + foreach ($domains as $domain) { + // TODO: (@Meldiron) Remove after 1.7.x migration + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) { + if ($isMd5) { + return $dbForPlatform->getDocument('rules', md5($domain)); } - $trustedProjects[] = $trustedProject; + + return $dbForPlatform->findOne('rules', [ + Query::equal('domain', [$domain]), + ]) ?? new Document(); + }); + + if ($rule->isEmpty()) { + continue; } - if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects)) { - $permitsCurrentProject = true; + + $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); + + // Temporary implementation until custom wildcard domains are an official feature + // Allow trusted projects; Used for Console (website) previews + if (!$permitsCurrentProject && !$rule->isEmpty() && !empty($rule->getAttribute('projectId', ''))) { + $trustedProjects = []; + foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) { + if (empty($trustedProject)) { + continue; + } + $trustedProjects[] = $trustedProject; + } + if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects)) { + $permitsCurrentProject = true; + } + } + + if ($permitsCurrentProject) { + $permittedRule = $rule; } } - if (!$permitsCurrentProject) { + if (\is_null($permittedRule)) { return new Document(); } - return $rule; + return $permittedRule; }, ['request', 'dbForPlatform', 'project', 'authorization']); /** From 3a0dc60a4cc638012338946a76e3b48bc9ce16ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 8 Feb 2026 14:36:34 +0100 Subject: [PATCH 2/5] Add test; fix implementation; pr reviews --- app/init/resources.php | 90 +++++++++---------- .../Projects/ProjectsConsoleClientTest.php | 85 ++++++++++++++++++ 2 files changed, 126 insertions(+), 49 deletions(-) diff --git a/app/init/resources.php b/app/init/resources.php index 9227b0fe99..9fdfdedeba 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -198,15 +198,26 @@ Http::setResource('allowedHostnames', function (array $platform, Document $proje } $originHostname = parse_url($request->getOrigin(), PHP_URL_HOST); + $refererHostname = parse_url($request->getReferer(), PHP_URL_HOST); + + $hostname = $originHostname; + if (empty($hostname)) { + $hostname = $refererHostname; + } /* Add request hostname for preflight requests */ if ($request->getMethod() === 'OPTIONS') { - $allowed[] = $originHostname; + $allowed[] = $hostname; } - /* Allow the request origin if a dev key or rule is found */ - if ((!$rule->isEmpty() || !$devKey->isEmpty()) && !empty($originHostname)) { - $allowed[] = $originHostname; + /* Allow the request origin of rule */ + if (!$rule->isEmpty() && !empty($rule->getAttribute('domain', ''))) { + $allowed[] = $rule->getAttribute('domain', ''); + } + + /* Allow the request origin if a dev key is found */ + if (!$devKey->isEmpty() && !empty($hostname)) { + $allowed[] = $hostname; } return array_unique($allowed); @@ -236,68 +247,49 @@ Http::setResource('allowedSchemes', function (Document $project) { * Rule associated with a request origin. */ Http::setResource('rule', function (Request $request, Database $dbForPlatform, Document $project, Authorization $authorization) { - $domains = []; + $domain = \parse_url($request->getOrigin(), PHP_URL_HOST); - $originDomain = \parse_url($request->getOrigin(), PHP_URL_HOST); - if (!empty($originDomain)) { - $domains[] = $originDomain; + if (empty($domain)) { + $domain = \parse_url($request->getReferer(), PHP_URL_HOST); } - $refererDomain = \parse_url($request->getReferer(), PHP_URL_HOST); - if (!empty($refererDomain)) { - $domains[] = $refererDomain; - } - - if (\count($domains) === 0) { + if (empty($domain)) { return new Document(); } - $permittedRule = null; - - foreach ($domains as $domain) { - // TODO: (@Meldiron) Remove after 1.7.x migration - $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; - $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) { - if ($isMd5) { - return $dbForPlatform->getDocument('rules', md5($domain)); - } - - return $dbForPlatform->findOne('rules', [ - Query::equal('domain', [$domain]), - ]) ?? new Document(); - }); - - if ($rule->isEmpty()) { - continue; + $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; + $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) { + if ($isMd5) { + return $dbForPlatform->getDocument('rules', md5($domain)); } - $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); + return $dbForPlatform->findOne('rules', [ + Query::equal('domain', [$domain]), + ]) ?? new Document(); + }); - // Temporary implementation until custom wildcard domains are an official feature - // Allow trusted projects; Used for Console (website) previews - if (!$permitsCurrentProject && !$rule->isEmpty() && !empty($rule->getAttribute('projectId', ''))) { - $trustedProjects = []; - foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) { - if (empty($trustedProject)) { - continue; - } - $trustedProjects[] = $trustedProject; - } - if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects)) { - $permitsCurrentProject = true; + $permitsCurrentProject = $rule->getAttribute('projectInternalId', '') === $project->getSequence(); + + // Temporary implementation until custom wildcard domains are an official feature + // Allow trusted projects; Used for Console (website) previews + if (!$permitsCurrentProject && !$rule->isEmpty() && !empty($rule->getAttribute('projectId', ''))) { + $trustedProjects = []; + foreach (\explode(',', System::getEnv('_APP_CONSOLE_TRUSTED_PROJECTS', '')) as $trustedProject) { + if (empty($trustedProject)) { + continue; } + $trustedProjects[] = $trustedProject; } - - if ($permitsCurrentProject) { - $permittedRule = $rule; + if (\in_array($rule->getAttribute('projectId', ''), $trustedProjects)) { + $permitsCurrentProject = true; } } - if (\is_null($permittedRule)) { + if (!$permitsCurrentProject) { return new Document(); } - return $permittedRule; + return $rule; }, ['request', 'dbForPlatform', 'project', 'authorization']); /** diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 559ffe9f1d..460c9e365a 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5116,6 +5116,91 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); } + public function testRuleOAuthRedirect(): void + { + // Prepare project + $projectId = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'testRuleOAuthRedirect', + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $provider = 'mock'; + $appId = '1'; + $secret = '123456'; + + // Prepare OAuth provider + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/oauth2', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'provider' => $provider, + 'appId' => $appId, + 'secret' => $secret, + 'enabled' => true, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + // Prepare rule. In reality this is site rule, but for testing, API rule is enough, and faster to prepare + $domain = \uniqid() . '-with-rule.custom.localhost'; + $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'domain' => $domain + ]); + + $this->assertEquals(201, $rule['headers']['status-code']); + + // Ensure unknown domain cannot be redirect URL + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'referer' => 'https://' . $domain, + ], [ + 'success' => 'https://domain-without-rule.com', + 'failure' => 'https://domain-without-rule.com' + ], followRedirects: false); + $this->assertEquals(400, $response['headers']['status-code']); + + // Ensure rule's domain can be redirect URL + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'referer' => 'https://' . $domain, + ], [ + 'success' => 'https://' . $domain, + 'failure' => 'https://' . $domain + ], followRedirects: false); + $this->assertEquals(301, $response['headers']['status-code']); + + // Ensure unknown domain cannot be redirect URL + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/magic-url', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'referer' => 'https://' . $domain, + ], [ + 'userId' => ID::unique(), + 'email' => 'user@appwrite.io', + 'url' => 'https://domain-without-rule.com', + ]); + $this->assertEquals(400, $response['headers']['status-code']); + + // Ensure rule's domain can be redirect URL + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/magic-url', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'referer' => 'https://' . $domain, + ], [ + 'userId' => ID::unique(), + 'email' => 'user@appwrite.io', + 'url' => 'https://' . $domain, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + } + /** * @group abuseEnabled */ From fd323feae8d78596c03fbebd9ee3d83acc524df5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 8 Feb 2026 14:37:35 +0100 Subject: [PATCH 3/5] Simplify diff --- app/init/resources.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/init/resources.php b/app/init/resources.php index 9fdfdedeba..8f78df1573 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -257,6 +257,7 @@ Http::setResource('rule', function (Request $request, Database $dbForPlatform, D return new Document(); } + // TODO: (@Meldiron) Remove after 1.7.x migration $isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5'; $rule = $authorization->skip(function () use ($dbForPlatform, $domain, $isMd5) { if ($isMd5) { From 94581adfcb2085a3abf1d3345ed9735034210fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 8 Feb 2026 14:44:21 +0100 Subject: [PATCH 4/5] Improve dev key tests --- .../Projects/ProjectsConsoleClientTest.php | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 460c9e365a..9c9b93d43b 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5091,6 +5091,31 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://example.com' ]); $this->assertEquals(200, $response['headers']['status-code']); + + /** Ensure any hostname is allowed */ + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-dev-key' => $devKey['secret'], + 'origin' => '', + 'referer' => 'https://domain-without-rule.com' + ], [ + 'success' => 'https://domain-without-rule.com', + 'failure' => 'https://domain-without-rule.com' + ], followRedirects: false); + $this->assertEquals(301, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-dev-key' => $devKey['secret'], + 'referer' => '', + 'origin' => 'https://domain-without-rule.com' + ], [ + 'success' => 'https://domain-without-rule.com', + 'failure' => 'https://domain-without-rule.com' + ], followRedirects: false); + $this->assertEquals(301, $response['headers']['status-code']); /** Test hostname in Magic URL */ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/magic-url', [ @@ -5159,6 +5184,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'referer' => 'https://' . $domain, + 'origin' => '', ], [ 'success' => 'https://domain-without-rule.com', 'failure' => 'https://domain-without-rule.com' @@ -5170,6 +5196,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'referer' => 'https://' . $domain, + 'origin' => '', ], [ 'success' => 'https://' . $domain, 'failure' => 'https://' . $domain @@ -5181,6 +5208,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'referer' => 'https://' . $domain, + 'origin' => '', ], [ 'userId' => ID::unique(), 'email' => 'user@appwrite.io', @@ -5193,6 +5221,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'referer' => 'https://' . $domain, + 'origin' => '', ], [ 'userId' => ID::unique(), 'email' => 'user@appwrite.io', From 801707c4072bc90c2a20b4a5b9924d9379252054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 8 Feb 2026 14:47:47 +0100 Subject: [PATCH 5/5] Linter fix --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 9c9b93d43b..e2e5621662 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5091,7 +5091,7 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://example.com' ]); $this->assertEquals(200, $response['headers']['status-code']); - + /** Ensure any hostname is allowed */ $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, [ 'content-type' => 'application/json', @@ -5104,7 +5104,7 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://domain-without-rule.com' ], followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); - + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId,