From e1689479869bb7d941484ecf4b9a4694d9e8dd2f Mon Sep 17 00:00:00 2001 From: DariusIII Date: Fri, 28 Nov 2025 17:07:45 +0100 Subject: [PATCH] Harden disposable email detection --- .../Controllers/Auth/RegisterController.php | 3 +- app/Http/Controllers/ProfileController.php | 3 +- app/Models/User.php | 3 +- app/Rules/ValidEmailDomain.php | 209 ++++++++++++++++++ config/disposable-email.php | 6 +- tests/Unit/Rules/ValidEmailDomainTest.php | 130 +++++++++++ 6 files changed, 348 insertions(+), 6 deletions(-) create mode 100644 app/Rules/ValidEmailDomain.php create mode 100644 tests/Unit/Rules/ValidEmailDomainTest.php diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index b0e805cb7..2880c6d0a 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -7,6 +7,7 @@ use App\Http\Requests\Auth\RegisterRegisterRequest; use App\Models\Invitation; use App\Models\Settings; use App\Models\User; +use App\Rules\ValidEmailDomain; use Illuminate\Foundation\Auth\RegistersUsers; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -127,7 +128,7 @@ class RegisterController extends Controller $validator = Validator::make($request->all(), [ 'username' => ['required', 'string', 'min:5', 'max:255', 'unique:users'], - 'email' => ['required', 'string', 'email', 'max:255', 'unique:users', 'indisposable'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:users', 'indisposable', new ValidEmailDomain()], 'password' => ['required', 'confirmed', Password::min(8)->letters()->mixedCase()->numbers()->symbols()->uncompromised()], ], [ 'password.min' => 'The password must be at least 8 characters.', diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index a5f93d236..ac705b360 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -7,6 +7,7 @@ use App\Models\ReleaseComment; use App\Models\User; use App\Models\UserDownload; use App\Models\UserRequest; +use App\Rules\ValidEmailDomain; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; use Illuminate\Foundation\Application; @@ -131,7 +132,7 @@ class ProfileController extends BasePageController return redirect()->to('profileedit'); case 'submit': $validator = Validator::make($request->all(), [ - 'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users,email,'.$userid, 'indisposable'], + 'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users,email,'.$userid, 'indisposable', new ValidEmailDomain()], 'password' => ['nullable', 'string', 'min:8', 'confirmed', 'regex:/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/'], ]); diff --git a/app/Models/User.php b/app/Models/User.php index 1e62ddadb..3449ca3a6 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Jobs\SendAccountExpiredEmail; use App\Jobs\SendAccountWillExpireEmail; +use App\Rules\ValidEmailDomain; use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -562,7 +563,7 @@ class User extends Authenticatable if ($validate) { $validator = Validator::make($user, [ 'username' => ['required', 'string', 'min:5', 'max:255', 'unique:users'], - 'email' => ['required', 'string', 'email', 'max:255', 'unique:users', 'indisposable'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:users', 'indisposable', new ValidEmailDomain()], 'password' => ['required', 'string', 'min:8', 'confirmed', 'regex:/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/'], ]); diff --git a/app/Rules/ValidEmailDomain.php b/app/Rules/ValidEmailDomain.php new file mode 100644 index 000000000..6ac28fad2 --- /dev/null +++ b/app/Rules/ValidEmailDomain.php @@ -0,0 +1,209 @@ + $value, + 'domain' => $domain, + ]); + $fail('Temporary or disposable email addresses are not allowed.'); + return; + } + + // Check 2: Check against our hardcoded blacklist + if (in_array($domain, self::BLOCKED_DOMAINS, true)) { + Log::warning('Disposable email attempt blocked (hardcoded blacklist)', [ + 'email' => $value, + 'domain' => $domain, + ]); + $fail('Temporary or disposable email addresses are not allowed.'); + return; + } + + // Check 3: Check for suspicious patterns in domain name + foreach (self::SUSPICIOUS_PATTERNS as $pattern) { + if (str_contains($domain, $pattern)) { + Log::warning('Disposable email attempt blocked (pattern match)', [ + 'email' => $value, + 'domain' => $domain, + 'pattern' => $pattern, + ]); + $fail('Temporary or disposable email addresses are not allowed.'); + return; + } + } + + // Check 4: Validate domain has valid DNS records (MX or A record) + if (! $this->validateDnsRecords($domain)) { + Log::warning('Email domain has no valid DNS records', [ + 'email' => $value, + 'domain' => $domain, + ]); + $fail('The email domain does not appear to be valid or reachable.'); + return; + } + + // Check 5: Block common free email services with plus addressing abuse + // (optional - you may want to comment this out if you want to allow Gmail, etc.) + // if ($this->hasSuspiciousPlusAddressing($value)) { + // Log::warning('Suspicious plus addressing detected', [ + // 'email' => $value, + // 'domain' => $domain, + // ]); + // $fail('This email format is not allowed.'); + // return; + // } + } + + /** + * Validate that the domain has proper DNS records + */ + private function validateDnsRecords(string $domain): bool + { + // Check for MX records (primary email validation) + if (@checkdnsrr($domain, 'MX')) { + return true; + } + + // Fall back to A record check (some domains use A records for email) + if (@checkdnsrr($domain, 'A')) { + return true; + } + + return false; + } + + /** + * Check for suspicious plus addressing patterns + * Some users abuse plus addressing to create multiple accounts + */ + private function hasSuspiciousPlusAddressing(string $email): bool + { + $parts = explode('@', $email); + $localPart = $parts[0]; + + // Check if contains + with suspicious patterns + if (str_contains($localPart, '+')) { + // You can add more sophisticated checks here + // For now, just log it but don't block + return false; + } + + return false; + } +} + diff --git a/config/disposable-email.php b/config/disposable-email.php index 44de7226d..f2d6ddd5c 100644 --- a/config/disposable-email.php +++ b/config/disposable-email.php @@ -10,12 +10,12 @@ return [ | The source URL yielding a list of disposable email domains. Change this | to whatever source you like. Just make sure it returns a JSON array. | - | A sensible default is provided using Rawgit's services. Rawgit is - | a free service, so there are no uptime or support guarantees. + | Updated to use the actively maintained disposable-email-domains repository + | from GitHub's raw content URL. | */ - 'source' => 'https://rawgit.com/andreis/disposable-email-domains/master/domains.json', + 'source' => 'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/master/disposable_email_blocklist.conf', /* |-------------------------------------------------------------------------- diff --git a/tests/Unit/Rules/ValidEmailDomainTest.php b/tests/Unit/Rules/ValidEmailDomainTest.php new file mode 100644 index 000000000..9fd26d117 --- /dev/null +++ b/tests/Unit/Rules/ValidEmailDomainTest.php @@ -0,0 +1,130 @@ +validate('email', $email, function($message) use (&$failed) { + $failed = true; + }); + + $this->assertTrue($failed, "Expected {$email} to be rejected"); + } + } + + /** + * Test that legitimate email domains are accepted + */ + public function test_accepts_legitimate_email_domains(): void + { + $rule = new ValidEmailDomain(); + + $legitimateEmails = [ + 'test@gmail.com', + 'test@yahoo.com', + 'test@outlook.com', + 'test@protonmail.com', + ]; + + foreach ($legitimateEmails as $email) { + $failed = false; + $rule->validate('email', $email, function($message) use (&$failed) { + $failed = true; + }); + + $this->assertFalse($failed, "Expected {$email} to be accepted"); + } + } + + /** + * Test that emails with suspicious patterns are rejected + */ + public function test_rejects_emails_with_suspicious_patterns(): void + { + $rule = new ValidEmailDomain(); + + $suspiciousEmails = [ + 'test@tempdomainexample.com', + 'test@throwawaystuff.net', + 'test@disposableemail.org', + ]; + + foreach ($suspiciousEmails as $email) { + $failed = false; + $rule->validate('email', $email, function($message) use (&$failed) { + $failed = true; + }); + + $this->assertTrue($failed, "Expected {$email} to be rejected due to pattern match"); + } + } + + /** + * Test that emails with invalid domains are rejected + */ + public function test_rejects_emails_with_invalid_domains(): void + { + $rule = new ValidEmailDomain(); + + $invalidEmails = [ + 'test@nonexistentdomain12345xyz.com', + 'test@fakeinvaliddomain999.net', + ]; + + foreach ($invalidEmails as $email) { + $failed = false; + $rule->validate('email', $email, function($message) use (&$failed) { + $failed = true; + }); + + $this->assertTrue($failed, "Expected {$email} to be rejected due to DNS validation"); + } + } + + /** + * Test that malformed emails are rejected + * Note: Basic format validation (like @domain.com) should be caught by Laravel's 'email' rule + * before our custom rule runs. We test multiple @ signs which might slip through. + */ + public function test_rejects_malformed_emails(): void + { + $rule = new ValidEmailDomain(); + + $malformedEmails = [ + 'notanemail', + 'noat.com', + 'multiple@@at.com', + ]; + + foreach ($malformedEmails as $email) { + $failed = false; + $rule->validate('email', $email, function($message) use (&$failed) { + $failed = true; + }); + + $this->assertTrue($failed, "Expected {$email} to be rejected as malformed"); + } + } +} +