diff --git a/src/Illuminate/Foundation/Http/Middleware/PreventRequestForgery.php b/src/Illuminate/Foundation/Http/Middleware/PreventRequestForgery.php index dc2474267943..5c37d2aac007 100644 --- a/src/Illuminate/Foundation/Http/Middleware/PreventRequestForgery.php +++ b/src/Illuminate/Foundation/Http/Middleware/PreventRequestForgery.php @@ -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 @@ -144,7 +144,7 @@ protected function hasValidOrigin($request) { $secFetchSite = $request->header('Sec-Fetch-Site'); - if ($secFetchSite === 'same-origin') { + if ($secFetchSite === 'same-origin' || $secFetchSite === 'none') { return true; } @@ -152,13 +152,45 @@ protected function hasValidOrigin($request) 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. * diff --git a/tests/Http/Middleware/PreventRequestForgeryTest.php b/tests/Http/Middleware/PreventRequestForgeryTest.php index 70db5ef78435..0a683961cee9 100644 --- a/tests/Http/Middleware/PreventRequestForgeryTest.php +++ b/tests/Http/Middleware/PreventRequestForgeryTest.php @@ -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] : [], [], [],