Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/Http/Helpers/http.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $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
Expand Down
59 changes: 59 additions & 0 deletions src/Http/Traits/Response/Header.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, mixed> $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<string, mixed> $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;
}
}
41 changes: 41 additions & 0 deletions tests/Unit/Http/Helpers/HttpHelperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
165 changes: 165 additions & 0 deletions tests/Unit/Http/ResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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();
}

}
Loading