Merge pull request #11369 from appwrite/fix-email-url-params-encoding

Fix: url params in redirect URLs
This commit is contained in:
Matej Bačo
2026-02-23 16:52:44 +01:00
committed by GitHub
8 changed files with 175 additions and 9 deletions
+1
View File
@@ -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
+1
View File
@@ -24,6 +24,7 @@ return [
System::getEnv('_APP_CONSOLE_DOMAIN', 'localhost'),
System::getEnv('_APP_MIGRATION_HOST'),
])),
'schemas' => \array_filter(\explode(',', System::getEnv('_APP_CONSOLE_SCHEMA', ''))),
'platformName' => APP_EMAIL_PLATFORM_NAME,
'logoUrl' => APP_EMAIL_LOGO_URL,
'accentColor' => APP_EMAIL_ACCENT_COLOR,
+1 -3
View File
@@ -2111,7 +2111,7 @@ 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);
if ($phrase === true) {
$phrase = Phrase::generate();
@@ -3573,7 +3573,6 @@ Http::post('/v1/account/recovery')
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled');
}
$url = htmlentities($url);
$email = \strtolower($email);
$profile = $dbForProject->findOne('users', [
@@ -3889,7 +3888,6 @@ Http::post('/v1/account/verifications/email')
throw new Exception(Exception::USER_EMAIL_NOT_FOUND);
}
$url = htmlentities($url);
if ($user->getAttribute('emailVerification')) {
throw new Exception(Exception::USER_EMAIL_ALREADY_VERIFIED);
}
-1
View File
@@ -498,7 +498,6 @@ Http::post('/v1/teams/:teamId/memberships')
$isAppUser = User::isApp($authorization->getRoles());
$isPrivilegedUser = User::isPrivileged($authorization->getRoles());
$url = htmlentities($url);
if (empty($url)) {
if (!$isAppUser && !$isPrivilegedUser) {
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'URL is required');
+3 -3
View File
@@ -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.
+1
View File
@@ -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
@@ -6597,4 +6597,168 @@ 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']);
// 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,
]
);
$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']);
// 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\"></a><h1>INJECTED</h1>';
$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->assertStringContainsString('<h1>', $lastEmail['html']);
$this->assertStringContainsString('</h1>', $lastEmail['html']);
$this->assertStringContainsString('">', $lastEmail['html']);
$this->assertStringContainsString('</a>', $lastEmail['html']);
$expectedUrl = $url . "?userId=" . $userId . "&secret=";
$this->assertStringContainsString($expectedUrl, $lastEmail['html']);
}
}
@@ -157,9 +157,11 @@ class TeamsCustomClientTest extends Scope
'testTeamsInviteHTMLInjection' => $email
], JSON_PRETTY_PRINT));
$encoded = 'http://localhost:5000/join-us\&quot;&gt;&lt;/a&gt;&lt;h1&gt;INJECTED&lt;/h1&gt;?';
$this->assertStringNotContainsString('<h1>INJECTED</h1>', $email['html']);
// injection allowed, meant to be protected client-side
$encoded = 'http://localhost:5000/join-us\"></a><h1>INJECTED</h1>';
$this->assertStringContainsString('<h1>INJECTED</h1>', $email['html']);
$this->assertStringContainsString($encoded, $email['html']);
$response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamUid . '/memberships/'.$response['body']['$id'], array_merge([