diff --git a/docs/changelogs/v4.7.7.md b/docs/changelogs/v4.7.7.md index cdf4eb2db148..d3858d5efc9a 100644 --- a/docs/changelogs/v4.7.7.md +++ b/docs/changelogs/v4.7.7.md @@ -14,6 +14,7 @@ Release Date: Unreleased ## Bugs Fixed +- **CORS:** Fixed a bug where `Cors::hasResponseHeaders()` matched header substrings via `str_contains()`, causing false positives when existing exposed headers shared a prefix with configured exposed headers. - **Security:** Fixed a bug where `Security::__construct()` threw a `TypeError` when the CSRF cookie was an array, causing an HTTP 500 response for affected requests. - **Worker Mode:** Fixed a bug where every request that ended with an uncaught exception, including a `404 Page Not Found` without a 404 override, called `exit()` in the exception handler. This ended the worker script, so FrankenPHP booted a new one, and the worker loop skipped its cleanup after the request. The exception handler now returns to the worker loop. diff --git a/system/HTTP/Cors.php b/system/HTTP/Cors.php index e1da450b8270..a8b405f4230c 100644 --- a/system/HTTP/Cors.php +++ b/system/HTTP/Cors.php @@ -240,9 +240,23 @@ public function hasResponseHeaders(RequestInterface $request, ResponseInterface return false; } - return ! ($this->config['exposedHeaders'] !== [] && (! $response->hasHeader('Access-Control-Expose-Headers') || ! str_contains( - $response->getHeaderLine('Access-Control-Expose-Headers'), - implode(', ', $this->config['exposedHeaders']), - ))); + if ($this->config['exposedHeaders'] !== []) { + if (! $response->hasHeader('Access-Control-Expose-Headers')) { + return false; + } + + $exposedHeaders = array_map( + strtolower(...), + array_map(trim(...), explode(',', $response->getHeaderLine('Access-Control-Expose-Headers'))), + ); + + foreach ($this->config['exposedHeaders'] as $header) { + if (! in_array(strtolower($header), $exposedHeaders, true)) { + return false; + } + } + } + + return true; } } diff --git a/tests/system/HTTP/CorsTest.php b/tests/system/HTTP/CorsTest.php index 0da2bdcee55b..71d2c84a44b7 100644 --- a/tests/system/HTTP/CorsTest.php +++ b/tests/system/HTTP/CorsTest.php @@ -566,4 +566,67 @@ public function testHasResponseHeadersTrue(): void $cors->hasResponseHeaders($request, $response), ); } + + public function testHasResponseHeadersReturnsFalseWhenExposedHeaderIsSubstringOfExistingHeader(): void + { + $config = $this->getDefaultConfig(); + $config['allowedOrigins'] = ['http://localhost:8080']; + $config['allowedMethods'] = ['GET']; + $config['exposedHeaders'] = ['X-Custom']; + $cors = $this->createCors($config); + + $request = $this->createRequest() + ->withMethod('GET') + ->setHeader('Origin', 'http://localhost:8080'); + + $response = service('redirectresponse', null, false); + $response->setHeader('Access-Control-Allow-Origin', 'http://localhost:8080'); + $response->setHeader('Access-Control-Expose-Headers', 'X-Custom-Header'); + + $this->assertFalse( + $cors->hasResponseHeaders($request, $response), + ); + } + + public function testHasResponseHeadersWithExposedHeadersInDifferentOrder(): void + { + $config = $this->getDefaultConfig(); + $config['allowedOrigins'] = ['http://localhost:8080']; + $config['allowedMethods'] = ['GET']; + $config['exposedHeaders'] = ['X-Header-One', 'X-Header-Two']; + $cors = $this->createCors($config); + + $request = $this->createRequest() + ->withMethod('GET') + ->setHeader('Origin', 'http://localhost:8080'); + + $response = service('redirectresponse', null, false); + $response->setHeader('Access-Control-Allow-Origin', 'http://localhost:8080'); + $response->setHeader('Access-Control-Expose-Headers', 'X-Header-Two, X-Header-One'); + + $this->assertTrue( + $cors->hasResponseHeaders($request, $response), + ); + } + + public function testHasResponseHeadersWithExposedHeadersCaseInsensitive(): void + { + $config = $this->getDefaultConfig(); + $config['allowedOrigins'] = ['http://localhost:8080']; + $config['allowedMethods'] = ['GET']; + $config['exposedHeaders'] = ['X-Custom-Header']; + $cors = $this->createCors($config); + + $request = $this->createRequest() + ->withMethod('GET') + ->setHeader('Origin', 'http://localhost:8080'); + + $response = service('redirectresponse', null, false); + $response->setHeader('Access-Control-Allow-Origin', 'http://localhost:8080'); + $response->setHeader('Access-Control-Expose-Headers', 'x-custom-header'); + + $this->assertTrue( + $cors->hasResponseHeaders($request, $response), + ); + } }