From db114eb794366f7f1910c281f3b2d1374b05adf5 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Fri, 7 Aug 2026 01:57:30 +0300 Subject: [PATCH 1/2] Fix CORS allow-all middleware --- CHANGELOG.md | 2 +- docs/guide/en/cors-allow-all-middleware.md | 13 ++++- src/CorsAllowAllMiddleware.php | 49 ++++++++++++++--- tests/CorsAllowAllMiddlewareTest.php | 61 ++++++++++++++++++++-- 4 files changed, 113 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce69418..2d2dcce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.2.1 under development -- no changes in this release. +- Bug #24: Fix invalid CORS headers and add optional preflight request handling to `CorsAllowAllMiddleware` (@samdark) ## 1.2.0 March 10, 2026 diff --git a/docs/guide/en/cors-allow-all-middleware.md b/docs/guide/en/cors-allow-all-middleware.md index 390fe8d..c82dab6 100644 --- a/docs/guide/en/cors-allow-all-middleware.md +++ b/docs/guide/en/cors-allow-all-middleware.md @@ -25,4 +25,15 @@ use Yiisoft\HttpMiddleware\CorsAllowAllMiddleware; $middleware = new CorsAllowAllMiddleware(); ``` -There are no constructor arguments, the middleware is ready to use out of the box. +To handle CORS preflight requests without passing them to the request handler, provide a PSR-17 response factory: + +```php +use Psr\Http\Message\ResponseFactoryInterface; +use Yiisoft\HttpMiddleware\CorsAllowAllMiddleware; + +/** @var ResponseFactoryInterface $responseFactory */ +$middleware = new CorsAllowAllMiddleware($responseFactory); +``` + +The constructor argument is optional for backward compatibility. Without a response factory, preflight requests are +passed to the next request handler and CORS headers are added to its response. diff --git a/src/CorsAllowAllMiddleware.php b/src/CorsAllowAllMiddleware.php index 548b8a8..0e20520 100644 --- a/src/CorsAllowAllMiddleware.php +++ b/src/CorsAllowAllMiddleware.php @@ -4,6 +4,7 @@ namespace Yiisoft\HttpMiddleware; +use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; @@ -20,18 +21,52 @@ */ final class CorsAllowAllMiddleware implements MiddlewareInterface { + /** + * @param ResponseFactoryInterface|null $responseFactory Factory used to short-circuit preflight requests. + */ + public function __construct( + private readonly ?ResponseFactoryInterface $responseFactory = null, + ) {} + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $response = $handler->handle($request); + $origin = $request->getHeaderLine('Origin'); + $isPreflight = $request->getMethod() === 'OPTIONS' + && $request->hasHeader('Access-Control-Request-Method'); + + $response = $isPreflight && $this->responseFactory !== null + ? $this->responseFactory->createResponse(204) + : $handler->handle($request); + + $exposedHeaders = []; + /** @var array $headers */ + $headers = $response->getHeaders(); + foreach ($headers as $name => $_) { + if (strtolower($name) !== 'set-cookie') { + $exposedHeaders[] = $name; + } + } - return $response - ->withHeader('Allow', '*') + $response = $response ->withHeader('Vary', 'Origin') - ->withHeader('Access-Control-Allow-Origin', '*') + ->withHeader('Access-Control-Allow-Origin', $origin === '' ? '*' : $origin) ->withHeader('Access-Control-Allow-Methods', 'GET,OPTIONS,HEAD,POST,PUT,PATCH,DELETE') - ->withHeader('Access-Control-Allow-Headers', '*') - ->withHeader('Access-Control-Expose-Headers', '*') - ->withHeader('Access-Control-Allow-Credentials', 'true') ->withHeader('Access-Control-Max-Age', '86400'); + + if ($origin === '') { + return $response + ->withHeader('Access-Control-Allow-Headers', '*') + ->withHeader('Access-Control-Expose-Headers', '*'); + } + + $requestedHeaders = $request->getHeaderLine('Access-Control-Request-Headers'); + if ($requestedHeaders !== '') { + $response = $response->withHeader('Access-Control-Allow-Headers', $requestedHeaders); + } + if ($exposedHeaders !== []) { + $response = $response->withHeader('Access-Control-Expose-Headers', implode(',', $exposedHeaders)); + } + + return $response->withHeader('Access-Control-Allow-Credentials', 'true'); } } diff --git a/tests/CorsAllowAllMiddlewareTest.php b/tests/CorsAllowAllMiddlewareTest.php index 6c93c63..41c5a1e 100644 --- a/tests/CorsAllowAllMiddlewareTest.php +++ b/tests/CorsAllowAllMiddlewareTest.php @@ -5,11 +5,13 @@ namespace Yiisoft\HttpMiddleware\Tests; use HttpSoft\Message\Response; +use HttpSoft\Message\ResponseFactory; use HttpSoft\Message\ServerRequest; use PHPUnit\Framework\TestCase; use Yiisoft\HttpMiddleware\CorsAllowAllMiddleware; use Yiisoft\HttpMiddleware\Tests\Support\FakeRequestHandler; +use function PHPUnit\Framework\assertNull; use function PHPUnit\Framework\assertSame; final class CorsAllowAllMiddlewareTest extends TestCase @@ -25,16 +27,69 @@ public function testBase(): void assertSame($request, $requestHandler->getLastRequest()); assertSame( [ - 'Allow' => ['*'], 'Vary' => ['Origin'], 'Access-Control-Allow-Origin' => ['*'], 'Access-Control-Allow-Methods' => ['GET,OPTIONS,HEAD,POST,PUT,PATCH,DELETE'], + 'Access-Control-Max-Age' => ['86400'], 'Access-Control-Allow-Headers' => ['*'], 'Access-Control-Expose-Headers' => ['*'], - 'Access-Control-Allow-Credentials' => ['true'], - 'Access-Control-Max-Age' => ['86400'], ], $response->getHeaders(), ); } + + public function testCredentialedRequest(): void + { + $request = (new ServerRequest()) + ->withHeader('Origin', 'https://example.com'); + $requestHandler = new FakeRequestHandler( + (new Response()) + ->withHeader('X-Request-Id', '42') + ->withHeader('Set-Cookie', 'token=secret'), + ); + + $response = (new CorsAllowAllMiddleware())->process($request, $requestHandler); + + assertSame('https://example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); + assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials')); + assertSame('X-Request-Id', $response->getHeaderLine('Access-Control-Expose-Headers')); + } + + public function testRequestWithFactoryIsHandled(): void + { + $request = new ServerRequest(); + $requestHandler = new FakeRequestHandler(); + + (new CorsAllowAllMiddleware(new ResponseFactory()))->process($request, $requestHandler); + + assertSame($request, $requestHandler->getLastRequest()); + } + + public function testOptionsWithoutRequestedMethodIsHandled(): void + { + $request = (new ServerRequest())->withMethod('OPTIONS'); + $requestHandler = new FakeRequestHandler(); + + (new CorsAllowAllMiddleware(new ResponseFactory()))->process($request, $requestHandler); + + assertSame($request, $requestHandler->getLastRequest()); + } + + public function testPreflight(): void + { + $request = (new ServerRequest()) + ->withMethod('OPTIONS') + ->withHeader('Origin', 'https://example.com') + ->withHeader('Access-Control-Request-Method', 'POST') + ->withHeader('Access-Control-Request-Headers', 'Content-Type, Authorization'); + $requestHandler = new FakeRequestHandler(); + + $response = (new CorsAllowAllMiddleware(new ResponseFactory()))->process($request, $requestHandler); + + assertNull($requestHandler->getLastRequest()); + assertSame(204, $response->getStatusCode()); + assertSame('https://example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); + assertSame('Content-Type, Authorization', $response->getHeaderLine('Access-Control-Allow-Headers')); + assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials')); + } } From ef222836e6831bf51913ce2193e87322938ef3af Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Fri, 7 Aug 2026 02:04:12 +0300 Subject: [PATCH 2/2] Address CORS middleware review comments --- src/CorsAllowAllMiddleware.php | 14 ++++++-- tests/CorsAllowAllMiddlewareTest.php | 54 ++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/CorsAllowAllMiddleware.php b/src/CorsAllowAllMiddleware.php index 0e20520..772f15d 100644 --- a/src/CorsAllowAllMiddleware.php +++ b/src/CorsAllowAllMiddleware.php @@ -10,6 +10,9 @@ use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use function count; +use function in_array; + /** * Adds Cross-Origin Resource Sharing (CORS) headers allowing everything to the response. * @@ -30,9 +33,10 @@ public function __construct( public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $origin = $request->getHeaderLine('Origin'); + $origins = $request->getHeader('Origin'); + $origin = count($origins) === 1 ? $origins[0] : ''; $isPreflight = $request->getMethod() === 'OPTIONS' - && $request->hasHeader('Access-Control-Request-Method'); + && $request->getHeaderLine('Access-Control-Request-Method') !== ''; $response = $isPreflight && $this->responseFactory !== null ? $this->responseFactory->createResponse(204) @@ -47,8 +51,12 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } } + $vary = array_map(trim(...), explode(',', strtolower($response->getHeaderLine('Vary')))); + if (!in_array('origin', $vary, true)) { + $response = $response->withAddedHeader('Vary', 'Origin'); + } + $response = $response - ->withHeader('Vary', 'Origin') ->withHeader('Access-Control-Allow-Origin', $origin === '' ? '*' : $origin) ->withHeader('Access-Control-Allow-Methods', 'GET,OPTIONS,HEAD,POST,PUT,PATCH,DELETE') ->withHeader('Access-Control-Max-Age', '86400'); diff --git a/tests/CorsAllowAllMiddlewareTest.php b/tests/CorsAllowAllMiddlewareTest.php index 41c5a1e..35ee43e 100644 --- a/tests/CorsAllowAllMiddlewareTest.php +++ b/tests/CorsAllowAllMiddlewareTest.php @@ -45,14 +45,39 @@ public function testCredentialedRequest(): void $requestHandler = new FakeRequestHandler( (new Response()) ->withHeader('X-Request-Id', '42') - ->withHeader('Set-Cookie', 'token=secret'), + ->withHeader('Set-Cookie', 'token=secret') + ->withHeader('Vary', 'Accept-Encoding'), ); $response = (new CorsAllowAllMiddleware())->process($request, $requestHandler); assertSame('https://example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials')); - assertSame('X-Request-Id', $response->getHeaderLine('Access-Control-Expose-Headers')); + assertSame('X-Request-Id,Vary', $response->getHeaderLine('Access-Control-Expose-Headers')); + assertSame(['Accept-Encoding', 'Origin'], $response->getHeader('Vary')); + } + + public function testMultipleOriginsAreNotReflected(): void + { + $request = (new ServerRequest()) + ->withHeader('Origin', ['https://example.com', 'https://example.org']); + + $response = (new CorsAllowAllMiddleware())->process($request, new FakeRequestHandler()); + + assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + assertSame('', $response->getHeaderLine('Access-Control-Allow-Credentials')); + } + + public function testExistingOriginInVaryIsNotDuplicated(): void + { + $handlerResponse = (new Response())->withHeader('Vary', 'Accept-Encoding, ORIGIN'); + + $response = (new CorsAllowAllMiddleware())->process( + new ServerRequest(), + new FakeRequestHandler($handlerResponse), + ); + + assertSame(['Accept-Encoding, ORIGIN'], $response->getHeader('Vary')); } public function testRequestWithFactoryIsHandled(): void @@ -75,6 +100,31 @@ public function testOptionsWithoutRequestedMethodIsHandled(): void assertSame($request, $requestHandler->getLastRequest()); } + public function testOptionsWithEmptyRequestedMethodIsHandled(): void + { + $request = (new ServerRequest()) + ->withMethod('OPTIONS') + ->withHeader('Access-Control-Request-Method', ''); + $requestHandler = new FakeRequestHandler(); + + (new CorsAllowAllMiddleware(new ResponseFactory()))->process($request, $requestHandler); + + assertSame($request, $requestHandler->getLastRequest()); + } + + public function testPreflightWithoutFactoryIsHandled(): void + { + $request = (new ServerRequest()) + ->withMethod('OPTIONS') + ->withHeader('Access-Control-Request-Method', 'POST'); + $requestHandler = new FakeRequestHandler(new Response(202)); + + $response = (new CorsAllowAllMiddleware())->process($request, $requestHandler); + + assertSame($request, $requestHandler->getLastRequest()); + assertSame(202, $response->getStatusCode()); + } + public function testPreflight(): void { $request = (new ServerRequest())