Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ protected function runningUnitTests()
}

/**
* Determine if the request has a valid origin based on the Sec-Fetch-Site header.
* Determine if the request has a valid origin.
*
* @param \Illuminate\Http\Request $request
* @return bool
Expand All @@ -144,21 +144,53 @@ protected function hasValidOrigin($request)
{
$secFetchSite = $request->header('Sec-Fetch-Site');

if ($secFetchSite === 'same-origin') {
if ($secFetchSite === 'same-origin' || $secFetchSite === 'none') {
return true;
}

if ($secFetchSite === 'same-site' && static::$allowSameSite) {
return true;
}

// Sec-Fetch-Site absent, try Origin header as fallback.
if ($secFetchSite === null && $this->originMatchesHost($request)) {
return true;
}

if (static::$originOnly) {
if ($secFetchSite === null && ! $request->secure()) {
throw new OriginMismatchException(
'Origin verification requires a secure connection. '
.'Browsers do not send Sec-Fetch-Site headers over plain HTTP.'
);
}

throw new OriginMismatchException('Origin mismatch.');
}

return false;
}

/**
* Determine if the request's Origin header matches the application host.
*
* @param \Illuminate\Http\Request $request
* @return bool
*/
protected function originMatchesHost($request)
{
$origin = $request->header('Origin');

if ($origin === null || $origin === 'null') {
return false;
}

return strcasecmp(
rtrim($origin, '/'),
$request->getSchemeAndHttpHost()
) === 0;
}

