From 24ed244620ac602e13e9d7f5419b22473622dff2 Mon Sep 17 00:00:00 2001 From: 3m5/frohberg Date: Tue, 22 Oct 2024 10:13:54 +0200 Subject: [PATCH] TASK: add NEOS 8 compatibility --- .github/workflows/ci.yml | 30 ++ .gitignore | 4 + Classes/Http/Component/CorsComponent.php | 368 ------------------- Classes/Http/CorsHeaderMiddleware.php | 322 ++++++++++++++++ Configuration/Settings.yaml | 25 +- README.md | 51 ++- Tests/Unit/Http/CorsHeaderMiddlewareTest.php | 254 +++++++++++++ composer.json | 17 +- phpstan-baseline.neon | 6 + phpstan.neon | 7 + phpunit.xml | 26 ++ 11 files changed, 697 insertions(+), 413 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore delete mode 100644 Classes/Http/Component/CorsComponent.php create mode 100644 Classes/Http/CorsHeaderMiddleware.php create mode 100644 Tests/Unit/Http/CorsHeaderMiddlewareTest.php create mode 100644 phpstan-baseline.neon create mode 100644 phpstan.neon create mode 100644 phpunit.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7312a27 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: [ push ] + +jobs: + phpunit: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: php-actions/composer@v6 + - name: PHPUnit Tests + uses: php-actions/phpunit@v4 + with: + php_extensions: xdebug + bootstrap: vendor/autoload.php + configuration: phpunit.xml + coverage_text: true + env: + XDEBUG_MODE: coverage + + phpstan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: php-actions/composer@v6 + - uses: php-actions/phpstan@v3 + with: + path: Classes Tests + configuration: phpstan.neon diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7dd1276 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +vendor +composer.lock +.phpunit.cache diff --git a/Classes/Http/Component/CorsComponent.php b/Classes/Http/Component/CorsComponent.php deleted file mode 100644 index 0dc37aa..0000000 --- a/Classes/Http/Component/CorsComponent.php +++ /dev/null @@ -1,368 +0,0 @@ -allowedWildcardOrigins = []; - foreach ($this->allowedOrigins as $origin) { - // Normalize - $origin = strtolower($origin); - if ($origin === '*') { - $this->allowedOriginsAll = true; - break; - } else if (($i = strpos($origin, '*')) !== false) { - $this->allowedWildcardOrigins[] = [substr($origin, 0, $i), substr($origin, $i+1)]; - } else { - $this->allowedPlainOrigins[] = $origin; - } - } - - $this->allowedHeadersAll = false; - // Origin is always appended as some browsers will always request for this header at preflight - if (!in_array('Origin', $this->allowedHeaders, true)) { - $this->allowedHeaders[] = 'Origin'; - } - - foreach ($this->allowedHeaders as $headerKey) { - if ($headerKey === '*') { - $this->allowedHeadersAll = true; - break; - } - } - - foreach ($this->exposedHeaders as &$exposedHeader) { - $exposedHeader = strtolower($exposedHeader); - } - - foreach ($this->allowedHeaders as &$allowedHeader) { - $allowedHeader = strtolower($allowedHeader); - } - - foreach ($this->allowedMethods as &$method) { - $method = strtoupper($method); - } - - if ($this->debug) { - $this->systemLogger->log('CORS Component: Init', LOG_DEBUG, [ - 'allowedHeaders' => $this->allowedHeaders - ]); - } - } - - public function handle(ComponentContext $componentContext) - { - $request = $componentContext->getHttpRequest(); - if ($request->getMethod() === 'OPTIONS') { - if ($this->debug) { - $this->systemLogger->log('CORS Component: Preflight request', LOG_DEBUG); - } - $this->handlePreflight($componentContext); - if (!$this->optionsPassthrough) { - $componentContext->setParameter(ComponentChain::class, 'cancel', true); - } - } else { - if ($this->debug) { - $this->systemLogger->log('CORS Component: Actual request', LOG_DEBUG); - } - $this->handleActualRequest($componentContext); - } - - } - - protected function handlePreflight(ComponentContext $componentContext) - { - $request = $componentContext->getHttpRequest(); - $response = $componentContext->getHttpResponse(); - - $origin = (string)$request->getHeader('Origin'); - - // Always set Vary headers - // see https://github.com/rs/cors/issues/10, - // https://github.com/rs/cors/commit/dbdca4d95feaa7511a46e6f1efb3b3aa505bc43f#commitcomment-12352001 - $request->setHeader('Vary', ['Origin', 'Access-Control-Request-Method', 'Access-Control-Request-Headers']); - - if ($origin === '') { - if ($this->debug) { - $this->systemLogger->log(' Preflight aborted: empty Origin header', LOG_DEBUG); - } - return; - } - - if (!$this->isOriginAllowed($origin)) { - if ($this->debug) { - $this->systemLogger->log(sprintf(' Preflight aborted: origin "%s" not allowed', $origin), LOG_DEBUG); - } - return; - } - - $requestMethod = $request->getHeader('Access-Control-Request-Method'); - if (!$this->isMethodAllowed($requestMethod)) { - if ($this->debug) { - $this->systemLogger->log(sprintf(' Preflight aborted: method "%s" not allowed', $origin), LOG_DEBUG); - } - return; - } - - $headerList = $request->getHeader("Access-Control-Request-Headers"); - $requestHeaders = $this->parseHeaderList($headerList); - if (!$this->areHeadersAllowed($requestHeaders)) { - if ($this->debug) { - $this->systemLogger->log(sprintf(' Preflight aborted: headers "%s" not allowed', $headerList), LOG_DEBUG); - } - return; - } - - if ($this->allowedOriginsAll && !$this->allowCredentials) { - $response->setHeader('Access-Control-Allow-Origin', '*'); - } else { - $response->setHeader('Access-Control-Allow-Origin', $origin); - } - - // Spec says: Since the list of methods can be unbounded, simply returning the method indicated - // by Access-Control-Request-Method (if supported) can be enough - $response->setHeader('Access-Control-Allow-Methods', strtoupper($requestMethod)); - - if ($requestHeaders !== []) { - // Spec says: Since the list of headers can be unbounded, simply returning supported headers - // from Access-Control-Request-Headers can be enough - $response->setHeader('Access-Control-Allow-Headers', implode(', ', $requestHeaders)); - } - - if ($this->allowCredentials) { - $response->setHeader('Access-Control-Allow-Credentials', 'true'); - } - - if ($this->maxAge > 0) { - $response->setHeader('Access-Control-Max-Age', $this->maxAge); - } - - if ($this->debug) { - $this->systemLogger->log(' Preflight response headers', LOG_DEBUG, [ - 'headers' => $response->getHeaders()->getAll() - ]); - } - } - - protected function handleActualRequest(ComponentContext $componentContext) - { - $request = $componentContext->getHttpRequest(); - $response = $componentContext->getHttpResponse(); - - $method = $request->getMethod(); - if ($method === 'OPTIONS') { - if ($this->debug) { - $this->systemLogger->log(' Actual request no headers added: method == OPTIONS', LOG_DEBUG); - } - return; - } - - $origin = $request->getHeader('Origin'); - $response->setHeader('Vary', 'Origin', false); - if ($origin === '') { - if ($this->debug) { - $this->systemLogger->log(' Actual request no headers added: missing origin', LOG_DEBUG); - } - return; - } - - if (!$this->isOriginAllowed($origin)) { - if ($this->debug) { - $this->systemLogger->log(sprintf(' Actual request no headers added: origin "%s" not allowed', $origin), LOG_DEBUG); - } - return; - } - - // Note that spec does define a way to specifically disallow a simple method like GET or - // POST. Access-Control-Allow-Methods is only used for pre-flight requests and the - // spec doesn't instruct to check the allowed methods for simple cross-origin requests. - // We think it's a nice feature to be able to have control on those methods though. - if (!$this->isMethodAllowed($method)) { - if ($this->debug) { - $this->systemLogger->log(sprintf(' Actual request no headers added: method "%s" not allowed', $method), LOG_DEBUG); - } - return; - } - - if ($this->allowedOriginsAll && !$this->allowCredentials) { - $response->setHeader('Access-Control-Allow-Origin', '*'); - } else { - $response->setHeader('Access-Control-Allow-Origin', $origin); - } - - if ($this->exposedHeaders !== []) { - $response->setHeader('Access-Control-Expose-Headers', implode(', ', $this->exposedHeaders)); - } - - if ($this->allowCredentials) { - $response->setHeader('Access-Control-Allow-Credentials', 'true'); - } - - if ($this->debug) { - $this->systemLogger->log(' Actual response added headers', LOG_DEBUG, [ - 'headers' => $response->getHeaders()->getAll() - ]); - } - } - - /** - * @param string $origin - * @return bool - */ - protected function isOriginAllowed($origin) - { - if ($this->allowedOriginsAll) { - return true; - } - $origin = strtolower($origin); - foreach ($this->allowedPlainOrigins as $o) { - if ($origin === $o) { - return true; - } - } - foreach ($this->allowedWildcardOrigins as $w) { - // TODO Test!!! - $matches = strlen($origin) >= strlen($w[0]) + strlen($w[1]) && strpos($origin, $w[0]) === 0 && strpos($origin, $w[1]) === strlen($origin) - strlen($w[1]); - if ($matches) { - return true; - } - } - return false; - } - - /** - * @param string $method - * @return bool - */ - protected function isMethodAllowed($method) - { - if ($this->allowedMethods === []) { - // If no method allowed, always return false, even for preflight request - return false; - } - $method = strtoupper($method); - if ($method === 'OPTIONS') { - // Always allow preflight requests - return true; - } - foreach ($this->allowedMethods as $m) { - if ($method === $m) { - return true; - } - } - return false; - } - - /** - * Tokenize + normalize a string containing a list of headers - * - * @param string $headerList - * @return string[] - */ - protected function parseHeaderList($headerList) - { - $headerList = strtolower($headerList); - return Arrays::trimExplode(',', $headerList, true); - } - - /** - * @param string[] $headers - * @return bool - */ - protected function areHeadersAllowed(array $headers) - { - if ($this->allowedHeadersAll || $this->allowedHeaders === []) { - return true; - } - foreach ($headers as $header) { - if (!in_array($header, $this->allowedHeaders, true)) { - return false; - } - } - return true; - } - -} \ No newline at end of file diff --git a/Classes/Http/CorsHeaderMiddleware.php b/Classes/Http/CorsHeaderMiddleware.php new file mode 100644 index 0000000..81467bb --- /dev/null +++ b/Classes/Http/CorsHeaderMiddleware.php @@ -0,0 +1,322 @@ +enabled) { + return $handler->handle($request); + } + + $this->initializeConfiguration(); + + $response = $handler->handle($request); + $method = $request->getMethod(); + + // method type is not options, return early + if ($method == 'OPTIONS') { + $this->logger->debug('CORS Component: Preflight request'); + return $this->handlePreflight($request, $response); + } + return $this->handleRequest($request, $response); + } + + private function initializeConfiguration(): void + { + foreach ($this->allowedOrigins as $origin) { + $origin = strtolower($origin); + if ($origin === '*') { + $this->allowedOriginsAll = true; + break; + } elseif (($i = strpos($origin, '*')) !== false) { + $this->allowedWildcardOrigins[] = [substr($origin, 0, $i), substr($origin, $i + 1)]; + } else { + $this->allowedPlainOrigins[] = $origin; + } + } + + // Origin is always appended as some browsers will always request for this header at preflight + if (!in_array(Header::ORIGIN, $this->allowedHeaders, true)) { + $this->allowedHeaders[] = Header::ORIGIN; + } + + foreach ($this->allowedHeaders as $headerKey) { + if ($headerKey === '*') { + $this->allowedHeadersAll = true; + break; + } + } + + foreach ($this->exposedHeaders as &$exposedHeader) { + $exposedHeader = strtolower($exposedHeader); + } + + foreach ($this->allowedHeaders as &$allowedHeader) { + $allowedHeader = strtolower($allowedHeader); + } + + foreach ($this->allowedMethods as &$method) { + $method = strtoupper($method); + } + + $this->logger->debug('CORS Component: Init', ['allowedHeaders' => $this->allowedHeaders]); + } + + private function handlePreflight(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $headersToAdd = []; + /** + * Always set Vary headers, see + * https://github.com/rs/cors/issues/10 and + * https://github.com/rs/cors/commit/dbdca4d95feaa7511a46e6f1efb3b3aa505bc43f#commitcomment-12352001 + */ + $response = $response->withHeader( + 'Vary', [Header::ORIGIN, Header::ACCESS_CONTROL_REQUEST_METHOD, Header::ACCESS_CONTROL_REQUEST_HEADERS] + ); + + $origin = $request->getHeader(Header::ORIGIN)[0] ?? ''; + + if ($origin === '') { + $this->logger->debug('Preflight aborted: empty Origin header.'); + + return $response; + } + + if (!$this->isOriginAllowed($origin)) { + + $this->logger->debug("Preflight aborted: origin $origin not allowed"); + + return $response; + } + + $requestMethod = $request->getHeader('Access-Control-Request-Method')[0] ?? ''; + if (!$this->isMethodAllowed($requestMethod)) { + + $this->logger->debug("Preflight aborted: method $requestMethod not allowed"); + + return $response; + } + + $headerList = $request->getHeader('Access-Control-Request-Headers'); + if (!$this->areHeadersAllowed($headerList)) { + + $headerList = implode(', ', $headerList); + $this->logger->debug("Preflight aborted: headers $headerList not allowed."); + + return $response; + } + + if ($this->allowedOriginsAll && !$this->allowCredentials) { + $headersToAdd[Header::ACCESS_CONTROL_ALLOW_ORIGIN] = '*'; + } else { + $headersToAdd[Header::ACCESS_CONTROL_ALLOW_ORIGIN] = $origin; + } + + // Spec says: Since the list of methods can be unbounded, simply returning the method indicated + // by Access-Control-Request-Method (if supported) can be enough + + $headersToAdd[Header::ACCESS_CONTROL_ALLOW_METHODS] = strtoupper($requestMethod); + + if ($headerList !== []) { + // Spec says: Since the list of headers can be unbounded, simply returning supported headers + // from Access-Control-Request-Headers can be enough + $headersToAdd[Header::ACCESS_CONTROL_ALLOW_HEADERS] = implode(', ', $headerList); + } + + if ($this->allowCredentials) { + $headersToAdd[Header::ACCESS_CONTROL_ALLOW_CREDENTIALS] = 'true'; + } + + if ($this->maxAge > 0) { + $headersToAdd[Header::ACCESS_CONTROL_MAX_AGE] = (string)$this->maxAge; + } + + $this->logger->debug('Preflight response headers', ['headers' => $response->getHeaders(),]); + + foreach ($headersToAdd as $header => $value) { + $response = $response->withHeader($header, $value); + } + + return $response; + } + + private function handleRequest(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $method = $request->getMethod(); + + $headersToAdd = []; + + if ($method === 'OPTIONS') { + + $this->logger->debug('Actual request no headers added: method == OPTIONS'); + + return $response; + } + + $origin = $request->getHeader(Header::ORIGIN)[0] ?? ''; + $headersToAdd['Vary'] = Header::ORIGIN; + if ($origin === '') { + + $this->logger->debug('Actual request no headers added: missing origin'); + + return $response; + } + + if (!$this->isOriginAllowed($origin)) { + + $this->logger->debug("Actual request no headers added: origin $origin not allowed"); + + return $response; + } + + // Note that spec does define a way to specifically disallow a simple method like GET or + // POST. Access-Control-Allow-Methods is only used for pre-flight requests and the + // spec doesn't instruct to check the allowed methods for simple cross-origin requests. + // We think it's a nice feature to be able to have control on those methods though. + if (!$this->isMethodAllowed($method)) { + + $this->logger->debug("Actual request no headers added: method $method not allowed"); + + return $response; + } + + if ($this->allowedOriginsAll && !$this->allowCredentials) { + $headersToAdd[Header::ACCESS_CONTROL_ALLOW_ORIGIN] = '*'; + } else { + $headersToAdd[Header::ACCESS_CONTROL_ALLOW_ORIGIN] = $origin; + } + + if ($this->exposedHeaders !== []) { + $headersToAdd['Access-Control-Expose-Headers'] = implode(', ', $this->exposedHeaders); + } + + $headersToAdd[Header::ACCESS_CONTROL_ALLOW_CREDENTIALS] = 'true'; + + $this->logger->debug('Actual response added headers', ['headers' => $response->getHeaders()]); + foreach ($headersToAdd as $header => $value) { + $response = $response->withHeader($header, $value); + } + + return $response; + } + + private function isOriginAllowed(string $origin): bool + { + if ($this->allowedOriginsAll) { + return true; + } + $origin = strtolower($origin); + if (in_array($origin, $this->allowedPlainOrigins, true)) { + return true; + } + foreach ($this->allowedWildcardOrigins as $allowedWildCardOrigin) { + $matches = strlen($origin) >= strlen($allowedWildCardOrigin[0]) + strlen( + $allowedWildCardOrigin[1] + ) && str_starts_with( + $origin, + $allowedWildCardOrigin[0] + ) && strpos($origin, $allowedWildCardOrigin[1]) === strlen($origin) - strlen($allowedWildCardOrigin[1]); + if ($matches) { + return true; + } + } + + return false; + } + + private function isMethodAllowed(string $method): bool + { + if ($this->allowedMethods === []) { + // If no method allowed, always return false, even for preflight request + return false; + } + $method = strtoupper($method); + if ($method === 'OPTIONS') { + // Always allow preflight requests + return true; + } + + return in_array($method, $this->allowedMethods, true); + } + + /** + * @param string[] $headers + */ + private function areHeadersAllowed(array $headers): bool + { + if ($this->allowedHeadersAll || $this->allowedHeaders === []) { + return true; + } + foreach ($headers as $header) { + if (!in_array($header, $this->allowedHeaders, true)) { + return false; + } + } + + return true; + } +} diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 4737975..c718cc8 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -36,23 +36,14 @@ Flowpack: # maxAge: 0 - # Instructs preflight to let other potential next components to process the OPTIONS method. Turn this on if your application handles OPTIONS. - # - optionsPassthrough: false - - # Debugging flag adds additional logging to System.log to debug server-side CORS issues. - # - debug: false - Neos: + Neos: + fusion: + autoInclude: + Flowpack.Cors: true Flow: - http: - chain: - - 'preprocess': - chain: - - 'flowpackCors': - position: 'end' - component: 'Flowpack\Cors\Http\Component\CorsComponent' + middlewares: + 'cors': + position: 'after routing' + middleware: 'Flowpack\Cors\Http\CorsHeaderMiddleware' diff --git a/README.md b/README.md index bae58b8..d9fc459 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,28 @@ # Flowpack.Cors -Fully featured CORS HTTP component (a.k.a. middleware) for Flow framework to allow "cross-domain" requests. - - -``` -⚡️ Warning - -This package is not working with version 7.0 and higher of the Neos/Flow framework. -In version 7.0 we introduced PSR-15 Middlewares and it is possible to use other PHP libraries instead. - -For instance https://github.com/tuupola/cors-middleware -``` + +* [Flowpack.Cors](#flowpackcors) + * [Introduction](#introduction) + * [Background](#background) + * [Installation](#installation) + * [Configuration](#configuration) + * [Enable CORS in Production:](#enable-cors-in-production) + * [Add additional allowed headers (e.g. `Authorization`):](#add-additional-allowed-headers-eg-authorization) + * [Configuration reference](#configuration-reference) + + +## Introduction +Fully featured CORS HTTP component (a.k.a. middleware) for Flow framework to allow "cross-domain" requests. ## Background -This package is a implementation of a CORS middleware for Cross-Origin Resource Sharing (see https://developer.mozilla.org/en-US/docs/Glossary/CORS). +This package is a implementation of a CORS middleware for Cross-Origin Resource Sharing ( +see https://developer.mozilla.org/en-US/docs/Glossary/CORS). This enables the client (browser) of a webapp to perform "cross-domain" requests. -The work is partially based on the awesome [github.com/rs/cors](https://github.com/rs/cors) HTTP middleware for the Go programming language. +The work is partially based on the awesome [github.com/rs/cors](https://github.com/rs/cors) HTTP middleware for the Go +programming language. ## Installation @@ -28,12 +32,13 @@ composer require flowpack/cors (Refer to the [composer documentation](https://getcomposer.org/doc/) for more details) - -The default settings enables CORS for all origins (`*`) in the Flow Development context. This is usually not what you want in a production environment. +The default settings enables CORS for all origins (`*`) in the Flow Development context. This is usually not what you +want in a production environment. ## Configuration -In your package or global `Settings.yaml` (see [Flow framework Configuration](http://flowframework.readthedocs.io/en/stable/TheDefinitiveGuide/PartIII/Configuration.html)). +In your package or global `Settings.yaml` ( +see [Flow framework Configuration](http://flowframework.readthedocs.io/en/stable/TheDefinitiveGuide/PartIII/Configuration.html)). ### Enable CORS in Production: @@ -47,7 +52,7 @@ Flowpack: - 'trusted-domain.tld' ``` -### Add additional allowed headers (e.g. `Authorization`): +### Add additional allowed headers (e.g. `Authorization`): ``` Flowpack: @@ -62,8 +67,8 @@ Flowpack: - 'Authorization' ``` -Note: Make sure to set _all_ array values including the defaults (if you want to keep them) in the configuration because the Flow configuration is merged with numeric keys which can lead to unwanted effects. - +Note: Make sure to set _all_ array values including the defaults (if you want to keep them) in the configuration because +the Flow configuration is merged with numeric keys which can lead to unwanted effects. ### Configuration reference @@ -105,12 +110,4 @@ Flowpack: # Indicates how long (in seconds) the results of a preflight request can be cached. The default is 0 which stands for no max age. # maxAge: 0 - - # Instructs preflight to let other potential next components to process the OPTIONS method. Turn this on if your application handles OPTIONS. - # - optionsPassthrough: false - - # Debugging flag adds additional logging to System.log to debug server-side CORS issues. - # - debug: false ``` diff --git a/Tests/Unit/Http/CorsHeaderMiddlewareTest.php b/Tests/Unit/Http/CorsHeaderMiddlewareTest.php new file mode 100644 index 0000000..8aa7a45 --- /dev/null +++ b/Tests/Unit/Http/CorsHeaderMiddlewareTest.php @@ -0,0 +1,254 @@ +middleware = new CorsHeaderMiddleware(); + $this->requestMock = $this->createMock(ServerRequestInterface::class); + $this->responseMock = $this->createMock(ResponseInterface::class); + $this->handlerMock = $this->createMock(RequestHandlerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + ObjectAccess::setProperty($this->middleware, 'enabled', true, true); + ObjectAccess::setProperty($this->middleware, 'logger', $this->logger, true); + + $this->handlerMock->expects($this->once())->method('handle')->willReturn($this->responseMock); + } + + public function testMiddlewareIsNotEnabled(): void + { + ObjectAccess::setProperty($this->middleware, 'enabled', false, true); + + $this->responseMock->expects($this->never())->method('withHeader'); + + $this->middleware->process($this->requestMock, $this->handlerMock); + } + + public function testMiddlewarePreflightWithConfig(): void + { + $this->injectConfiguration(); + $this->requestMock->expects($this->once())->method('getMethod')->willReturn('OPTIONS'); + $this->requestMock->expects($this->atLeastOnce())->method('getHeader')->willReturnCallback(function (string $value) { + return match ($value) { + Header::ORIGIN => ['https://google.com'], + Header::ACCESS_CONTROL_REQUEST_METHOD => ['GET'], + Header::ACCESS_CONTROL_REQUEST_HEADERS => [], + default => throw new UnexpectedValueException(), + }; + }); + + $this->responseMock->expects($this->exactly(5))->method('withHeader')->willReturnSelf(); + + $this->middleware->process($this->requestMock, $this->handlerMock); + } + + public function testMiddlewarePreflightWithWildcardConfig(): void + { + $this->injectWildCardConfiguration(); + $this->requestMock->expects($this->atLeastOnce())->method('getMethod')->willReturn('OPTIONS'); + $this->requestMock->expects($this->atLeastOnce())->method('getHeader')->willReturnCallback(function (string $value) { + return match ($value) { + Header::ORIGIN => ['https://google.com'], + Header::ACCESS_CONTROL_REQUEST_METHOD => ['GET'], + Header::ACCESS_CONTROL_REQUEST_HEADERS => [], + default => throw new UnexpectedValueException(), + }; + }); + + $this->responseMock->expects($this->exactly(4))->method('withHeader')->willReturnSelf(); + + $this->middleware->process($this->requestMock, $this->handlerMock); + } + + public function testMiddlewareActualRequestWithConfig(): void + { + $this->injectConfiguration(); + $this->requestMock->expects($this->atLeastOnce())->method('getMethod')->willReturn('POST'); + $this->requestMock->expects($this->atLeastOnce())->method('getHeader')->willReturnCallback(function (string $value) { + return match ($value) { + Header::ORIGIN => ['https://google.com'], + Header::ACCESS_CONTROL_REQUEST_HEADERS => [], + default => throw new UnexpectedValueException(), + }; + }); + + $this->responseMock->expects($this->exactly(4))->method('withHeader')->willReturnSelf(); + + $this->middleware->process($this->requestMock, $this->handlerMock); + } + + public function testMiddlewareActualRequestWithWildcardConfig(): void + { + $this->injectWildCardConfiguration(); + $this->requestMock->expects($this->atLeastOnce())->method('getMethod')->willReturn('POST'); + $this->requestMock->expects($this->atLeastOnce())->method('getHeader')->willReturnCallback(function (string $value) { + return match ($value) { + Header::ORIGIN => ['https://google.com'], + Header::ACCESS_CONTROL_REQUEST_HEADERS => [], + default => throw new UnexpectedValueException(), + }; + }); + + $this->responseMock->expects($this->exactly(4))->method('withHeader')->willReturnSelf(); + + $this->middleware->process($this->requestMock, $this->handlerMock); + } + + public function testMiddlewareActualRequestWithWildcardOrigin(): void + { + $this->injectConfiguration(); + ObjectAccess::setProperty( + $this->middleware, + 'allowedOrigins', + [ + 0 => '*.google.com', + ], + true + ); + $this->requestMock->expects($this->atLeastOnce())->method('getMethod')->willReturn('POST'); + $this->requestMock->expects($this->atLeastOnce())->method('getHeader')->willReturnCallback(function (string $value) { + return match ($value) { + Header::ORIGIN => ['https://drive.google.com'], + Header::ACCESS_CONTROL_REQUEST_HEADERS => [], + default => throw new UnexpectedValueException(), + }; + }); + + $this->responseMock->expects($this->exactly(4))->method('withHeader')->willReturnSelf(); + + $this->middleware->process($this->requestMock, $this->handlerMock); + } + + public function testMiddlewareActualRequestWithNotAllowedOrigin(): void + { + $this->injectConfiguration(); + ObjectAccess::setProperty( + $this->middleware, + 'allowedOrigins', + [ + 0 => '*.google.com', + ], + true + ); + $this->requestMock->expects($this->atLeastOnce())->method('getMethod')->willReturn('POST'); + $this->requestMock->expects($this->atLeastOnce())->method('getHeader')->willReturnCallback(function (string $value) { + return match ($value) { + Header::ORIGIN => ['https://test.de'], + Header::ACCESS_CONTROL_REQUEST_HEADERS => [], + default => throw new UnexpectedValueException(), + }; + }); + + $this->responseMock->expects($this->never())->method('withHeader')->willReturnSelf(); + + $this->logger->expects($this->exactly(2))->method('debug'); + + $this->middleware->process($this->requestMock, $this->handlerMock); + } + + private function injectConfiguration(): void + { + ObjectAccess::setProperty( + $this->middleware, + 'allowedOrigins', + [ + 0 => 'https://google.com', + ], + true + ); + ObjectAccess::setProperty( + $this->middleware, + 'allowedMethods', + [ + 0 => 'GET', + 1 => 'POST', + ], + true + ); + ObjectAccess::setProperty( + $this->middleware, + 'exposedHeaders', + ['Custom-Header'], + true + ); + ObjectAccess::setProperty( + $this->middleware, + 'allowCredentials', + true, + true, + ); + ObjectAccess::setProperty( + $this->middleware, + 'maxAge', + 60, + true + ); + } + + private function injectWildCardConfiguration(): void + { + ObjectAccess::setProperty( + $this->middleware, + 'allowedOrigins', + [ + 0 => '*', + ], + true + ); + ObjectAccess::setProperty( + $this->middleware, + 'allowedMethods', + [ + 0 => 'GET', + 1 => 'POST', + ], + true + ); + ObjectAccess::setProperty( + $this->middleware, + 'exposedHeaders', + ['Custom-Header'], + true + ); + ObjectAccess::setProperty( + $this->middleware, + 'allowCredentials', + false, + true, + ); + ObjectAccess::setProperty( + $this->middleware, + 'maxAge', + 60, + true + ); + } +} diff --git a/composer.json b/composer.json index c44e6fc..3012986 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,9 @@ "description": "CORS HTTP component (middleware) for Neos Flow", "license": "LGPL-3.0+", "require": { - "neos/flow": "^4.0 || ^5.0 || ^6.0" + "php": "^8.1", + "neos/flow": "^8.3", + "lmc/http-constants": "^1.2" }, "autoload": { "psr-4": { @@ -15,5 +17,18 @@ "psr-4": { "Flowpack\\Cors\\Tests\\": "Tests" } + }, + "config": { + "allow-plugins": { + "neos/composer-plugin": true, + "phpstan/extension-installer": true + } + }, + "require-dev": { + "phpunit/phpunit": "^11.4", + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-phpunit": "^1.4", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan-strict-rules": "^1.6" } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..2fd4d90 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,6 @@ +parameters: + ignoreErrors: + - + message: "#^Property Flowpack\\\\Cors\\\\Http\\\\CorsHeaderMiddleware\\:\\:\\$allowedWildcardOrigins \\(array\\\\) does not accept array\\\\|string\\>\\.$#" + count: 1 + path: Classes/Http/CorsHeaderMiddleware.php diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..c5e7e0c --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +includes: + - phpstan-baseline.neon +parameters: + level: max + paths: + - Classes + - Tests diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..bca437a --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,26 @@ + + + + + Tests + + + + + + Classes + + +