diff --git a/src/Http/Controllers/ForgotPasswordController.php b/src/Http/Controllers/ForgotPasswordController.php index 405ed1b18b5..5563a51de96 100644 --- a/src/Http/Controllers/ForgotPasswordController.php +++ b/src/Http/Controllers/ForgotPasswordController.php @@ -10,7 +10,6 @@ use Statamic\Facades\Site; use Statamic\Facades\URL; use Statamic\Http\Middleware\RedirectIfAuthenticated; -use Statamic\Support\Str; class ForgotPasswordController extends Controller { @@ -35,10 +34,18 @@ public function sendResetLinkEmail(Request $request) if ($url = $request->_reset_url) { $url = URL::makeAbsolute($url); - $isExternal = Site::all() - ->map(fn ($site) => $site->absoluteUrl()) - ->filter(fn ($siteUrl) => Str::startsWith($url, $siteUrl)) - ->isEmpty(); + $urlDomain = parse_url($url, PHP_URL_HOST); + $currentRequestDomain = parse_url(url()->to('/'), PHP_URL_HOST); + + $isExternal = $urlDomain + ? Site::all() + ->map(fn ($site) => parse_url($site->absoluteUrl(), PHP_URL_HOST)) + ->push($currentRequestDomain) + ->filter(fn ($siteDomain) => ! is_null($siteDomain)) + ->unique() + ->filter(fn ($siteDomain) => $siteDomain === $urlDomain) + ->isEmpty() + : false; throw_if($isExternal, ValidationException::withMessages([ '_reset_url' => trans('validation.url', ['attribute' => '_reset_url']), diff --git a/tests/Auth/ForgotPasswordTest.php b/tests/Auth/ForgotPasswordTest.php new file mode 100644 index 00000000000..6c53af2e6d3 --- /dev/null +++ b/tests/Auth/ForgotPasswordTest.php @@ -0,0 +1,148 @@ +set('app.url', 'http://absolute-url-resolved-from-request.com'); + } + + #[Test] + #[DataProvider('externalProvider')] + public function it_validates_reset_url_when_sending_reset_link_email($url, $isExternal) + { + $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/'], + 'c' => ['name' => 'C', 'locale' => 'fr_FR', 'url' => '/fr/'], + ]); + + $this->simulateSuccessfulPasswordResetEmail(); + + User::make() + ->email('san@holo.com') + ->password('chewy') + ->save(); + + $response = $this->post('/!/auth/password/email', [ + 'email' => 'san@holo.com', + '_reset_url' => $url, + ]); + + if ($isExternal) { + $response->assertSessionHasErrors(['_reset_url']); + + return; + } + + $response->assertSessionHasNoErrors(); + } + + public static function externalProvider() + { + 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://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', 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', 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], + + // 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], + ]; + } + + #[Test] + public function it_allows_reset_url_for_current_request_domain_when_not_in_sites_config() + { + $this->setSites([ + 'a' => ['name' => 'A', 'locale' => 'en_US', 'url' => 'http://this-site.com/'], + ]); + + $this->simulateSuccessfulPasswordResetEmail(); + + User::make() + ->email('san@holo.com') + ->password('chewy') + ->save(); + + $this + ->post('/!/auth/password/email', [ + 'email' => 'san@holo.com', + '_reset_url' => 'http://absolute-url-resolved-from-request.com/some-slug', + ]) + ->assertSessionHasNoErrors(); + } + + protected function simulateSuccessfulPasswordResetEmail() + { + $success = new class + { + public function sendResetLink() + { + return Password::RESET_LINK_SENT; + } + }; + + Password::shouldReceive('broker')->andReturn($success); + } +}