/**
* Determine if the session and input CSRF tokens match.
*
Expand Down
293 changes: 290 additions & 3 deletions tests/Http/Middleware/PreventRequestForgeryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,298 @@ public function test_origin_only_mode_passes_same_origin()
$this->assertEquals('OK', $response->getContent());
}

protected function createRequest(array $server = [], ?string $token = null)
// Sec-Fetch-Site: none tests

public function test_none_header_passes_in_default_mode()
{
$middleware = $this->createMiddleware();
$request = $this->createRequest(['HTTP_SEC_FETCH_SITE' => 'none']);

$response = $middleware->handle($request, fn () => new Response('OK'));

$this->assertEquals('OK', $response->getContent());
}

public function test_none_header_passes_in_origin_only_mode()
{
PreventRequestForgery::useOriginOnly();

$middleware = $this->createMiddleware();
$request = $this->createRequest(['HTTP_SEC_FETCH_SITE' => 'none']);

$response = $middleware->handle($request, fn () => new Response('OK'));

$this->assertEquals('OK', $response->getContent());
}

public function test_absent_header_still_rejected_in_origin_only_mode()
{
PreventRequestForgery::useOriginOnly();

$middleware = $this->createMiddleware();
$request = $this->createRequest(server: ['HTTPS' => 'on'], url: 'https://example.com/test');

$this->expectException(OriginMismatchException::class);

$middleware->handle($request, fn () => new Response('OK'));
}

public function test_get_request_with_none_header_still_passes()
{
$middleware = $this->createMiddleware();
$request = $this->createRequest(
server: ['HTTP_SEC_FETCH_SITE' => 'none'],
method: 'GET',
);

$response = $middleware->handle($request, fn () => new Response('OK'));

$this->assertEquals('OK', $response->getContent());
}

// Origin header fallback tests

public function test_origin_fallback_passes_when_origin_matches_host()
{
$middleware = $this->createMiddleware();
$request = $this->createRequest(
server: ['HTTP_ORIGIN' => 'http://example.com'],
);

$response = $middleware->handle($request, fn () => new Response('OK'));

$this->assertEquals('OK', $response->getContent());
}

public function test_origin_fallback_rejects_when_origin_does_not_match()
{
$middleware = $this->createMiddleware();
$request = $this->createRequest(
server: ['HTTP_ORIGIN' => 'https://evil.com'],
);

$this->expectException(TokenMismatchException::class);

$middleware->handle($request, fn () => new Response('OK'));
}

public function test_origin_fallback_rejects_null_origin()
{
$middleware = $this->createMiddleware();
$request = $this->createRequest(
server: ['HTTP_ORIGIN' => 'null'],
);

$this->expectException(TokenMismatchException::class);

$middleware->handle($request, fn () => new Response('OK'));
}

public function test_origin_fallback_rejects_when_no_origin_header()
{
$middleware = $this->createMiddleware();
$request = $this->createRequest();

$this->expectException(TokenMismatchException::class);

$middleware->handle($request, fn () => new Response('OK'));
}

public function test_origin_fallback_not_used_when_sec_fetch_site_present()
{
$middleware = $this->createMiddleware();
$request = $this->createRequest(
server: [
'HTTP_SEC_FETCH_SITE' => 'cross-site',
'HTTP_ORIGIN' => 'http://example.com',
],
);

$this->expectException(TokenMismatchException::class);

$middleware->handle($request, fn () => new Response('OK'));
}

public function test_origin_fallback_not_used_when_sec_fetch_site_same_origin()
{
$middleware = $this->createMiddleware();
// Origin doesn't match, but Sec-Fetch-Site: same-origin should pass
// without ever consulting the Origin header.
$request = $this->createRequest(
server: [
'HTTP_SEC_FETCH_SITE' => 'same-origin',
'HTTP_ORIGIN' => 'https://evil.com',
],
);

$response = $middleware->handle($request, fn () => new Response('OK'));

$this->assertEquals('OK', $response->getContent());
}

public function test_origin_fallback_normalizes_default_ports()
{
$middleware = $this->createMiddleware();
$request = $this->createRequest(
server: ['HTTPS' => 'on', 'HTTP_ORIGIN' => 'https://example.com'],
url: 'https://example.com:443/test',
);

$response = $middleware->handle($request, fn () => new Response('OK'));

$this->assertEquals('OK', $response->getContent());
}

public function test_origin_fallback_rejects_port_mismatch()
{
$middleware = $this->createMiddleware();
$request = $this->createRequest(
server: ['HTTPS' => 'on', 'HTTP_ORIGIN' => 'https://example.com:8443'],
url: 'https://example.com/test',
);

$this->expectException(TokenMismatchException::class);

$middleware->handle($request, fn () => new Response('OK'));
}

public function test_origin_fallback_rejects_scheme_mismatch()
{
$middleware = $this->createMiddleware();
$request = $this->createRequest(
server: ['HTTPS' => 'on', 'HTTP_ORIGIN' => 'http://example.com'],
url: 'https://example.com/test',
);

$this->expectException(TokenMismatchException::class);

$middleware->handle($request, fn () => new Response('OK'));
}

public function test_origin_fallback_is_case_insensitive()
{
$middleware = $this->createMiddleware();
$request = $this->createRequest(
server: ['HTTP_ORIGIN' => 'http://Example.COM'],
);

$response = $middleware->handle($request, fn () => new Response('OK'));

$this->assertEquals('OK', $response->getContent());
}

public function test_origin_fallback_passes_in_origin_only_mode()
{
PreventRequestForgery::useOriginOnly();

$middleware = $this->createMiddleware();
$request = $this->createRequest(
server: ['HTTPS' => 'on', 'HTTP_ORIGIN' => 'https://example.com'],
url: 'https://example.com/test',
);

$response = $middleware->handle($request, fn () => new Response('OK'));

$this->assertEquals('OK', $response->getContent());
}

public function test_origin_fallback_mismatch_throws_in_origin_only_mode()
{
PreventRequestForgery::useOriginOnly();

$middleware = $this->createMiddleware();
$request = $this->createRequest(
server: ['HTTPS' => 'on', 'HTTP_ORIGIN' => 'https://evil.com'],
url: 'https://example.com/test',
);

$this->expectException(OriginMismatchException::class);

$middleware->handle($request, fn () => new Response('OK'));
}

public function test_no_origin_throws_in_origin_only_mode()
{
PreventRequestForgery::useOriginOnly();

$middleware = $this->createMiddleware();
$request = $this->createRequest(
server: ['HTTPS' => 'on'],
url: 'https://example.com/test',
);

$this->expectException(OriginMismatchException::class);

$middleware->handle($request, fn () => new Response('OK'));
}

// HTTP error message tests

public function test_origin_only_http_missing_header_shows_helpful_message()
{
PreventRequestForgery::useOriginOnly();

$middleware = $this->createMiddleware();
$request = $this->createRequest();

try {
$middleware->handle($request, fn () => new Response('OK'));
$this->fail('Expected OriginMismatchException');
} catch (OriginMismatchException $e) {
$this->assertStringContainsString('secure connection', $e->getMessage());
}
}

public function test_origin_only_https_missing_header_shows_generic_message()
{
PreventRequestForgery::useOriginOnly();

$middleware = $this->createMiddleware();
$request = $this->createRequest(
server: ['HTTPS' => 'on'],
url: 'https://example.com/test',
);

try {
$middleware->handle($request, fn () => new Response('OK'));
$this->fail('Expected OriginMismatchException');
} catch (OriginMismatchException $e) {
$this->assertEquals('Origin mismatch.', $e->getMessage());
}
}

public function test_origin_only_http_cross_site_shows_generic_message()
{
PreventRequestForgery::useOriginOnly();

$middleware = $this->createMiddleware();
$request = $this->createRequest(
server: ['HTTP_SEC_FETCH_SITE' => 'cross-site'],
);

try {
$middleware->handle($request, fn () => new Response('OK'));
$this->fail('Expected OriginMismatchException');
} catch (OriginMismatchException $e) {
$this->assertEquals('Origin mismatch.', $e->getMessage());
}
}

public function test_default_mode_http_unchanged()
{
$middleware = $this->createMiddleware();
$request = $this->createRequest();

$this->expectException(TokenMismatchException::class);

$middleware->handle($request, fn () => new Response('OK'));
}

protected function createRequest(array $server = [], ?string $token = null, string $url = 'http://example.com/test', string $method = 'POST')
{
$request = Request::create(
'http://example.com/test',
'POST',
$url,
$method,
$token ? ['_token' => $token] : [],
[],
[],
Expand Down
Loading