From ff06920e2479610bec304e289ad42f4aaad9b67f Mon Sep 17 00:00:00 2001 From: Eldad Fux Date: Mon, 13 Oct 2025 23:43:54 +0100 Subject: [PATCH] feat: add support for blocking disposable email addresses - Introduced a new exception for disposable email addresses. - Updated user account creation and email handling to check against a list of disposable email domains. - Added a new API endpoint to enable or disable disposable email checks for projects. - Updated project model to include disposable email settings. - Configured loading of disposable email domains from a separate configuration file. --- app/config/domains/disposable-emails.php | 22 +++++++++++ app/config/errors.php | 5 +++ app/controllers/api/account.php | 32 +++++++++++++++ app/controllers/api/projects.php | 39 +++++++++++++++++++ app/controllers/api/users.php | 2 + app/init/configs.php | 1 + src/Appwrite/Extend/Exception.php | 1 + .../Utopia/Response/Model/Project.php | 1 + 8 files changed, 103 insertions(+) create mode 100644 app/config/domains/disposable-emails.php diff --git a/app/config/domains/disposable-emails.php b/app/config/domains/disposable-emails.php new file mode 100644 index 0000000000..b5516470d8 --- /dev/null +++ b/app/config/domains/disposable-emails.php @@ -0,0 +1,22 @@ + true, + '027168.com' => true, + '062e.com' => true, + '0815.ru' => true, + '0815.su' => true, + '0845.ru' => true, + '0box.eu' => true, + '0cd.cn' => true, + '10minutemail.com' => true, + 'tempmail.org' => true, + 'guerrillamail.com' => true, + 'mailinator.com' => true, + 'temp-mail.org' => true, + 'throwaway.email' => true, + 'yopmail.com' => true, + 'maildrop.cc' => true, + 'getnada.com' => true, + 'tempail.com' => true, +]; diff --git a/app/config/errors.php b/app/config/errors.php index 2e18f05797..58fa0a607e 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -221,6 +221,11 @@ return [ 'description' => 'A user with the same email already exists in the current project.', 'code' => 409, ], + Exception::USER_EMAIL_DISPOSABLE => [ + 'name' => Exception::USER_EMAIL_DISPOSABLE, + 'description' => 'Disposable email addresses are not allowed. Please use a permanent email address.', + 'code' => 400, + ], Exception::USER_PASSWORD_MISMATCH => [ 'name' => Exception::USER_PASSWORD_MISMATCH, 'description' => 'Passwords do not match. Please check the password and confirm password.', diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 5563fc6a59..18d3177251 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -369,6 +369,14 @@ App::post('/v1/account') } } + if ($project->getAttribute('auths', [])['disposableEmails'] ?? false) { + $disposableEmails = Config::getParam('disposableEmails', []); + $emailDomain = substr(strrchr($email, "@"), 1); + if (isset($disposableEmails[$emailDomain])) { + throw new Exception(Exception::USER_EMAIL_DISPOSABLE); + } + } + $hooks->trigger('passwordValidator', [$dbForProject, $project, $password, &$user, true]); $passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0; @@ -1967,6 +1975,14 @@ App::post('/v1/account/tokens/magic-url') throw new Exception(Exception::USER_EMAIL_ALREADY_EXISTS); } + if ($project->getAttribute('auths', [])['disposableEmails'] ?? false) { + $disposableEmails = Config::getParam('disposableEmails', []); + $emailDomain = substr(strrchr($email, "@"), 1); + if (isset($disposableEmails[$emailDomain])) { + throw new Exception(Exception::USER_EMAIL_DISPOSABLE); + } + } + $userId = $userId === 'unique()' ? ID::unique() : $userId; $user->setAttributes([ @@ -2217,6 +2233,14 @@ App::post('/v1/account/tokens/email') throw new Exception(Exception::GENERAL_BAD_REQUEST); /** Return a generic bad request to prevent exposing existing accounts */ } + if ($project->getAttribute('auths', [])['disposableEmails'] ?? false) { + $disposableEmails = Config::getParam('disposableEmails', []); + $emailDomain = substr(strrchr($email, "@"), 1); + if (isset($disposableEmails[$emailDomain])) { + throw new Exception(Exception::USER_EMAIL_DISPOSABLE); + } + } + $userId = $userId === 'unique()' ? ID::unique() : $userId; $user->setAttributes([ @@ -3050,6 +3074,14 @@ App::patch('/v1/account/email') throw new Exception(Exception::GENERAL_BAD_REQUEST); /** Return a generic bad request to prevent exposing existing accounts */ } + if ($project->getAttribute('auths', [])['disposableEmails'] ?? false) { + $disposableEmails = Config::getParam('disposableEmails', []); + $emailDomain = substr(strrchr($email, "@"), 1); + if (isset($disposableEmails[$emailDomain])) { + throw new Exception(Exception::USER_EMAIL_DISPOSABLE); + } + } + $user ->setAttribute('email', $email) ->setAttribute('emailVerification', false) // After this user needs to confirm mail again diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 80d407322e..c60de52bfb 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -120,6 +120,7 @@ App::post('/v1/projects') 'passwordDictionary' => false, 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, 'personalDataCheck' => false, + 'disposableEmails' => false, 'mockNumbers' => [], 'sessionAlerts' => false, 'membershipsUserName' => false, @@ -1021,6 +1022,44 @@ App::patch('/v1/projects/:projectId/auth/personal-data') $response->dynamic($project, Response::MODEL_PROJECT); }); +App::patch('/v1/projects/:projectId/auth/disposable-emails') + ->desc('Update disposable emails check') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'auth', + name: 'updateDisposableEmails', + description: '/docs/references/projects/update-disposable-emails.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PROJECT, + ) + ] + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('enabled', false, new Boolean(false), 'Set whether or not to block disposable email addresses. Default is false.') + ->inject('response') + ->inject('dbForPlatform') + ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { + + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $auths = $project->getAttribute('auths', []); + $auths['disposableEmails'] = $enabled; + + $dbForPlatform->updateDocument('projects', $project->getId(), $project + ->setAttribute('auths', $auths)); + + $response->dynamic($project, Response::MODEL_PROJECT); + }); + App::patch('/v1/projects/:projectId/auth/max-sessions') ->desc('Update project user sessions limit') ->groups(['api', 'projects']) diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 5498a33bf5..2b1e5bafea 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -97,6 +97,7 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e } } + $password = (!empty($password)) ? ($hash === 'plaintext' ? Auth::passwordHash($password, $hash, $hashOptionsObject) : $password) : null; $user = new Document([ '$id' => $userId, @@ -1324,6 +1325,7 @@ App::patch('/v1/users/:userId/password') } } + if (\strlen($password) === 0) { $user ->setAttribute('password', '') diff --git a/app/init/configs.php b/app/init/configs.php index 7572302919..1ec4d60480 100644 --- a/app/init/configs.php +++ b/app/init/configs.php @@ -40,3 +40,4 @@ Config::load('storage-outputs', __DIR__ . '/../config/storage/outputs.php'); Config::load('specifications', __DIR__ . '/../config/specifications.php'); Config::load('templates-function', __DIR__ . '/../config/templates/function.php'); Config::load('templates-site', __DIR__ . '/../config/templates/site.php'); +Config::load('disposableEmails', __DIR__ . '/../config/domains/disposable-emails.php'); // (eldad) following cloud camelCase format. Wish we could be consistent with the rest of the codebase. \ No newline at end of file diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 6f8744568a..21c0dc9ac5 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -81,6 +81,7 @@ class Exception extends \Exception public const string USER_PASSWORD_RECENTLY_USED = 'password_recently_used'; public const string USER_PASSWORD_PERSONAL_DATA = 'password_personal_data'; public const string USER_EMAIL_ALREADY_EXISTS = 'user_email_already_exists'; + public const string USER_EMAIL_DISPOSABLE = 'user_email_disposable'; public const string USER_PASSWORD_MISMATCH = 'user_password_mismatch'; public const string USER_SESSION_NOT_FOUND = 'user_session_not_found'; public const string USER_IDENTITY_NOT_FOUND = 'user_identity_not_found'; diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index abe67e7e86..03c2d5772e 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -377,6 +377,7 @@ class Project extends Model $document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0); $document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false); $document->setAttribute('authPersonalDataCheck', $authValues['personalDataCheck'] ?? false); + $document->setAttribute('authDisposableEmails', $authValues['disposableEmails'] ?? false); $document->setAttribute('authMockNumbers', $authValues['mockNumbers'] ?? []); $document->setAttribute('authSessionAlerts', $authValues['sessionAlerts'] ?? false); $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? true);