From e2bc3afce6fbe940f778bac682fb86d42ed26559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 20 Feb 2026 11:15:59 +0100 Subject: [PATCH 1/6] Fix url params in redirect URLs --- app/controllers/api/account.php | 33 +++++- app/controllers/api/teams.php | 13 ++- .../Projects/ProjectsConsoleClientTest.php | 101 ++++++++++++++++++ 3 files changed, 143 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 2ab880d307..ed04b003d8 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2111,7 +2111,16 @@ Http::post('/v1/account/tokens/magic-url') if (empty(System::getEnv('_APP_SMTP_HOST'))) { throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled'); } - $url = htmlentities($url); + + $url = \parse_url($url); + $url['path'] = \htmlentities($url['path'] ?? ''); + $url = (isset($url['scheme']) ? $url['scheme'] . '://' : '') . + (isset($url['user']) ? $url['user'] . (isset($url['pass']) ? ':' . $url['pass'] : '') . '@' : '') . + (isset($url['host']) ? $url['host'] : '') . + (isset($url['port']) ? ':' . $url['port'] : '') . + (isset($url['path']) ? $url['path'] : '') . + (isset($url['query']) ? '?' . $url['query'] : '') . + (isset($url['fragment']) ? '#' . $url['fragment'] : ''); if ($phrase === true) { $phrase = Phrase::generate(); @@ -3573,7 +3582,16 @@ Http::post('/v1/account/recovery') throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled'); } - $url = htmlentities($url); + $url = \parse_url($url); + $url['path'] = \htmlentities($url['path'] ?? ''); + $url = (isset($url['scheme']) ? $url['scheme'] . '://' : '') . + (isset($url['user']) ? $url['user'] . (isset($url['pass']) ? ':' . $url['pass'] : '') . '@' : '') . + (isset($url['host']) ? $url['host'] : '') . + (isset($url['port']) ? ':' . $url['port'] : '') . + (isset($url['path']) ? $url['path'] : '') . + (isset($url['query']) ? '?' . $url['query'] : '') . + (isset($url['fragment']) ? '#' . $url['fragment'] : ''); + $email = \strtolower($email); $profile = $dbForProject->findOne('users', [ @@ -3889,7 +3907,16 @@ Http::post('/v1/account/verifications/email') throw new Exception(Exception::USER_EMAIL_NOT_FOUND); } - $url = htmlentities($url); + $url = \parse_url($url); + $url['path'] = \htmlentities($url['path'] ?? ''); + $url = (isset($url['scheme']) ? $url['scheme'] . '://' : '') . + (isset($url['user']) ? $url['user'] . (isset($url['pass']) ? ':' . $url['pass'] : '') . '@' : '') . + (isset($url['host']) ? $url['host'] : '') . + (isset($url['port']) ? ':' . $url['port'] : '') . + (isset($url['path']) ? $url['path'] : '') . + (isset($url['query']) ? '?' . $url['query'] : '') . + (isset($url['fragment']) ? '#' . $url['fragment'] : ''); + if ($user->getAttribute('emailVerification')) { throw new Exception(Exception::USER_EMAIL_ALREADY_VERIFIED); } diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index a674cdc40f..28d58ec770 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -498,7 +498,18 @@ Http::post('/v1/teams/:teamId/memberships') $isAppUser = User::isApp($authorization->getRoles()); $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); - $url = htmlentities($url); + if (!empty($url)) { + $url = \parse_url($url); + $url['path'] = \htmlentities($url['path'] ?? ''); + $url = (isset($url['scheme']) ? $url['scheme'] . '://' : '') . + (isset($url['user']) ? $url['user'] . (isset($url['pass']) ? ':' . $url['pass'] : '') . '@' : '') . + (isset($url['host']) ? $url['host'] : '') . + (isset($url['port']) ? ':' . $url['port'] : '') . + (isset($url['path']) ? $url['path'] : '') . + (isset($url['query']) ? '?' . $url['query'] : '') . + (isset($url['fragment']) ? '#' . $url['fragment'] : ''); + + } if (empty($url)) { if (!$isAppUser && !$isPrivilegedUser) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'URL is required'); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 2909ba47f4..14e310ff97 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -6597,4 +6597,105 @@ class ProjectsConsoleClientTest extends Scope } } } + + public function testPasswordRecoveryUrlParams(): void + { + // With search params + $url = 'http://localhost/auth/signin?id=abcd1234&tenant=efgh5678&domain=example.com&referred=0'; + + $response = $this->client->call( + Client::METHOD_POST, + '/account/recovery', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + [ + 'userId' => ID::unique(), + 'email' => $this->getUser()['email'], + 'url' => $url, + ] + ); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['userId']); + + $userId = $response['body']['userId']; + + $lastEmail = $this->getLastEmail(); + + $this->assertEquals($this->getUser()['email'], $lastEmail['to'][0]['address']); + $this->assertEquals('Password Reset for ' . $this->getProject()['name'], $lastEmail['subject']); + + $expectedUrl = $url . "&userId=" . $userId . "&secret="; + + $this->assertStringContainsString($expectedUrl, $lastEmail['html']); + + // Without search params + $url = 'http://localhost/auth/signin'; + + $response = $this->client->call( + Client::METHOD_POST, + '/account/recovery', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + [ + 'userId' => ID::unique(), + 'email' => $this->getUser()['email'], + 'url' => $url, + ] + ); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['userId']); + + $userId = $response['body']['userId']; + + $lastEmail = $this->getLastEmail(); + + $this->assertEquals($this->getUser()['email'], $lastEmail['to'][0]['address']); + $this->assertEquals('Password Reset for ' . $this->getProject()['name'], $lastEmail['subject']); + + $expectedUrl = $url . "?userId=" . $userId . "&secret="; + + $this->assertStringContainsString($expectedUrl, $lastEmail['html']); + + // With injection + $url = 'http://localhost/auth/signin\">

INJECTED

'; + + $response = $this->client->call( + Client::METHOD_POST, + '/account/recovery', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + [ + 'userId' => ID::unique(), + 'email' => $this->getUser()['email'], + 'url' => $url, + ] + ); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['userId']); + + $userId = $response['body']['userId']; + + $lastEmail = $this->getLastEmail(); + + $this->assertEquals($this->getUser()['email'], $lastEmail['to'][0]['address']); + $this->assertEquals('Password Reset for ' . $this->getProject()['name'], $lastEmail['subject']); + + $this->assertStringContainsString('INJECTED', $lastEmail['html']); + $this->assertStringNotContainsString('

', $lastEmail['html']); + $this->assertStringNotContainsString('

', $lastEmail['html']); + + $sanitizedUrl = \htmlentities($url); + $expectedUrl = $sanitizedUrl . "?userId=" . $userId . "&secret="; + $this->assertStringContainsString($expectedUrl, $lastEmail['html']); + + } } From 2b6b66d8a437f105fbc86ab03834c6ee431ac78f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 23 Feb 2026 12:45:18 +0100 Subject: [PATCH 2/6] Remove html encoding for urls --- app/controllers/api/account.php | 29 ----------------------------- app/controllers/api/teams.php | 12 ------------ 2 files changed, 41 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index ed04b003d8..ff796f2209 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2112,15 +2112,6 @@ Http::post('/v1/account/tokens/magic-url') throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled'); } - $url = \parse_url($url); - $url['path'] = \htmlentities($url['path'] ?? ''); - $url = (isset($url['scheme']) ? $url['scheme'] . '://' : '') . - (isset($url['user']) ? $url['user'] . (isset($url['pass']) ? ':' . $url['pass'] : '') . '@' : '') . - (isset($url['host']) ? $url['host'] : '') . - (isset($url['port']) ? ':' . $url['port'] : '') . - (isset($url['path']) ? $url['path'] : '') . - (isset($url['query']) ? '?' . $url['query'] : '') . - (isset($url['fragment']) ? '#' . $url['fragment'] : ''); if ($phrase === true) { $phrase = Phrase::generate(); @@ -3582,16 +3573,6 @@ Http::post('/v1/account/recovery') throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled'); } - $url = \parse_url($url); - $url['path'] = \htmlentities($url['path'] ?? ''); - $url = (isset($url['scheme']) ? $url['scheme'] . '://' : '') . - (isset($url['user']) ? $url['user'] . (isset($url['pass']) ? ':' . $url['pass'] : '') . '@' : '') . - (isset($url['host']) ? $url['host'] : '') . - (isset($url['port']) ? ':' . $url['port'] : '') . - (isset($url['path']) ? $url['path'] : '') . - (isset($url['query']) ? '?' . $url['query'] : '') . - (isset($url['fragment']) ? '#' . $url['fragment'] : ''); - $email = \strtolower($email); $profile = $dbForProject->findOne('users', [ @@ -3907,16 +3888,6 @@ Http::post('/v1/account/verifications/email') throw new Exception(Exception::USER_EMAIL_NOT_FOUND); } - $url = \parse_url($url); - $url['path'] = \htmlentities($url['path'] ?? ''); - $url = (isset($url['scheme']) ? $url['scheme'] . '://' : '') . - (isset($url['user']) ? $url['user'] . (isset($url['pass']) ? ':' . $url['pass'] : '') . '@' : '') . - (isset($url['host']) ? $url['host'] : '') . - (isset($url['port']) ? ':' . $url['port'] : '') . - (isset($url['path']) ? $url['path'] : '') . - (isset($url['query']) ? '?' . $url['query'] : '') . - (isset($url['fragment']) ? '#' . $url['fragment'] : ''); - if ($user->getAttribute('emailVerification')) { throw new Exception(Exception::USER_EMAIL_ALREADY_VERIFIED); } diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 28d58ec770..7a3370a4ab 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -498,18 +498,6 @@ Http::post('/v1/teams/:teamId/memberships') $isAppUser = User::isApp($authorization->getRoles()); $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); - if (!empty($url)) { - $url = \parse_url($url); - $url['path'] = \htmlentities($url['path'] ?? ''); - $url = (isset($url['scheme']) ? $url['scheme'] . '://' : '') . - (isset($url['user']) ? $url['user'] . (isset($url['pass']) ? ':' . $url['pass'] : '') . '@' : '') . - (isset($url['host']) ? $url['host'] : '') . - (isset($url['port']) ? ':' . $url['port'] : '') . - (isset($url['path']) ? $url['path'] : '') . - (isset($url['query']) ? '?' . $url['query'] : '') . - (isset($url['fragment']) ? '#' . $url['fragment'] : ''); - - } if (empty($url)) { if (!$isAppUser && !$isPrivilegedUser) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'URL is required'); From 5414cc67ab7b223c116c210cd2fc40821bbc86ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 23 Feb 2026 12:54:20 +0100 Subject: [PATCH 3/6] Finalize html encoding rework --- .../Services/Projects/ProjectsConsoleClientTest.php | 11 ++++++----- tests/e2e/Services/Teams/TeamsCustomClientTest.php | 6 ++++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 14e310ff97..5f6982b7d7 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -6662,7 +6662,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertStringContainsString($expectedUrl, $lastEmail['html']); - // With injection + // With injection (allowed, meant to be protected client-side) $url = 'http://localhost/auth/signin\">

INJECTED

'; $response = $this->client->call( @@ -6690,11 +6690,12 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('Password Reset for ' . $this->getProject()['name'], $lastEmail['subject']); $this->assertStringContainsString('INJECTED', $lastEmail['html']); - $this->assertStringNotContainsString('

', $lastEmail['html']); - $this->assertStringNotContainsString('

', $lastEmail['html']); + $this->assertStringContainsString('

', $lastEmail['html']); + $this->assertStringContainsString('

', $lastEmail['html']); + $this->assertStringContainsString('">', $lastEmail['html']); + $this->assertStringContainsString('', $lastEmail['html']); - $sanitizedUrl = \htmlentities($url); - $expectedUrl = $sanitizedUrl . "?userId=" . $userId . "&secret="; + $expectedUrl = $url . "?userId=" . $userId . "&secret="; $this->assertStringContainsString($expectedUrl, $lastEmail['html']); } diff --git a/tests/e2e/Services/Teams/TeamsCustomClientTest.php b/tests/e2e/Services/Teams/TeamsCustomClientTest.php index 6dbead76db..973e64a62e 100644 --- a/tests/e2e/Services/Teams/TeamsCustomClientTest.php +++ b/tests/e2e/Services/Teams/TeamsCustomClientTest.php @@ -157,9 +157,11 @@ class TeamsCustomClientTest extends Scope 'testTeamsInviteHTMLInjection' => $email ], JSON_PRETTY_PRINT)); - $encoded = 'http://localhost:5000/join-us\"></a><h1>INJECTED</h1>?'; - $this->assertStringNotContainsString('

INJECTED

', $email['html']); + // injection allowed, meant to be protected client-side + $encoded = 'http://localhost:5000/join-us\">

INJECTED

'; + + $this->assertStringContainsString('

INJECTED

', $email['html']); $this->assertStringContainsString($encoded, $email['html']); $response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamUid . '/memberships/'.$response['body']['$id'], array_merge([ From f4ee6488ac0e014414c33f0bcd1a9e81399939d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 23 Feb 2026 13:09:26 +0100 Subject: [PATCH 4/6] Add tests for custom url schema in redirects --- .env | 1 + app/config/platform.php | 1 + app/init/resources.php | 6 +- docker-compose.yml | 1 + .../Projects/ProjectsConsoleClientTest.php | 63 +++++++++++++++++++ 5 files changed, 69 insertions(+), 3 deletions(-) diff --git a/.env b/.env index fee68d5a04..4ba52035ce 100644 --- a/.env +++ b/.env @@ -10,6 +10,7 @@ _APP_CONSOLE_SESSION_ALERTS=enabled _APP_CONSOLE_WHITELIST_IPS= _APP_CONSOLE_COUNTRIES_DENYLIST=AQ _APP_CONSOLE_HOSTNAMES=localhost,appwrite.io,*.appwrite.io +_APP_CONSOLE_SCHEMA=appwriteio _APP_MIGRATION_HOST=appwrite _APP_SYSTEM_EMAIL_NAME=Appwrite _APP_SYSTEM_EMAIL_ADDRESS=noreply@appwrite.io diff --git a/app/config/platform.php b/app/config/platform.php index 17dfd72217..40af996999 100644 --- a/app/config/platform.php +++ b/app/config/platform.php @@ -24,6 +24,7 @@ return [ System::getEnv('_APP_CONSOLE_DOMAIN', 'localhost'), System::getEnv('_APP_MIGRATION_HOST'), ])), + 'schemas' => \explode(',', System::getEnv('_APP_CONSOLE_SCHEMA', '')), 'platformName' => APP_EMAIL_PLATFORM_NAME, 'logoUrl' => APP_EMAIL_LOGO_URL, 'accentColor' => APP_EMAIL_ACCENT_COLOR, diff --git a/app/init/resources.php b/app/init/resources.php index 0bb5b17ce6..3fa53763d9 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -230,8 +230,8 @@ Http::setResource('allowedHostnames', function (array $platform, Document $proje /** * List of allowed request schemes for the request. */ -Http::setResource('allowedSchemes', function (Document $project) { - $allowed = []; +Http::setResource('allowedSchemes', function (array $platform, Document $project) { + $allowed = [...($platform['schemas'] ?? [])]; if (!$project->isEmpty() && $project->getId() !== 'console') { /* Add hardcoded schemes */ @@ -245,7 +245,7 @@ Http::setResource('allowedSchemes', function (Document $project) { } return array_unique($allowed); -}, ['project']); +}, ['platform', 'project']); /** * Rule associated with a request origin. diff --git a/docker-compose.yml b/docker-compose.yml index df9f6246fd..14a89643ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -121,6 +121,7 @@ services: - _APP_CONSOLE_SESSION_ALERTS - _APP_CONSOLE_WHITELIST_IPS - _APP_CONSOLE_HOSTNAMES + - _APP_CONSOLE_SCHEMA - _APP_SYSTEM_EMAIL_NAME - _APP_SYSTEM_EMAIL_ADDRESS - _APP_SYSTEM_TEAM_EMAIL diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 5f6982b7d7..11dc206ebc 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -6631,6 +6631,38 @@ class ProjectsConsoleClientTest extends Scope $this->assertStringContainsString($expectedUrl, $lastEmail['html']); + // With search params, with mobile backlink + $url = 'appwriteio://signin?id=abcd1234&tenant=efgh5678&domain=example.com&referred=0'; + + $response = $this->client->call( + Client::METHOD_POST, + '/account/recovery', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + [ + 'userId' => ID::unique(), + 'email' => $this->getUser()['email'], + 'url' => $url, + ] + ); + + \var_dump($response); + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['userId']); + + $userId = $response['body']['userId']; + + $lastEmail = $this->getLastEmail(); + + $this->assertEquals($this->getUser()['email'], $lastEmail['to'][0]['address']); + $this->assertEquals('Password Reset for ' . $this->getProject()['name'], $lastEmail['subject']); + + $expectedUrl = $url . "&userId=" . $userId . "&secret="; + + $this->assertStringContainsString($expectedUrl, $lastEmail['html']); + // Without search params $url = 'http://localhost/auth/signin'; @@ -6662,6 +6694,37 @@ class ProjectsConsoleClientTest extends Scope $this->assertStringContainsString($expectedUrl, $lastEmail['html']); + // Without search params, with mobile backlink + $url = 'appwriteio://signin'; + + $response = $this->client->call( + Client::METHOD_POST, + '/account/recovery', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + [ + 'userId' => ID::unique(), + 'email' => $this->getUser()['email'], + 'url' => $url, + ] + ); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['userId']); + + $userId = $response['body']['userId']; + + $lastEmail = $this->getLastEmail(); + + $this->assertEquals($this->getUser()['email'], $lastEmail['to'][0]['address']); + $this->assertEquals('Password Reset for ' . $this->getProject()['name'], $lastEmail['subject']); + + $expectedUrl = $url . "?userId=" . $userId . "&secret="; + + $this->assertStringContainsString($expectedUrl, $lastEmail['html']); + // With injection (allowed, meant to be protected client-side) $url = 'http://localhost/auth/signin\">

INJECTED

'; From 8946d3b7eea37b18446a3e8ed8059b2df9fc5f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 23 Feb 2026 16:19:19 +0100 Subject: [PATCH 5/6] AI review fixes --- app/config/platform.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/config/platform.php b/app/config/platform.php index 40af996999..21aedaad98 100644 --- a/app/config/platform.php +++ b/app/config/platform.php @@ -24,7 +24,7 @@ return [ System::getEnv('_APP_CONSOLE_DOMAIN', 'localhost'), System::getEnv('_APP_MIGRATION_HOST'), ])), - 'schemas' => \explode(',', System::getEnv('_APP_CONSOLE_SCHEMA', '')), + 'schemas' => \array_filter(\explode(',', System::getEnv('_APP_CONSOLE_SCHEMA', ''))), 'platformName' => APP_EMAIL_PLATFORM_NAME, 'logoUrl' => APP_EMAIL_LOGO_URL, 'accentColor' => APP_EMAIL_ACCENT_COLOR, From f0c8f7e00ec43e141068de1d3b55c230661e9cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 23 Feb 2026 16:49:17 +0100 Subject: [PATCH 6/6] Update tests/e2e/Services/Projects/ProjectsConsoleClientTest.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 11dc206ebc..4996f3808f 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -6648,7 +6648,6 @@ class ProjectsConsoleClientTest extends Scope ] ); - \var_dump($response); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['userId']);