From 01957a6b61f5aede8322e88c5071087537dbd71b Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Fri, 2 Oct 2026 18:28:39 +0200 Subject: [PATCH 1/2] Validate intended login redirects --- app/Http/Controllers/Auth/LoginController.php | 34 ++++++++++++++++++- tests/Feature/Auth/LoginTest.php | 29 ++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 5319eeee..e7d5117d 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -146,7 +146,13 @@ public function handleProviderCallback(Request $request, $provider = 'sso') public function showLoginForm(Request $request) { if ($request->has('intended')) { - Session::put('url.intended', $request->input('intended')); + $intended = $request->input('intended'); + + if (is_string($intended) && $this->isSafeIntendedUrl($request, $intended)) { + Session::put('url.intended', $intended); + } else { + Session::forget('url.intended'); + } } if (SsoProvider::isForced()) { @@ -157,4 +163,30 @@ public function showLoginForm(Request $request) 'hasSsoLoginAvailable' => SsoProvider::isEnabled(), ]); } + + private function isSafeIntendedUrl(Request $request, string $intended): bool + { + if (str_starts_with($intended, '/')) { + return ! str_starts_with($intended, '//') + && ! str_starts_with($intended, '/\\'); + } + + $parts = parse_url($intended); + + if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) { + return false; + } + + $scheme = strtolower($parts['scheme']); + + if (! in_array($scheme, ['http', 'https'], true)) { + return false; + } + + $port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80); + + return $scheme === $request->getScheme() + && strcasecmp($parts['host'], $request->getHost()) === 0 + && $port === $request->getPort(); + } } diff --git a/tests/Feature/Auth/LoginTest.php b/tests/Feature/Auth/LoginTest.php index 2c05b2b1..a0b01968 100644 --- a/tests/Feature/Auth/LoginTest.php +++ b/tests/Feature/Auth/LoginTest.php @@ -31,6 +31,35 @@ $response->assertStatus(302); }); +test('login rejects an external intended URL', function () { + $user = createUser(); + + $this->get(route('login', ['intended' => 'https://attacker.example/landing'])) + ->assertSessionMissing('url.intended'); + + $this->post(route('login'), [ + 'email' => $user->email, + 'password' => 'password', + ])->assertRedirect(route('home')); +}); + +test('login accepts a local intended path', function () { + $user = createUser(); + + $this->get(route('login', ['intended' => '/items/example'])) + ->assertSessionHas('url.intended', '/items/example'); + + $this->post(route('login'), [ + 'email' => $user->email, + 'password' => 'password', + ])->assertRedirect('/items/example'); +}); + +test('login rejects a protocol-relative intended URL', function () { + $this->get(route('login', ['intended' => '//attacker.example/landing'])) + ->assertSessionMissing('url.intended'); +}); + test('users cannot authenticate with an incorrect password', function () { $user = createUser(); From 10feaa9472f6c8e83d6c7a2e3a4bab9857684b4b Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Fri, 2 Oct 2026 18:36:55 +0200 Subject: [PATCH 2/2] Test same-origin intended redirects --- tests/Feature/Auth/LoginTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/Feature/Auth/LoginTest.php b/tests/Feature/Auth/LoginTest.php index a0b01968..9534acf8 100644 --- a/tests/Feature/Auth/LoginTest.php +++ b/tests/Feature/Auth/LoginTest.php @@ -55,6 +55,19 @@ ])->assertRedirect('/items/example'); }); +test('login accepts a same-origin intended URL', function () { + $user = createUser(); + $intended = route('items.show', 'example'); + + $this->get(route('login', ['intended' => $intended])) + ->assertSessionHas('url.intended', $intended); + + $this->post(route('login'), [ + 'email' => $user->email, + 'password' => 'password', + ])->assertRedirect($intended); +}); + test('login rejects a protocol-relative intended URL', function () { $this->get(route('login', ['intended' => '//attacker.example/landing'])) ->assertSessionMissing('url.intended');