From 6bd625e160c6c200080d6b6a9cd635b432b445da Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:44:39 +0400 Subject: [PATCH 1/7] [#540] Add back() to the response redirect API Co-Authored-By: Claude Sonnet 5.5 --- src/Http/Traits/Response/Header.php | 12 ++++++++++ tests/Unit/Http/ResponseTest.php | 35 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/Http/Traits/Response/Header.php b/src/Http/Traits/Response/Header.php index e0ae31054..593ea19a7 100644 --- a/src/Http/Traits/Response/Header.php +++ b/src/Http/Traits/Response/Header.php @@ -10,7 +10,9 @@ namespace Quantum\Http\Traits\Response; +use Quantum\Di\Exceptions\DiException; use Quantum\Http\Enums\ContentType; +use ReflectionException; /** * Trait Header @@ -94,4 +96,14 @@ public function redirect(string $url, int $code = 302): self $this->setHeader('Location', $url); return $this; } + + /** + * Redirects back to the referrer, falls back to the base url + * @return self + * @throws DiException|ReflectionException + */ + public function back(int $code = 302): self + { + return $this->redirect(get_referrer() ?? base_url(), $code); + } } diff --git a/tests/Unit/Http/ResponseTest.php b/tests/Unit/Http/ResponseTest.php index 251f2eeae..1c92b2031 100644 --- a/tests/Unit/Http/ResponseTest.php +++ b/tests/Unit/Http/ResponseTest.php @@ -11,11 +11,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 +62,35 @@ public function testResponseRedirect(): void $this->assertEquals(301, $response->getStatusCode()); } + public function testResponseBackRedirectsToReferrer(): void + { + $referrer = 'https://example.com/page'; + + server()->set('HTTP_REFERER', $referrer); + + $response = response()->back(); + + $this->assertEquals($referrer, $response->getHeader('Location')); + + $this->assertEquals(302, $response->getStatusCode()); + } + + public function testResponseBackFallsBackToBaseUrl(): void + { + $response = response()->back(); + + $this->assertEquals(base_url(), $response->getHeader('Location')); + + $this->assertEquals(302, $response->getStatusCode()); + } + + public function testResponseBackWithStatusCode(): void + { + $response = response()->back(301); + + $this->assertEquals(base_url(), $response->getHeader('Location')); + + $this->assertEquals(301, $response->getStatusCode()); + } + } From ecc2c0af99c1a8da28d1ead2dbe717fdef363b9d Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:51:30 +0400 Subject: [PATCH 2/7] [#540] Let back() carry old input Co-Authored-By: Claude Sonnet 5.5 --- src/Http/Traits/Response/Header.php | 12 ++++++++-- tests/Unit/Http/ResponseTest.php | 35 ++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/Http/Traits/Response/Header.php b/src/Http/Traits/Response/Header.php index 593ea19a7..589eca306 100644 --- a/src/Http/Traits/Response/Header.php +++ b/src/Http/Traits/Response/Header.php @@ -10,7 +10,10 @@ 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 ReflectionException; @@ -99,11 +102,16 @@ public function redirect(string $url, int $code = 302): self /** * Redirects back to the referrer, falls back to the base url + * @param array $data Old input to keep for the next request * @return self - * @throws DiException|ReflectionException + * @throws ConfigException|DiException|BaseException|ReflectionException */ - public function back(int $code = 302): self + public function back(array $data = [], int $code = 302): self { + if ($data !== []) { + session()->set(ReservedKeys::PREV_REQUEST, $data); + } + return $this->redirect(get_referrer() ?? base_url(), $code); } } diff --git a/tests/Unit/Http/ResponseTest.php b/tests/Unit/Http/ResponseTest.php index 1c92b2031..1dc734b0d 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; @@ -86,11 +87,43 @@ public function testResponseBackFallsBackToBaseUrl(): void public function testResponseBackWithStatusCode(): void { - $response = response()->back(301); + $response = response()->back([], 301); $this->assertEquals(base_url(), $response->getHeader('Location')); $this->assertEquals(301, $response->getStatusCode()); } + public function testResponseBackWithOldData(): void + { + $session = SessionFactory::get(); + + $referrer = 'https://example.com/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(); + } + } From 6a2e976d2094fd39ea42b5c6aaa5e1b0ddd91710 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:00:17 +0400 Subject: [PATCH 3/7] [#540] Add the back() redirect to the changelog Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2345b7256..72c249fa4 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()`, which redirects to the referrer and falls back to `base_url()` when the request has none. It takes the old input to keep for the next request, read with `old()` as after `redirectWith()`, and an optional status code (`response()->back($data, $code)`) (#540) + ## [3.1.0] - 2026-10-24 ### Added From 6593a66e859047fd966af068f7a72889f9311e11 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:13:10 +0400 Subject: [PATCH 4/7] [#540] Fall back to base_url() when the referrer is empty Co-Authored-By: Claude Sonnet 5.5 --- src/Http/Traits/Response/Header.php | 2 +- tests/Unit/Http/ResponseTest.php | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Http/Traits/Response/Header.php b/src/Http/Traits/Response/Header.php index 589eca306..4f62c6233 100644 --- a/src/Http/Traits/Response/Header.php +++ b/src/Http/Traits/Response/Header.php @@ -112,6 +112,6 @@ public function back(array $data = [], int $code = 302): self session()->set(ReservedKeys::PREV_REQUEST, $data); } - return $this->redirect(get_referrer() ?? base_url(), $code); + return $this->redirect(get_referrer() ?: base_url(), $code); } } diff --git a/tests/Unit/Http/ResponseTest.php b/tests/Unit/Http/ResponseTest.php index 1dc734b0d..ff7ca2baa 100644 --- a/tests/Unit/Http/ResponseTest.php +++ b/tests/Unit/Http/ResponseTest.php @@ -85,6 +85,15 @@ public function testResponseBackFallsBackToBaseUrl(): void $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); From c828d1be0787d725a11fe4ce6ca14d0818196db8 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:30:05 +0400 Subject: [PATCH 5/7] [#540] Add the back() helper Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- src/Http/Helpers/http.php | 10 ++++++ tests/Unit/Http/Helpers/HttpHelperTest.php | 39 ++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72c249fa4..e0f9a8e1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ The format is based on Keep a Changelog. ## [Unreleased] ### Added -- Added `Response::back()`, which redirects to the referrer and falls back to `base_url()` when the request has none. It takes the old input to keep for the next request, read with `old()` as after `redirectWith()`, and an optional status code (`response()->back($data, $code)`) (#540) +- Added `Response::back()` and the `back()` helper, which redirect to the referrer and fall back to `base_url()` when the request has none. 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 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/tests/Unit/Http/Helpers/HttpHelperTest.php b/tests/Unit/Http/Helpers/HttpHelperTest.php index ed686742d..a9066e812 100644 --- a/tests/Unit/Http/Helpers/HttpHelperTest.php +++ b/tests/Unit/Http/Helpers/HttpHelperTest.php @@ -156,4 +156,43 @@ public function testRedirectWithOldData(): void $this->session->flush(); } + + public function testRedirectingBack(): void + { + $this->request->create('GET', 'http://example.com'); + + server()->set('HTTP_REFERER', 'https://example.com/page'); + + $redirectResponse = back(); + + $this->assertSame($this->response, $redirectResponse); + + $this->assertEquals('https://example.com/page', $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(); + } } From 7e4cfc0b7a9146965f0a1b7005c6395edbc347d8 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:43:43 +0400 Subject: [PATCH 6/7] [#540] Restrict back() to same-origin referrers Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- src/Http/Traits/Response/Header.php | 43 ++++++++++++++++++- tests/Unit/Http/Helpers/HttpHelperTest.php | 6 ++- tests/Unit/Http/ResponseTest.php | 50 +++++++++++++++++++++- 4 files changed, 94 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0f9a8e1e..861ef4359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ 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. 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) +- 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). 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 diff --git a/src/Http/Traits/Response/Header.php b/src/Http/Traits/Response/Header.php index 4f62c6233..5e835295a 100644 --- a/src/Http/Traits/Response/Header.php +++ b/src/Http/Traits/Response/Header.php @@ -15,6 +15,7 @@ use Quantum\Di\Exceptions\DiException; use Quantum\App\Enums\ReservedKeys; use Quantum\Http\Enums\ContentType; +use Quantum\Http\Request; use ReflectionException; /** @@ -101,7 +102,8 @@ public function redirect(string $url, int $code = 302): self } /** - * Redirects back to the referrer, falls back to the base url + * 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 @@ -112,6 +114,43 @@ public function back(array $data = [], int $code = 302): self session()->set(ReservedKeys::PREV_REQUEST, $data); } - return $this->redirect(get_referrer() ?: base_url(), $code); + $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 a9066e812..b1cb15ba7 100644 --- a/tests/Unit/Http/Helpers/HttpHelperTest.php +++ b/tests/Unit/Http/Helpers/HttpHelperTest.php @@ -161,13 +161,15 @@ public function testRedirectingBack(): void { $this->request->create('GET', 'http://example.com'); - server()->set('HTTP_REFERER', 'https://example.com/page'); + $referrer = base_url() . '/page'; + + server()->set('HTTP_REFERER', $referrer); $redirectResponse = back(); $this->assertSame($this->response, $redirectResponse); - $this->assertEquals('https://example.com/page', $this->response->getHeader('Location')); + $this->assertEquals($referrer, $this->response->getHeader('Location')); $this->assertEquals(StatusCode::FOUND, $this->response->getStatusCode()); diff --git a/tests/Unit/Http/ResponseTest.php b/tests/Unit/Http/ResponseTest.php index ff7ca2baa..d8b71f5f4 100644 --- a/tests/Unit/Http/ResponseTest.php +++ b/tests/Unit/Http/ResponseTest.php @@ -65,7 +65,7 @@ public function testResponseRedirect(): void public function testResponseBackRedirectsToReferrer(): void { - $referrer = 'https://example.com/page'; + $referrer = base_url() . '/page?id=1#top'; server()->set('HTTP_REFERER', $referrer); @@ -76,6 +76,52 @@ public function testResponseBackRedirectsToReferrer(): void $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 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(); @@ -107,7 +153,7 @@ public function testResponseBackWithOldData(): void { $session = SessionFactory::get(); - $referrer = 'https://example.com/signup'; + $referrer = base_url() . '/signup'; server()->set('HTTP_REFERER', $referrer); From e89edd884879fb4d946922fb4766c1b50bec4db0 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:46:47 +0400 Subject: [PATCH 7/7] [#540] Test same-origin referrers and note the app.base_url requirement Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- tests/Unit/Http/ResponseTest.php | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 861ef4359..24e671828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ 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). 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) +- 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 diff --git a/tests/Unit/Http/ResponseTest.php b/tests/Unit/Http/ResponseTest.php index d8b71f5f4..a913e8254 100644 --- a/tests/Unit/Http/ResponseTest.php +++ b/tests/Unit/Http/ResponseTest.php @@ -103,6 +103,48 @@ public function testResponseBackFallsBackToBaseUrlWithOtherPortOrScheme(): void } } + 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 ([