diff --git a/CHANGELOG.md b/CHANGELOG.md index 2345b7256..24e671828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog. +## [Unreleased] + +### Added +- Added `Response::back()` and the `back()` helper, which redirect to the referrer and fall back to `base_url()` when the request has none, or when the referrer is not on the application origin (same scheme, host and port), which is compared with `base_url()`: behind a proxy that terminates TLS, or where the `Host` header is not pinned, set `app.base_url`. They take the old input to keep for the next request, read with `old()` as after `redirectWith()`, and an optional status code (`back($data, $code)`) (#540) + ## [3.1.0] - 2026-10-24 ### Added diff --git a/src/Http/Helpers/http.php b/src/Http/Helpers/http.php index 0a1aa22ef..417bd8be1 100644 --- a/src/Http/Helpers/http.php +++ b/src/Http/Helpers/http.php @@ -78,6 +78,16 @@ function redirectWith(string $url, array $data, int $code = StatusCode::FOUND): return response()->redirect($url, $code); } +/** + * Redirect back to the referrer, falls back to the base url + * @param array $data + * @throws ConfigException|DiException|BaseException|ReflectionException + */ +function back(array $data = [], int $code = StatusCode::FOUND): Response +{ + return response()->back($data, $code); +} + /** * Gets old input values after redirect * @return mixed|null diff --git a/src/Http/Traits/Response/Header.php b/src/Http/Traits/Response/Header.php index e0ae31054..5e835295a 100644 --- a/src/Http/Traits/Response/Header.php +++ b/src/Http/Traits/Response/Header.php @@ -10,7 +10,13 @@ namespace Quantum\Http\Traits\Response; +use Quantum\Config\Exceptions\ConfigException; +use Quantum\App\Exceptions\BaseException; +use Quantum\Di\Exceptions\DiException; +use Quantum\App\Enums\ReservedKeys; use Quantum\Http\Enums\ContentType; +use Quantum\Http\Request; +use ReflectionException; /** * Trait Header @@ -94,4 +100,57 @@ public function redirect(string $url, int $code = 302): self $this->setHeader('Location', $url); return $this; } + + /** + * Redirects back to the referrer when it is on the application origin, + * falls back to the base url otherwise + * @param array $data Old input to keep for the next request + * @return self + * @throws ConfigException|DiException|BaseException|ReflectionException + */ + public function back(array $data = [], int $code = 302): self + { + if ($data !== []) { + session()->set(ReservedKeys::PREV_REQUEST, $data); + } + + $referrer = get_referrer(); + + return $this->redirect($referrer && $this->isSameOrigin($referrer) ? $referrer : base_url(), $code); + } + + /** + * Checks that the url has the scheme, host and port of the base url + * @throws DiException|ReflectionException + */ + private function isSameOrigin(string $url): bool + { + $target = parse_url($url); + $base = parse_url(base_url()); + + if (str_contains($url, '\\') || !is_array($target) || !is_array($base) || isset($target['user'])) { + return false; + } + + $origin = $this->getOrigin($target); + + return $origin !== null && $origin === $this->getOrigin($base); + } + + /** + * Gets the scheme, host and port of the parsed url + * @param array $parts + */ + private function getOrigin(array $parts): ?string + { + if (!isset($parts['scheme'], $parts['host'])) { + return null; + } + + $scheme = strtolower($parts['scheme']); + + $port = $parts['port'] ?? ($scheme === 'https' ? Request::DEFAULT_HTTPS_PORT : Request::DEFAULT_HTTP_PORT); + + return $scheme . '://' . strtolower($parts['host']) . ':' . $port; + } } diff --git a/tests/Unit/Http/Helpers/HttpHelperTest.php b/tests/Unit/Http/Helpers/HttpHelperTest.php index ed686742d..b1cb15ba7 100644 --- a/tests/Unit/Http/Helpers/HttpHelperTest.php +++ b/tests/Unit/Http/Helpers/HttpHelperTest.php @@ -156,4 +156,45 @@ public function testRedirectWithOldData(): void $this->session->flush(); } + + public function testRedirectingBack(): void + { + $this->request->create('GET', 'http://example.com'); + + $referrer = base_url() . '/page'; + + server()->set('HTTP_REFERER', $referrer); + + $redirectResponse = back(); + + $this->assertSame($this->response, $redirectResponse); + + $this->assertEquals($referrer, $this->response->getHeader('Location')); + + $this->assertEquals(StatusCode::FOUND, $this->response->getStatusCode()); + + server()->flush(); + } + + public function testRedirectingBackFallsBackToBaseUrl(): void + { + back([], StatusCode::MOVED_PERMANENTLY); + + $this->assertEquals(base_url(), $this->response->getHeader('Location')); + + $this->assertEquals(StatusCode::MOVED_PERMANENTLY, $this->response->getStatusCode()); + } + + public function testRedirectingBackWithOldData(): void + { + $this->session = SessionFactory::get(); + + back(['firstname' => 'Josh', 'lastname' => 'Doe']); + + $this->assertEquals('Josh', old('firstname')); + + $this->assertEquals('Doe', old('lastname')); + + $this->session->flush(); + } } diff --git a/tests/Unit/Http/ResponseTest.php b/tests/Unit/Http/ResponseTest.php index 251f2eeae..a913e8254 100644 --- a/tests/Unit/Http/ResponseTest.php +++ b/tests/Unit/Http/ResponseTest.php @@ -2,6 +2,7 @@ namespace Quantum\Tests\Unit\Http; +use Quantum\Session\Factories\SessionFactory; use Quantum\Http\Enums\ContentType; use Quantum\Tests\Unit\AppTestCase; use Throwable; @@ -11,11 +12,15 @@ class ResponseTest extends AppTestCase public function setUp(): void { parent::setUp(); + + request()->create('GET', 'http://example.com'); } public function tearDown(): void { response()->flush(); + + server()->flush(); } public function testResponseContentType(): void @@ -58,4 +63,164 @@ public function testResponseRedirect(): void $this->assertEquals(301, $response->getStatusCode()); } + public function testResponseBackRedirectsToReferrer(): void + { + $referrer = base_url() . '/page?id=1#top'; + + server()->set('HTTP_REFERER', $referrer); + + $response = response()->back(); + + $this->assertEquals($referrer, $response->getHeader('Location')); + + $this->assertEquals(302, $response->getStatusCode()); + } + + public function testResponseBackFallsBackToBaseUrlWithExternalReferrer(): void + { + server()->set('HTTP_REFERER', 'https://attacker.example/page'); + + $response = response()->back(); + + $this->assertEquals(base_url(), $response->getHeader('Location')); + } + + public function testResponseBackFallsBackToBaseUrlWithOtherPortOrScheme(): void + { + $base = parse_url(base_url()); + + $otherScheme = $base['scheme'] === 'https' ? 'http' : 'https'; + + foreach ([ + $base['scheme'] . '://' . $base['host'] . ':9999/page', + $otherScheme . '://' . $base['host'] . '/page', + ] as $referrer) { + server()->set('HTTP_REFERER', $referrer); + + $response = response()->back(); + + $this->assertEquals(base_url(), $response->getHeader('Location'), $referrer); + } + } + + public function testResponseBackAcceptsSameOriginReferrerForms(): void + { + $base = parse_url(base_url()); + + $defaultPort = $base['scheme'] === 'https' ? 443 : 80; + + foreach ([ + strtoupper($base['scheme']) . '://' . strtoupper($base['host']) . '/page', + $base['scheme'] . '://' . $base['host'] . ':' . $defaultPort . '/page', + $base['scheme'] . '://' . $base['host'], + ] as $referrer) { + server()->set('HTTP_REFERER', $referrer); + + $response = response()->back(); + + $this->assertEquals($referrer, $response->getHeader('Location'), $referrer); + } + } + + public function testResponseBackWithHttpsBaseUrl(): void + { + $baseUrl = config()->get('app.base_url'); + + config()->set('app.base_url', 'https://example.com'); + + foreach (['https://example.com/page', 'https://example.com:443/page'] as $referrer) { + server()->set('HTTP_REFERER', $referrer); + + $response = response()->back(); + + $this->assertEquals($referrer, $response->getHeader('Location'), $referrer); + } + + server()->set('HTTP_REFERER', 'http://example.com/page'); + + $response = response()->back(); + + $this->assertEquals('https://example.com', $response->getHeader('Location')); + + config()->set('app.base_url', $baseUrl); + } + + public function testResponseBackFallsBackToBaseUrlWithMalformedReferrer(): void + { + foreach ([ + 'not a url', + '/page', + '//attacker.example/page', + 'https:///page', + 'javascript:alert(1)', + base_url() . '\\@attacker.example', + base_url() . '@attacker.example', + ] as $referrer) { + server()->set('HTTP_REFERER', $referrer); + + $response = response()->back(); + + $this->assertEquals(base_url(), $response->getHeader('Location'), $referrer); + } + } + + public function testResponseBackFallsBackToBaseUrl(): void + { + $response = response()->back(); + + $this->assertEquals(base_url(), $response->getHeader('Location')); + + $this->assertEquals(302, $response->getStatusCode()); + } + + public function testResponseBackFallsBackToBaseUrlWithEmptyReferrer(): void + { + server()->set('HTTP_REFERER', ''); + + $response = response()->back(); + + $this->assertEquals(base_url(), $response->getHeader('Location')); + } + + public function testResponseBackWithStatusCode(): void + { + $response = response()->back([], 301); + + $this->assertEquals(base_url(), $response->getHeader('Location')); + + $this->assertEquals(301, $response->getStatusCode()); + } + + public function testResponseBackWithOldData(): void + { + $session = SessionFactory::get(); + + $referrer = base_url() . '/signup'; + + server()->set('HTTP_REFERER', $referrer); + + $response = response()->back(['firstname' => 'Josh', 'lastname' => 'Doe']); + + $this->assertEquals($referrer, $response->getHeader('Location')); + + $this->assertEquals('Josh', old('firstname')); + + $this->assertEquals('Doe', old('lastname')); + + $session->flush(); + } + + public function testResponseBackWithoutDataKeepsOldData(): void + { + $session = SessionFactory::get(); + + response()->back(['firstname' => 'Josh']); + + response()->back(); + + $this->assertEquals('Josh', old('firstname')); + + $session->flush(); + } + }