From a5506fab39f06c10ff042f91d44532b25994d1f8 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 18 Mar 2026 15:09:24 -0400 Subject: [PATCH 1/6] adjust provider trait so that we can get internal/external urls on their own. --- .../Facades/Concerns/ProvidesExternalUrls.php | 152 ++++++++++-------- 1 file changed, 85 insertions(+), 67 deletions(-) diff --git a/tests/Facades/Concerns/ProvidesExternalUrls.php b/tests/Facades/Concerns/ProvidesExternalUrls.php index 731b22d637..633a7c4c49 100644 --- a/tests/Facades/Concerns/ProvidesExternalUrls.php +++ b/tests/Facades/Concerns/ProvidesExternalUrls.php @@ -4,84 +4,102 @@ trait ProvidesExternalUrls { - public static function externalUrlProvider() + private static function internalUrls() { return [ - ['http://this-site.com', false], - ['http://this-site.com?foo', false], - ['http://this-site.com#anchor', false], - ['http://this-site.com/', false], - ['http://this-site.com/?foo', false], - ['http://this-site.com/#anchor', false], + 'http://this-site.com', + 'http://this-site.com?foo', + 'http://this-site.com#anchor', + 'http://this-site.com/', + 'http://this-site.com/?foo', + 'http://this-site.com/#anchor', - ['http://that-site.com', true], - ['http://that-site.com/', true], - ['http://that-site.com/?foo', true], - ['http://that-site.com/#anchor', true], - ['http://that-site.com/some-slug', true], - ['http://that-site.com/some-slug?foo', true], - ['http://that-site.com/some-slug#anchor', true], + 'http://subdomain.this-site.com', + 'http://subdomain.this-site.com/', + 'http://subdomain.this-site.com/?foo', + 'http://subdomain.this-site.com/#anchor', + 'http://subdomain.this-site.com/some-slug', + 'http://subdomain.this-site.com/some-slug?foo', + 'http://subdomain.this-site.com/some-slug#anchor', - ['http://subdomain.this-site.com', false], - ['http://subdomain.this-site.com/', false], - ['http://subdomain.this-site.com/?foo', false], - ['http://subdomain.this-site.com/#anchor', false], - ['http://subdomain.this-site.com/some-slug', false], - ['http://subdomain.this-site.com/some-slug?foo', false], - ['http://subdomain.this-site.com/some-slug#anchor', false], + 'http://absolute-url-resolved-from-request.com', + 'http://absolute-url-resolved-from-request.com/', + 'http://absolute-url-resolved-from-request.com/?foo', + 'http://absolute-url-resolved-from-request.com/?anchor', + 'http://absolute-url-resolved-from-request.com/some-slug', + 'http://absolute-url-resolved-from-request.com/some-slug?foo', + 'http://absolute-url-resolved-from-request.com/some-slug#anchor', - ['http://absolute-url-resolved-from-request.com', false], - ['http://absolute-url-resolved-from-request.com/', false], - ['http://absolute-url-resolved-from-request.com/?foo', false], - ['http://absolute-url-resolved-from-request.com/?anchor', false], - ['http://absolute-url-resolved-from-request.com/some-slug', false], - ['http://absolute-url-resolved-from-request.com/some-slug?foo', false], - ['http://absolute-url-resolved-from-request.com/some-slug#anchor', false], - ['/', false], - ['/?foo', false], - ['/#anchor', false], - ['/some-slug', false], - ['?foo', false], - ['#anchor', false], - ['', false], - [null, false], + '/', + '/?foo', + '/#anchor', + '/some-slug', + '?foo', + '#anchor', + '', + null, + ]; + } + + private static function externalUrls() + { + return [ + 'http://that-site.com', + 'http://that-site.com/', + 'http://that-site.com/?foo', + 'http://that-site.com/#anchor', + 'http://that-site.com/some-slug', + 'http://that-site.com/some-slug?foo', + 'http://that-site.com/some-slug#anchor', // Protocol-relative URLs are external - ['//evil.com', true], - ['//evil.com/', true], - ['//evil.com/path', true], - ['//this-site.com', true], + '//evil.com', + '//evil.com/', + '//evil.com/path', + '//this-site.com', // External domain that starts with a valid domain. - ['http://this-site.com.au', true], - ['http://this-site.com.au/', true], - ['http://this-site.com.au/?foo', true], - ['http://this-site.com.au/#anchor', true], - ['http://this-site.com.au/some-slug', true], - ['http://this-site.com.au/some-slug?foo', true], - ['http://this-site.com.au/some-slug#anchor', true], - ['http://subdomain.this-site.com.au', true], - ['http://subdomain.this-site.com.au/', true], - ['http://subdomain.this-site.com.au/?foo', true], - ['http://subdomain.this-site.com.au/#anchor', true], - ['http://subdomain.this-site.com.au/some-slug', true], - ['http://subdomain.this-site.com.au/some-slug?foo', true], - ['http://subdomain.this-site.com.au/some-slug#anchor', true], + 'http://this-site.com.au', + 'http://this-site.com.au/', + 'http://this-site.com.au/?foo', + 'http://this-site.com.au/#anchor', + 'http://this-site.com.au/some-slug', + 'http://this-site.com.au/some-slug?foo', + 'http://this-site.com.au/some-slug#anchor', + 'http://subdomain.this-site.com.au', + 'http://subdomain.this-site.com.au/', + 'http://subdomain.this-site.com.au/?foo', + 'http://subdomain.this-site.com.au/#anchor', + 'http://subdomain.this-site.com.au/some-slug', + 'http://subdomain.this-site.com.au/some-slug?foo', + 'http://subdomain.this-site.com.au/some-slug#anchor', // Credential injection - ['http://this-site.com@evil.com', true], - ['http://this-site.com@evil.com/', true], - ['http://this-site.com@evil.com/path', true], - ['http://this-site.com@evil.com/path?query', true], - ['http://this-site.com:password@evil.com', true], - ['http://user:pass@evil.com', true], - ['http://absolute-url-resolved-from-request.com@evil.com', true], - ['http://absolute-url-resolved-from-request.com@evil.com/path', true], - ['http://subdomain.this-site.com@evil.com', true], - ['http://subdomain.this-site.com@evil.com/path', true], - ['http://this-site.com:8000@evil.com', true], - ['http://this-site.com:8000@evil.com/path', true], - ['http://this-site.com:8000@webhook.site/token', true], + 'http://this-site.com@evil.com', + 'http://this-site.com@evil.com/', + 'http://this-site.com@evil.com/path', + 'http://this-site.com@evil.com/path?query', + 'http://this-site.com:password@evil.com', + 'http://user:pass@evil.com', + 'http://absolute-url-resolved-from-request.com@evil.com', + 'http://absolute-url-resolved-from-request.com@evil.com/path', + 'http://subdomain.this-site.com@evil.com', + 'http://subdomain.this-site.com@evil.com/path', + 'http://this-site.com:8000@evil.com', + 'http://this-site.com:8000@evil.com/path', + 'http://this-site.com:8000@webhook.site/token', + ]; + } + + public static function externalUrlProvider() + { + $keyFn = function ($key) { + return is_null($key) ? 'null' : $key; + }; + + return [ + ...collect(static::internalUrls())->mapWithKeys(fn ($url) => [$keyFn($url) => [$url, false]])->all(), + ...collect(static::externalUrls())->mapWithKeys(fn ($url) => [$keyFn($url) => [$url, true]])->all(), ]; } } From 6fd4a50399c653654f1e30aa3cd9741e7c83a8e7 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 18 Mar 2026 15:10:35 -0400 Subject: [PATCH 2/6] encryption with relative fallback rather than validation --- src/Auth/UserTags.php | 15 ++- .../Controllers/ForgotPasswordController.php | 25 +++-- tests/Auth/ForgotPasswordTest.php | 94 ++++++++++++++++--- tests/Tags/User/ForgotPasswordFormTest.php | 32 ++++++- 4 files changed, 144 insertions(+), 22 deletions(-) diff --git a/src/Auth/UserTags.php b/src/Auth/UserTags.php index 61f34cd36b..82eb76246f 100644 --- a/src/Auth/UserTags.php +++ b/src/Auth/UserTags.php @@ -352,7 +352,7 @@ public function forgotPasswordForm() $params['error_redirect'] = $this->parseRedirect($errorRedirect); } - if ($resetUrl = $this->params->get('reset_url')) { + if ($resetUrl = $this->getPasswordResetUrl($this->params->get('reset_url'))) { $params['reset_url'] = $resetUrl; } @@ -374,6 +374,19 @@ public function forgotPasswordForm() return $html; } + private function getPasswordResetUrl(?string $url = null): ?string + { + if (! $url) { + return null; + } + + if (! URL::isAbsolute($url) && ! str_starts_with($url, '/')) { + $url = '/'.$url; + } + + return encrypt($url); + } + /** * Output a reset password form. * diff --git a/src/Http/Controllers/ForgotPasswordController.php b/src/Http/Controllers/ForgotPasswordController.php index d9e423cbfc..11ed95953c 100644 --- a/src/Http/Controllers/ForgotPasswordController.php +++ b/src/Http/Controllers/ForgotPasswordController.php @@ -2,12 +2,12 @@ namespace Statamic\Http\Controllers; +use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Password; use Inertia\Inertia; use Statamic\Auth\Passwords\PasswordReset; use Statamic\Auth\SendsPasswordResetEmails; -use Statamic\Exceptions\ValidationException; use Statamic\Facades\URL; use Statamic\Http\Middleware\RedirectIfAuthenticated; @@ -32,17 +32,30 @@ public function showLinkRequestForm() public function sendResetLinkEmail(Request $request) { - if ($url = $request->_reset_url) { - throw_if(URL::isExternalToApplication($url), ValidationException::withMessages([ - '_reset_url' => trans('validation.url', ['attribute' => '_reset_url']), - ])); - + if ($url = $this->getResetFormUrl($request)) { PasswordReset::resetFormUrl(URL::makeAbsolute($url)); } return $this->traitSendResetLinkEmail($request); } + private function getResetFormUrl(Request $request): ?string + { + if (! $url = $request->_reset_url) { + return null; + } + + try { + $url = decrypt($url); + } catch (DecryptException $e) { + if (str_starts_with($url, '/') && ! str_starts_with($url, '//')) { + return $url; + } + } + + return URL::isExternalToApplication($url) ? null : $url; + } + public function broker() { $broker = config('statamic.users.passwords.'.PasswordReset::BROKER_RESETS); diff --git a/tests/Auth/ForgotPasswordTest.php b/tests/Auth/ForgotPasswordTest.php index 0a63d5580f..3aad2ddccd 100644 --- a/tests/Auth/ForgotPasswordTest.php +++ b/tests/Auth/ForgotPasswordTest.php @@ -5,6 +5,7 @@ use Illuminate\Support\Facades\Password; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Statamic\Auth\Passwords\PasswordReset; use Statamic\Facades\User; use Tests\Facades\Concerns\ProvidesExternalUrls; use Tests\PreventSavingStacheItemsToDisk; @@ -26,6 +27,8 @@ public function setUp(): void { parent::setUp(); + PasswordReset::resetFormUrl(null); + $this->setSites([ 'a' => ['name' => 'A', 'locale' => 'en_US', 'url' => 'http://this-site.com/'], 'b' => ['name' => 'B', 'locale' => 'en_US', 'url' => 'http://subdomain.this-site.com/'], @@ -34,28 +37,93 @@ public function setUp(): void } #[Test] - #[DataProvider('externalUrlProvider')] - public function it_validates_reset_url_when_sending_reset_link_email($url, $isExternal) + public function it_accepts_encrypted_reset_url_when_sending_reset_link_email() { $this->simulateSuccessfulPasswordResetEmail(); + $this->createUser(); - User::make() - ->email('san@holo.com') - ->password('chewy') - ->save(); + $this->post('/!/auth/password/email', [ + 'email' => 'san@holo.com', + '_reset_url' => encrypt('http://this-site.com/some-path'), + ])->assertSessionHasNoErrors(); + $this->assertEquals('http://this-site.com/some-path?token=test-token', PasswordReset::url('test-token', 'resets')); + } + + #[Test] + public function it_accepts_unencrypted_relative_reset_url_when_sending_reset_link_email() + { + $this->simulateSuccessfulPasswordResetEmail(); + $this->createUser(); - $response = $this->post('/!/auth/password/email', [ + $this->post('/!/auth/password/email', [ + 'email' => 'san@holo.com', + '_reset_url' => '/some-path', + ])->assertSessionHasNoErrors(); + $this->assertEquals('http://absolute-url-resolved-from-request.com/some-path?token=test-token', PasswordReset::url('test-token', 'resets')); + } + + #[Test] + #[DataProvider('externalResetUrlProvider')] + public function it_rejects_unencrypted_external_reset_url_when_sending_reset_link_email($url) + { + $this->simulateSuccessfulPasswordResetEmail(); + $this->createUser(); + + $this->post('/!/auth/password/email', [ 'email' => 'san@holo.com', '_reset_url' => $url, - ]); + ])->assertSessionHasNoErrors(); // Allow the notification to be sent, but without the bad url. + $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets')); + } + + #[Test] + public function it_rejects_unencrypted_restring_reset_url_when_sending_reset_link_email() + { + // Unencrypted string that doesn't look like a URL is probably a tampered encrypted string. + // It might be a relative url without a leading slash, but we won't treat it as that. + + $this->simulateSuccessfulPasswordResetEmail(); + $this->createUser(); + + $this->post('/!/auth/password/email', [ + 'email' => 'san@holo.com', + '_reset_url' => 'not-an-encrypted-string', + ])->assertSessionHasNoErrors(); // Allow the notification to be sent, but without the bad url. + $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets')); + } + + #[Test] + #[DataProvider('externalResetUrlProvider')] + public function it_rejects_encrypted_external_reset_url_when_sending_reset_link_email($url) + { + // It's weird to point to an external URL, even if you encrypt it yourself. + // This is an additional safeguard. + + $this->simulateSuccessfulPasswordResetEmail(); + $this->createUser(); - if ($isExternal) { - $response->assertSessionHasErrors(['_reset_url']); + $this->post('/!/auth/password/email', [ + 'email' => 'san@holo.com', + '_reset_url' => encrypt($url), + ])->assertSessionHasNoErrors(); // Allow the notification to be sent, but without the bad url. + $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets')); + } + + public static function externalResetUrlProvider() + { + $keyFn = function ($key) { + return is_null($key) ? 'null' : $key; + }; - return; - } + return collect(static::externalUrls())->mapWithKeys(fn ($url) => [$keyFn($url) => [$url]])->all(); + } - $response->assertSessionHasNoErrors(); + private function createUser(): void + { + User::make() + ->email('san@holo.com') + ->password('chewy') + ->save(); } protected function simulateSuccessfulPasswordResetEmail() diff --git a/tests/Tags/User/ForgotPasswordFormTest.php b/tests/Tags/User/ForgotPasswordFormTest.php index 04cc9eaf92..f2c7e1f282 100644 --- a/tests/Tags/User/ForgotPasswordFormTest.php +++ b/tests/Tags/User/ForgotPasswordFormTest.php @@ -3,6 +3,7 @@ namespace Tests\Tags\User; use Illuminate\Support\Facades\Password; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Parse; use Statamic\Facades\User; @@ -32,12 +33,39 @@ public function it_renders_form() #[Test] public function it_renders_form_with_params() { - $output = $this->tag('{{ user:forgot_password_form redirect="/submitted" error_redirect="/errors" reset_url="/resetting" class="form" id="form" }}{{ /user:forgot_password_form }}'); + $output = $this->tag('{{ user:forgot_password_form redirect="/submitted" error_redirect="/errors" class="form" id="form" }}{{ /user:forgot_password_form }}'); $this->assertStringStartsWith('
', $output); $this->assertStringContainsString('', $output); $this->assertStringContainsString('', $output); - $this->assertStringContainsString('', $output); + } + + #[Test] + #[DataProvider('resetUrlProvider')] + public function it_renders_reset_url($resetUrl, $expectedUrl) + { + $output = $this->tag('{{ user:forgot_password_form reset_url="'.$resetUrl.'" }}{{ /user:forgot_password_form }}'); + + $this->assertMatchesRegularExpression('//', $output); + preg_match('//', $output, $matches); + $this->assertEquals($expectedUrl, decrypt($matches[1])); + } + + public static function resetUrlProvider() + { + return [ + '/custom' => ['/custom', '/custom'], + 'custom' => ['custom', '/custom'], + 'absolute' => ['https://example.com/custom', 'https://example.com/custom'], + ]; + } + + #[Test] + public function it_renders_null_reset_url() + { + $output = $this->tag('{{ user:forgot_password_form :reset_url="null" }}{{ /user:forgot_password_form }}'); + + $this->assertStringNotContainsString('_reset_url', $output); } #[Test] From e1c6e7fd87b72f8d26d3427a9fd032b679ab3b67 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 18 Mar 2026 15:42:19 -0400 Subject: [PATCH 3/6] test typo --- tests/Auth/ForgotPasswordTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Auth/ForgotPasswordTest.php b/tests/Auth/ForgotPasswordTest.php index 3aad2ddccd..ca58c269c6 100644 --- a/tests/Auth/ForgotPasswordTest.php +++ b/tests/Auth/ForgotPasswordTest.php @@ -77,7 +77,7 @@ public function it_rejects_unencrypted_external_reset_url_when_sending_reset_lin } #[Test] - public function it_rejects_unencrypted_restring_reset_url_when_sending_reset_link_email() + public function it_rejects_unencrypted_string_reset_url_when_sending_reset_link_email() { // Unencrypted string that doesn't look like a URL is probably a tampered encrypted string. // It might be a relative url without a leading slash, but we won't treat it as that. From 25c7f536c8b90a8470a8970fed4f601d78382624 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 18 Mar 2026 15:46:46 -0400 Subject: [PATCH 4/6] reject unencrypted internal urls too --- src/Http/Controllers/ForgotPasswordController.php | 4 +--- tests/Auth/ForgotPasswordTest.php | 13 +++++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Http/Controllers/ForgotPasswordController.php b/src/Http/Controllers/ForgotPasswordController.php index 11ed95953c..3165432042 100644 --- a/src/Http/Controllers/ForgotPasswordController.php +++ b/src/Http/Controllers/ForgotPasswordController.php @@ -48,9 +48,7 @@ private function getResetFormUrl(Request $request): ?string try { $url = decrypt($url); } catch (DecryptException $e) { - if (str_starts_with($url, '/') && ! str_starts_with($url, '//')) { - return $url; - } + return str_starts_with($url, '/') && ! str_starts_with($url, '//') ? $url : null; } return URL::isExternalToApplication($url) ? null : $url; diff --git a/tests/Auth/ForgotPasswordTest.php b/tests/Auth/ForgotPasswordTest.php index ca58c269c6..618b662a80 100644 --- a/tests/Auth/ForgotPasswordTest.php +++ b/tests/Auth/ForgotPasswordTest.php @@ -76,6 +76,19 @@ public function it_rejects_unencrypted_external_reset_url_when_sending_reset_lin $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets')); } + #[Test] + public function it_rejects_unencrypted_absolute_internal_reset_url_when_sending_reset_link_email() + { + $this->simulateSuccessfulPasswordResetEmail(); + $this->createUser(); + + $this->post('/!/auth/password/email', [ + 'email' => 'san@holo.com', + '_reset_url' => 'http://this-site.com/some-path', + ])->assertSessionHasNoErrors(); + $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets')); + } + #[Test] public function it_rejects_unencrypted_string_reset_url_when_sending_reset_link_email() { From 10afb0d439d904fcd5acea232c02f9faea74731c Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 18 Mar 2026 16:09:49 -0400 Subject: [PATCH 5/6] reject when encountering control characters --- src/Http/Controllers/ForgotPasswordController.php | 10 +++++++++- tests/Auth/ForgotPasswordTest.php | 13 +++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/Http/Controllers/ForgotPasswordController.php b/src/Http/Controllers/ForgotPasswordController.php index 3165432042..bd628fc9b1 100644 --- a/src/Http/Controllers/ForgotPasswordController.php +++ b/src/Http/Controllers/ForgotPasswordController.php @@ -48,7 +48,15 @@ private function getResetFormUrl(Request $request): ?string try { $url = decrypt($url); } catch (DecryptException $e) { - return str_starts_with($url, '/') && ! str_starts_with($url, '//') ? $url : null; + if (! str_starts_with($url, '/') || str_starts_with($url, '//')) { + return null; + } + + if (preg_match('/[\x00-\x1F\x7F]/', $url)) { + return null; + } + + return $url; } return URL::isExternalToApplication($url) ? null : $url; diff --git a/tests/Auth/ForgotPasswordTest.php b/tests/Auth/ForgotPasswordTest.php index 618b662a80..09a8722522 100644 --- a/tests/Auth/ForgotPasswordTest.php +++ b/tests/Auth/ForgotPasswordTest.php @@ -89,6 +89,19 @@ public function it_rejects_unencrypted_absolute_internal_reset_url_when_sending_ $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets')); } + #[Test] + public function it_rejects_unencrypted_relative_reset_url_with_control_characters_when_sending_reset_link_email() + { + $this->simulateSuccessfulPasswordResetEmail(); + $this->createUser(); + + $this->post('/!/auth/password/email', [ + 'email' => 'san@holo.com', + '_reset_url' => "/some-path\r\nLocation: https://evil.com", + ])->assertSessionHasNoErrors(); + $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets')); + } + #[Test] public function it_rejects_unencrypted_string_reset_url_when_sending_reset_link_email() { From 55104280c7929495ff9daeaaa7fb508418663b0d Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 18 Mar 2026 16:11:49 -0400 Subject: [PATCH 6/6] protect against large strings --- src/Http/Controllers/ForgotPasswordController.php | 4 ++++ tests/Auth/ForgotPasswordTest.php | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/Http/Controllers/ForgotPasswordController.php b/src/Http/Controllers/ForgotPasswordController.php index bd628fc9b1..752ca56e58 100644 --- a/src/Http/Controllers/ForgotPasswordController.php +++ b/src/Http/Controllers/ForgotPasswordController.php @@ -45,6 +45,10 @@ private function getResetFormUrl(Request $request): ?string return null; } + if (strlen($url) > 2048) { + return null; + } + try { $url = decrypt($url); } catch (DecryptException $e) { diff --git a/tests/Auth/ForgotPasswordTest.php b/tests/Auth/ForgotPasswordTest.php index 09a8722522..339351f728 100644 --- a/tests/Auth/ForgotPasswordTest.php +++ b/tests/Auth/ForgotPasswordTest.php @@ -102,6 +102,19 @@ public function it_rejects_unencrypted_relative_reset_url_with_control_character $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets')); } + #[Test] + public function it_rejects_reset_url_longer_than_2048_characters_when_sending_reset_link_email() + { + $this->simulateSuccessfulPasswordResetEmail(); + $this->createUser(); + + $this->post('/!/auth/password/email', [ + 'email' => 'san@holo.com', + '_reset_url' => '/'.str_repeat('a', 2048), + ])->assertSessionHasNoErrors(); + $this->assertEquals('http://absolute-url-resolved-from-request.com/!/auth/password/reset/test-token?', PasswordReset::url('test-token', 'resets')); + } + #[Test] public function it_rejects_unencrypted_string_reset_url_when_sending_reset_link_email() {