diff --git a/app/Models/User.php b/app/Models/User.php index 3a2d26edb6..c5e9f592c3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -304,7 +304,12 @@ class User extends Authenticatable implements SendsEmail public function sendVerificationEmail() { $mail = new MailMessage; - $url = URL::temporarySignedRoute( + // Sign the link for the configured instance URL instead of the request Host header. + $urlGenerator = clone URL::getFacadeRoot(); + $instanceUrl = rtrim(base_url(), '/'); + $urlGenerator->forceRootUrl($instanceUrl); + $urlGenerator->forceScheme(parse_url($instanceUrl, PHP_URL_SCHEME) ?: 'http'); + $url = $urlGenerator->temporarySignedRoute( 'verify.verify', Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), [ diff --git a/tests/Feature/VerificationEmailHostTest.php b/tests/Feature/VerificationEmailHostTest.php new file mode 100644 index 0000000000..28c981a580 --- /dev/null +++ b/tests/Feature/VerificationEmailHostTest.php @@ -0,0 +1,52 @@ +withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]); + Once::flush(); + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], [ + 'fqdn' => 'https://coolify.example.com', + 'smtp_enabled' => true, + 'smtp_host' => 'smtp.example.com', + 'smtp_port' => 587, + 'smtp_from_address' => 'coolify@example.com', + 'smtp_from_name' => 'Coolify', + ])); + Once::flush(); +}); + +it('builds the verification link from the instance URL, not the request host', function () { + $user = User::factory()->create(['email' => 'host-check@example.com', 'email_verified_at' => null]); + + $html = null; + Event::listen(MessageSending::class, function (MessageSending $event) use (&$html) { + $html = $event->message->getHtmlBody(); + + return false; + }); + + $this->app->instance('request', Request::create('http://attacker.test/verify')); + + $user->sendVerificationEmail(); + + expect($html)->not->toBeNull(); + preg_match('~href="([^"]*/email/verify/[^"]+)"~', $html, $matches); + $url = html_entity_decode($matches[1] ?? ''); + + expect($url)->toStartWith('https://coolify.example.com/email/verify/') + ->and($html)->not->toContain('attacker.test'); + + $this->actingAs($user)->get($url)->assertRedirect(); + expect($user->refresh()->email_verified_at)->not->toBeNull(); +});