From 0ec9604753069043cd80b2ba277e8969cebc1f62 Mon Sep 17 00:00:00 2001 From: Evgeniy Zyubin Date: Fri, 12 Nov 2021 19:11:08 +0300 Subject: [PATCH] Add source code and tests, update support files (#1) --- .github/workflows/build.yml | 1 - .github/workflows/mutation.yml | 1 - .github/workflows/static.yml | 1 - CHANGELOG.md | 2 +- README.md | 24 +- composer.json | 34 +- phpunit.xml.dist | 2 +- psalm.xml | 5 +- src/.gitkeep | 0 src/BasicNetworkResolver.php | 179 +++++++ src/Exception/BadUriPrefixException.php | 23 + src/ForceSecureConnection.php | 155 +++++++ src/HttpCache.php | 219 +++++++++ src/IpFilter.php | 67 +++ src/Redirect.php | 79 ++++ src/SubFolder.php | 97 ++++ src/TagRequest.php | 28 ++ src/TrustedHostsNetworkResolver.php | 539 ++++++++++++++++++++++ tests/.gitkeep | 0 tests/BasicNetworkResolverTest.php | 179 +++++++ tests/ForceSecureConnectionTest.php | 224 +++++++++ tests/HttpCacheTest.php | 116 +++++ tests/IpFilterTest.php | 82 ++++ tests/RedirectTest.php | 106 +++++ tests/SubFolderTest.php | 211 +++++++++ tests/TagRequestTest.php | 31 ++ tests/TestAsset/MockRequestHandler.php | 40 ++ tests/TrustedHostsNetworkResolverTest.php | 297 ++++++++++++ 28 files changed, 2711 insertions(+), 31 deletions(-) delete mode 100644 src/.gitkeep create mode 100644 src/BasicNetworkResolver.php create mode 100644 src/Exception/BadUriPrefixException.php create mode 100644 src/ForceSecureConnection.php create mode 100644 src/HttpCache.php create mode 100644 src/IpFilter.php create mode 100644 src/Redirect.php create mode 100644 src/SubFolder.php create mode 100644 src/TagRequest.php create mode 100644 src/TrustedHostsNetworkResolver.php delete mode 100644 tests/.gitkeep create mode 100644 tests/BasicNetworkResolverTest.php create mode 100644 tests/ForceSecureConnectionTest.php create mode 100644 tests/HttpCacheTest.php create mode 100644 tests/IpFilterTest.php create mode 100644 tests/RedirectTest.php create mode 100644 tests/SubFolderTest.php create mode 100644 tests/TagRequestTest.php create mode 100644 tests/TestAsset/MockRequestHandler.php create mode 100644 tests/TrustedHostsNetworkResolverTest.php diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1b93b8b..aa63f0e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,7 +40,6 @@ jobs: - windows-latest php: - - 7.4 - 8.0 steps: diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index bf6b211..f403a41 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -34,7 +34,6 @@ jobs: - ubuntu-latest php: - - 7.4 - 8.0 steps: diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 432d818..caa1a47 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -36,7 +36,6 @@ jobs: - ubuntu-latest php: - - 7.4 - 8.0 steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a8fbfe..62be62f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# _____ Change Log +# Yii Middleware Change Log ## 1.0.0 under development diff --git a/README.md b/README.md index 000fff9..acd7972 100644 --- a/README.md +++ b/README.md @@ -2,31 +2,31 @@ -

Yii _____

+

Yii Middleware


-[![Latest Stable Version](https://poser.pugx.org/yiisoft/_____/v/stable.png)](https://packagist.org/packages/yiisoft/_____) -[![Total Downloads](https://poser.pugx.org/yiisoft/_____/downloads.png)](https://packagist.org/packages/yiisoft/_____) -[![Build status](https://github.com/yiisoft/_____/workflows/build/badge.svg)](https://github.com/yiisoft/_____/actions?query=workflow%3Abuild) -[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/yiisoft/_____/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/yiisoft/_____/?branch=master) -[![Code Coverage](https://scrutinizer-ci.com/g/yiisoft/_____/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/yiisoft/_____/?branch=master) -[![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2F_____%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/_____/master) -[![static analysis](https://github.com/yiisoft/_____/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/_____/actions?query=workflow%3A%22static+analysis%22) -[![type-coverage](https://shepherd.dev/github/yiisoft/_____/coverage.svg)](https://shepherd.dev/github/yiisoft/_____) +[![Latest Stable Version](https://poser.pugx.org/yiisoft/yii-middleware/v/stable.png)](https://packagist.org/packages/yiisoft/yii-middleware) +[![Total Downloads](https://poser.pugx.org/yiisoft/yii-middleware/downloads.png)](https://packagist.org/packages/yiisoft/yii-middleware) +[![Build status](https://github.com/yiisoft/yii-middleware/workflows/build/badge.svg)](https://github.com/yiisoft/yii-middleware/actions?query=workflow%3Abuild) +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/yiisoft/yii-middleware/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/yiisoft/yii-middleware/?branch=master) +[![Code Coverage](https://scrutinizer-ci.com/g/yiisoft/yii-middleware/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/yiisoft/yii-middleware/?branch=master) +[![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Fyii-middleware%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/yii-middleware/master) +[![static analysis](https://github.com/yiisoft/yii-middleware/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/yii-middleware/actions?query=workflow%3A%22static+analysis%22) +[![type-coverage](https://shepherd.dev/github/yiisoft/yii-middleware/coverage.svg)](https://shepherd.dev/github/yiisoft/yii-middleware) The package ... ## Requirements -- PHP 7.4 or higher. +- PHP 8.0 or higher. ## Installation The package could be installed with composer: ```shell -composer require yiisoft/_____ --prefer-dist +composer require yiisoft/yii-middleware --prefer-dist ``` ## General usage @@ -60,7 +60,7 @@ The code is statically analyzed with [Psalm](https://psalm.dev/). To run static ## License -The Yii _____ is free software. It is released under the terms of the BSD License. +The Yii Middleware is free software. It is released under the terms of the BSD License. Please see [`LICENSE`](./LICENSE.md) for more information. Maintained by [Yii Software](https://www.yiiframework.com/). diff --git a/composer.json b/composer.json index 3fe16b4..2789ef4 100644 --- a/composer.json +++ b/composer.json @@ -1,19 +1,21 @@ { - "name": "yiisoft/replace-with-package-name", + "name": "yiisoft/yii-middleware", "type": "library", - "description": "_____", + "description": "Yii middleware", "keywords": [ - "_____" + "yii", + "framework", + "middleware" ], "homepage": "https://www.yiiframework.com/", "license": "BSD-3-Clause", "support": { - "issues": "https://github.com/yiisoft/_____/issues?state=open", + "issues": "https://github.com/yiisoft/yii-middleware/issues?state=open", "forum": "https://www.yiiframework.com/forum/", "wiki": "https://www.yiiframework.com/wiki/", "irc": "irc://irc.freenode.net/yii", "chat": "https://t.me/yii3en", - "source": "https://github.com/yiisoft/_____" + "source": "https://github.com/yiisoft/yii-middleware" }, "funding": [ { @@ -28,22 +30,34 @@ "minimum-stability": "dev", "prefer-stable": true, "require": { - "php": "^7.4|^8.0" + "php": "^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", + "yiisoft/aliases": "^2.0", + "yiisoft/friendly-exception": "^1.0", + "yiisoft/http": "^1.2", + "yiisoft/network-utilities": "^1.0", + "yiisoft/router": "^3.0@dev", + "yiisoft/validator": "^3.0@dev" }, "require-dev": { + "httpsoft/http-message": "^1.0", "phpunit/phpunit": "^9.5", - "roave/infection-static-analysis-plugin": "^1.9", + "roave/infection-static-analysis-plugin": "^1.10", "spatie/phpunit-watcher": "^1.23", - "vimeo/psalm": "^4.9" + "vimeo/psalm": "^4.12", + "yiisoft/router-fastroute": "^3.0@dev" }, "autoload": { "psr-4": { - "Yiisoft\\_____\\": "src" + "Yiisoft\\Yii\\Middleware\\": "src" } }, "autoload-dev": { "psr-4": { - "Yiisoft\\_____\\Tests\\": "tests" + "Yiisoft\\Yii\\Middleware\\Tests\\": "tests" } }, "config": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 976580a..ba0b293 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -18,7 +18,7 @@ - + ./tests diff --git a/psalm.xml b/psalm.xml index b899031..e88a799 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,14 +1,11 @@ - - - diff --git a/src/.gitkeep b/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/BasicNetworkResolver.php b/src/BasicNetworkResolver.php new file mode 100644 index 0000000..c9b9a00 --- /dev/null +++ b/src/BasicNetworkResolver.php @@ -0,0 +1,179 @@ + ['http'], + 'https' => ['https', 'on'], + ]; + + /** + * @psalm-var array + */ + private array $protocolHeaders = []; + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $newScheme = null; + + foreach ($this->protocolHeaders as $header => $data) { + if (!$request->hasHeader($header)) { + continue; + } + + $headerValues = $request->getHeader($header); + + if (is_callable($data)) { + $newScheme = $data($headerValues, $header, $request); + if ($newScheme === null) { + continue; + } + + if (!is_string($newScheme)) { + throw new RuntimeException('The scheme is neither string nor null.'); + } + + if ($newScheme === '') { + throw new RuntimeException('The scheme cannot be an empty string.'); + } + + break; + } + + $headerValue = strtolower($headerValues[0]); + + foreach ($data as $protocol => $acceptedValues) { + if (!in_array($headerValue, $acceptedValues, true)) { + continue; + } + $newScheme = $protocol; + break 2; + } + } + + $uri = $request->getUri(); + + if ($newScheme !== null && $newScheme !== $uri->getScheme()) { + $request = $request->withUri($uri->withScheme($newScheme)); + } + + return $handler->handle($request); + } + + /** + * With added header to check for determining whether the connection is made via HTTP or HTTPS (or any protocol). + * + * The match of header names and values is case-insensitive. + * It's not advisable to put insecure/untrusted headers here. + * + * Accepted types of values: + * - NULL (default): {{DEFAULT_PROTOCOL_AND_ACCEPTABLE_VALUES}} + * - callable: custom function for getting the protocol + * ```php + * ->withProtocolHeader('x-forwarded-proto', function(array $values, string $header, ServerRequestInterface $request) { + * return $values[0] === 'https' ? 'https' : 'http'; + * return null; // If it doesn't make sense. + * }); + * ``` + * - array: The array keys are protocol string and the array value is a list of header values that indicate the protocol. + * ```php + * ->withProtocolHeader('x-forwarded-proto', [ + * 'http' => ['http'], + * 'https' => ['https'] + * ]); + * ``` + * + * @param string $header + * @param array|callable|null $values + * + * @return self + * + * @see DEFAULT_PROTOCOL_AND_ACCEPTABLE_VALUES + */ + public function withAddedProtocolHeader(string $header, $values = null): self + { + $new = clone $this; + $header = strtolower($header); + + if ($values === null) { + $new->protocolHeaders[$header] = self::DEFAULT_PROTOCOL_AND_ACCEPTABLE_VALUES; + return $new; + } + + if (is_callable($values)) { + $new->protocolHeaders[$header] = $values; + return $new; + } + + if (!is_array($values)) { + throw new RuntimeException('Accepted values is not array nor callable.'); + } + + if (count($values) === 0) { + throw new RuntimeException('Accepted values cannot be an empty array.'); + } + + $new->protocolHeaders[$header] = []; + + foreach ($values as $protocol => $acceptedValues) { + if (!is_string($protocol)) { + throw new RuntimeException('The protocol must be type of string.'); + } + + if ($protocol === '') { + throw new RuntimeException('The protocol cannot be an empty string'); + } + + $new->protocolHeaders[$header][$protocol] = array_map('strtolower', (array) $acceptedValues); + } + + return $new; + } + + public function withoutProtocolHeader(string $header): self + { + $new = clone $this; + unset($new->protocolHeaders[strtolower($header)]); + return $new; + } + + public function withoutProtocolHeaders(?array $headers = null): self + { + $new = clone $this; + + if ($headers === null) { + $new->protocolHeaders = []; + return $new; + } + + foreach ($headers as $header) { + $new = $new->withoutProtocolHeader($header); + } + + return $new; + } +} diff --git a/src/Exception/BadUriPrefixException.php b/src/Exception/BadUriPrefixException.php new file mode 100644 index 0000000..d1c4bd7 --- /dev/null +++ b/src/Exception/BadUriPrefixException.php @@ -0,0 +1,23 @@ +responseFactory = $responseFactory; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($this->redirect && strcasecmp($request->getUri()->getScheme(), 'http') === 0) { + $url = (string) $request->getUri()->withScheme('https')->withPort($this->port); + + return $this->addHSTS( + $this->responseFactory + ->createResponse($this->statusCode) + ->withHeader(Header::LOCATION, $url) + ); + } + + return $this->addHSTS($this->addCSP($handler->handle($request))); + } + + /** + * Redirects from HTTP to HTTPS + * + * @param int $statusCode + * @param int|null $port + * + * @return self + */ + public function withRedirection(int $statusCode = Status::MOVED_PERMANENTLY, int $port = null): self + { + $new = clone $this; + $new->redirect = true; + $new->port = $port; + $new->statusCode = $statusCode; + return $new; + } + + public function withoutRedirection(): self + { + $new = clone $this; + $new->redirect = false; + return $new; + } + + /** + * Add Content-Security-Policy header to response. + * + * @see Header::CONTENT_SECURITY_POLICY + * + * @param string $directives + * + * @return self + */ + public function withCSP(string $directives = self::DEFAULT_CSP_DIRECTIVES): self + { + $new = clone $this; + $new->addCSP = true; + $new->cspDirectives = $directives; + return $new; + } + + public function withoutCSP(): self + { + $new = clone $this; + $new->addCSP = false; + return $new; + } + + /** + * Add Strict-Transport-Security header to each response. + * + * @see Header::STRICT_TRANSPORT_SECURITY + * + * @param int $maxAge + * @param bool $subDomains + * + * @return self + */ + public function withHSTS(int $maxAge = self::DEFAULT_HSTS_MAX_AGE, bool $subDomains = false): self + { + $new = clone $this; + $new->addHSTS = true; + $new->hstsMaxAge = $maxAge; + $new->hstsSubDomains = $subDomains; + return $new; + } + + public function withoutHSTS(): self + { + $new = clone $this; + $new->addHSTS = false; + return $new; + } + + private function addCSP(ResponseInterface $response): ResponseInterface + { + return $this->addCSP + ? $response->withHeader(Header::CONTENT_SECURITY_POLICY, $this->cspDirectives) + : $response; + } + + private function addHSTS(ResponseInterface $response): ResponseInterface + { + $subDomains = $this->hstsSubDomains ? '; includeSubDomains' : ''; + return $this->addHSTS + ? $response->withHeader(Header::STRICT_TRANSPORT_SECURITY, "max-age={$this->hstsMaxAge}{$subDomains}") + : $response; + } +} diff --git a/src/HttpCache.php b/src/HttpCache.php new file mode 100644 index 0000000..b7cd8f0 --- /dev/null +++ b/src/HttpCache.php @@ -0,0 +1,219 @@ +lastModified === null && $this->etagSeed === null) || + !in_array($request->getMethod(), [Method::GET, Method::HEAD], true) + ) { + return $handler->handle($request); + } + + $lastModified = null; + if ($this->lastModified !== null) { + $lastModified = ($this->lastModified)($request, $this->params); + } + + $etag = null; + if ($this->etagSeed !== null) { + $seed = ($this->etagSeed)($request, $this->params); + + if ($seed !== null) { + $etag = $this->generateEtag($seed); + } + } + + $cacheIsValid = $this->validateCache($request, $lastModified, $etag); + $response = $handler->handle($request); + + if ($cacheIsValid) { + $response = $response->withStatus(Status::NOT_MODIFIED); + } + + if ($this->cacheControlHeader !== null) { + $response = $response->withHeader(Header::CACHE_CONTROL, $this->cacheControlHeader); + } + if ($etag !== null) { + $response = $response->withHeader(Header::ETAG, $etag); + } + + // https://tools.ietf.org/html/rfc7232#section-4.1 + if ($lastModified !== null && (!$cacheIsValid || $etag === null)) { + $response = $response->withHeader( + Header::LAST_MODIFIED, + gmdate('D, d M Y H:i:s', $lastModified) . ' GMT', + ); + } + + return $response; + } + + /** + * Validates if the HTTP cache contains valid content. If both Last-Modified and ETag are null, returns false. + * + * @param ServerRequestInterface $request + * @param int|null $lastModified The calculated Last-Modified value in terms of a UNIX timestamp. + * If null, the Last-Modified header will not be validated. + * @param string|null $etag The calculated ETag value. If null, the ETag header will not be validated. + * + * @return bool Whether the HTTP cache is still valid. + */ + private function validateCache(ServerRequestInterface $request, ?int $lastModified, ?string $etag): bool + { + if ($request->hasHeader(Header::IF_NONE_MATCH)) { + // HTTP_IF_NONE_MATCH takes precedence over HTTP_IF_MODIFIED_SINCE + // http://tools.ietf.org/html/rfc7232#section-3.3 + return $etag !== null && in_array($etag, $this->getETags($request), true); + } + + if ($request->hasHeader(Header::IF_MODIFIED_SINCE)) { + $header = $request->getHeaderLine(Header::IF_MODIFIED_SINCE); + return $lastModified !== null && @strtotime($header) >= $lastModified; + } + + return false; + } + + /** + * Generates an ETag from the given seed string. + * + * @param string $seed Seed for the ETag + * + * @return string the generated ETag + */ + private function generateEtag(string $seed): string + { + $etag = '"' . rtrim(base64_encode(sha1($seed, true)), '=') . '"'; + return $this->weakEtag ? 'W/' . $etag : $etag; + } + + /** + * Gets the Etags. + * + * @param ServerRequestInterface $request + * + * @return array The entity tags + */ + private function getETags(ServerRequestInterface $request): array + { + if ($request->hasHeader(Header::IF_NONE_MATCH)) { + $header = $request->getHeaderLine(Header::IF_NONE_MATCH); + $header = str_replace('-gzip', '', $header); + return preg_split('/[\s,]+/', $header, -1, PREG_SPLIT_NO_EMPTY) ?: []; + } + + return []; + } + + public function withLastModified(callable $lastModified): self + { + $new = clone $this; + $new->lastModified = $lastModified; + return $new; + } + + public function withEtagSeed(callable $etagSeed): self + { + $new = clone $this; + $new->etagSeed = $etagSeed; + return $new; + } + + public function withWeakTag(bool $weakTag): self + { + $new = clone $this; + $new->weakEtag = $weakTag; + return $new; + } + + public function withParams(mixed $params): self + { + $new = clone $this; + $new->params = $params; + return $new; + } + + public function withCacheControlHeader(?string $header): self + { + $new = clone $this; + $new->cacheControlHeader = $header; + return $new; + } +} diff --git a/src/IpFilter.php b/src/IpFilter.php new file mode 100644 index 0000000..9ff6c44 --- /dev/null +++ b/src/IpFilter.php @@ -0,0 +1,67 @@ +ipValidator = $ipValidator; + $this->responseFactory = $responseFactory; + $this->clientIpAttribute = $clientIpAttribute; + } + + public function withIpValidator(Ip $ipValidator): self + { + $new = clone $this; + $new->ipValidator = $ipValidator; + return $new; + } + + /** + * Process an incoming server request. + * + * Processes an incoming server request in order to produce a response. + * If unable to produce the response itself, it may delegate to the provided + * request handler to do so. + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $clientIp = $request->getServerParams()['REMOTE_ADDR'] ?? null; + + if ($this->clientIpAttribute !== null) { + $clientIp = $request->getAttribute($clientIp); + } + + if ($clientIp === null || !$this->ipValidator->disallowNegation()->disallowSubnet()->validate($clientIp)->isValid()) { + $response = $this->responseFactory->createResponse(Status::FORBIDDEN); + $response->getBody()->write(Status::TEXTS[Status::FORBIDDEN]); + return $response; + } + + return $handler->handle($request); + } +} diff --git a/src/Redirect.php b/src/Redirect.php new file mode 100644 index 0000000..8e41780 --- /dev/null +++ b/src/Redirect.php @@ -0,0 +1,79 @@ +responseFactory = $responseFactory; + $this->urlGenerator = $urlGenerator; + } + + public function toUrl(string $url): self + { + $new = clone $this; + $new->uri = $url; + return $new; + } + + public function toRoute(string $name, array $parameters = []): self + { + $new = clone $this; + $new->route = $name; + $new->parameters = $parameters; + return $new; + } + + public function withStatus(int $code): self + { + $new = clone $this; + $new->statusCode = $code; + return $new; + } + + public function permanent(): self + { + $new = clone $this; + $new->statusCode = Status::MOVED_PERMANENTLY; + return $new; + } + + public function temporary(): self + { + $new = clone $this; + $new->statusCode = Status::SEE_OTHER; + return $new; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($this->route === null && $this->uri === null) { + throw new InvalidArgumentException('Either toUrl() or toRoute() should be used.'); + } + + $uri = $this->uri ?? $this->urlGenerator->generate($this->route, $this->parameters); + + return $this->responseFactory + ->createResponse($this->statusCode) + ->withAddedHeader('Location', $uri); + } +} diff --git a/src/SubFolder.php b/src/SubFolder.php new file mode 100644 index 0000000..f01760c --- /dev/null +++ b/src/SubFolder.php @@ -0,0 +1,97 @@ +uriGenerator = $uriGenerator; + $this->aliases = $aliases; + $this->prefix = $prefix; + $this->alias = $alias; + } + + /** + * {@inheritDoc} + * + * @throws BadUriPrefixException + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $uri = $request->getUri(); + $path = $uri->getPath(); + $prefix = $this->prefix; + $auto = $prefix === null; + $length = $auto ? 0 : strlen($prefix); + + if ($auto) { + // automatically checks that the project is in a subfolder + // and URI contains a prefix + $scriptName = $request->getServerParams()['SCRIPT_NAME']; + if (strpos($scriptName, '/', 1) !== false) { + $tmpPrefix = substr($scriptName, 0, strrpos($scriptName, '/')); + if (strpos($path, $tmpPrefix) === 0) { + $prefix = $tmpPrefix; + $length = strlen($prefix); + } + } + } elseif ($length > 0) { + if ($prefix[-1] === '/') { + throw new BadUriPrefixException('Wrong URI prefix value'); + } + + if (strpos($path, $prefix) !== 0) { + throw new BadUriPrefixException('URI prefix does not match'); + } + } + + if ($length > 0) { + $newPath = substr($path, $length); + if ($newPath === '') { + $newPath = '/'; + } + + if ($newPath[0] !== '/') { + if (!$auto) { + throw new BadUriPrefixException('URI prefix does not match completely'); + } + } else { + $request = $request->withUri($uri->withPath($newPath)); + $this->uriGenerator->setUriPrefix($prefix); + + if ($this->alias !== null) { + $this->aliases->set($this->alias, $prefix . '/'); + } + } + } + + return $handler->handle($request); + } +} diff --git a/src/TagRequest.php b/src/TagRequest.php new file mode 100644 index 0000000..aae9d67 --- /dev/null +++ b/src/TagRequest.php @@ -0,0 +1,28 @@ +handle($request->withAttribute('requestTag', $this->getRequestTag())); + } + + private function getRequestTag(): string + { + return uniqid('', true); + } +} diff --git a/src/TrustedHostsNetworkResolver.php b/src/TrustedHostsNetworkResolver.php new file mode 100644 index 0000000..fc8f68b --- /dev/null +++ b/src/TrustedHostsNetworkResolver.php @@ -0,0 +1,539 @@ +withAddedTrustedHosts( + * // List of secure hosts including $ _SERVER['REMOTE_ADDR'], can specify IPv4, IPv6, domains and aliases (see {{Ip}}) + * ['1.1.1.1', '2.2.2.1/3', '2001::/32', 'localhost'] + * // IP list headers. For advanced handling headers, see the constants IP_HEADER_TYPE_ *. + * // Headers containing multiple sub-elements (eg RFC 7239) must also be listed for other relevant types + * // (eg. host headers), otherwise they will only be used as an IP list. + * ['x-forwarded-for', [TrustedHostsNetworkResolver::IP_HEADER_TYPE_RFC7239, 'forwarded']] + * // protocol headers with accepted protocols and values. Matching of values ​​is case insensitive. + * ['front-end-https' => ['https' => 'on']], + * // Host headers + * ['forwarded', 'x-forwarded-for'] + * // URL headers + * ['x-rewrite-url'], + * // Trusted headers. It is a good idea to list all relevant headers. + * ['x-forwarded-for', 'forwarded', ...] + * ); + * ->withAddedTrustedHosts(...) + * ; + * ``` + */ +class TrustedHostsNetworkResolver implements MiddlewareInterface +{ + public const IP_HEADER_TYPE_RFC7239 = 'rfc7239'; + + public const DEFAULT_TRUSTED_HEADERS = [ + // common: + 'x-forwarded-for', + 'x-forwarded-host', + 'x-forwarded-proto', + 'x-forwarded-port', + + // RFC: + 'forward', + + // Microsoft: + 'front-end-https', + 'x-rewrite-url', + ]; + + private const DATA_KEY_HOSTS = 'hosts'; + private const DATA_KEY_IP_HEADERS = 'ipHeaders'; + private const DATA_KEY_HOST_HEADERS = 'hostHeaders'; + private const DATA_KEY_URL_HEADERS = 'urlHeaders'; + private const DATA_KEY_PROTOCOL_HEADERS = 'protocolHeaders'; + private const DATA_KEY_TRUSTED_HEADERS = 'trustedHeaders'; + private const DATA_KEY_PORT_HEADERS = 'portHeaders'; + + private array $trustedHosts = []; + + private ?string $attributeIps = null; + + private ?Ip $ipValidator = null; + + public function withIpValidator(Ip $ipValidator): self + { + $new = clone $this; + $new->ipValidator = $ipValidator; + return $new; + } + + /** + * With added trusted hosts and related headers + * + * The header lists are evaluated in the order they were specified. + * If you specify multiple headers by type (eg IP headers), you must ensure that the irrelevant header is removed + * eg. web server application, otherwise spoof clients can be use this vulnerability. + * + * @param string[] $hosts List of trusted hosts IP addresses. If `isValidHost` is extended, then can use + * domain names with reverse DNS resolving eg. yiiframework.com, * .yiiframework.com. + * @param array $ipHeaders List of headers containing IP lists. + * @param array $protocolHeaders List of headers containing protocol. eg. ['x-forwarded-for' => ['http' => 'http', 'https' => ['on', 'https']]] + * @param string[] $hostHeaders List of headers containing HTTP host. + * @param string[] $urlHeaders List of headers containing HTTP URL. + * @param string[] $portHeaders List of headers containing port number. + * @param string[]|null $trustedHeaders List of trusted headers. Removed from the request, if in checking process + * are classified as untrusted by hosts. + * + * @return static + */ + public function withAddedTrustedHosts( + array $hosts, + // Defining default headers is not secure! + array $ipHeaders = [], + array $protocolHeaders = [], + array $hostHeaders = [], + array $urlHeaders = [], + array $portHeaders = [], + ?array $trustedHeaders = null + ): self { + $new = clone $this; + foreach ($ipHeaders as $ipHeader) { + if (is_string($ipHeader)) { + continue; + } + if (!is_array($ipHeader)) { + throw new InvalidArgumentException('Type of ipHeader is not a string and not array'); + } + if (count($ipHeader) !== 2) { + throw new InvalidArgumentException('The ipHeader array must have exactly 2 elements'); + } + [$type, $header] = $ipHeader; + if (!is_string($type)) { + throw new InvalidArgumentException('The type is not a string'); + } + if (!is_string($header)) { + throw new InvalidArgumentException('The header is not a string'); + } + if ($type === self::IP_HEADER_TYPE_RFC7239) { + continue; + } + + throw new InvalidArgumentException("Not supported IP header type: $type"); + } + if (count($hosts) === 0) { + throw new InvalidArgumentException('Empty hosts not allowed'); + } + $trustedHeaders = $trustedHeaders ?? self::DEFAULT_TRUSTED_HEADERS; + $protocolHeaders = $this->prepareProtocolHeaders($protocolHeaders); + $this->checkTypeStringOrArray($hosts, 'hosts'); + $this->checkTypeStringOrArray($trustedHeaders, 'trustedHeaders'); + $this->checkTypeStringOrArray($hostHeaders, 'hostHeaders'); + $this->checkTypeStringOrArray($urlHeaders, 'urlHeaders'); + $this->checkTypeStringOrArray($portHeaders, 'portHeaders'); + + foreach ($hosts as $host) { + $host = str_replace('*', 'wildcard', $host); // wildcard is allowed in host + if (filter_var($host, FILTER_VALIDATE_DOMAIN) === false) { + throw new InvalidArgumentException("'$host' host is not a domain and not an IP address"); + } + } + $new->trustedHosts[] = [ + self::DATA_KEY_HOSTS => $hosts, + self::DATA_KEY_IP_HEADERS => $ipHeaders, + self::DATA_KEY_PROTOCOL_HEADERS => $protocolHeaders, + self::DATA_KEY_TRUSTED_HEADERS => $trustedHeaders, + self::DATA_KEY_HOST_HEADERS => $hostHeaders, + self::DATA_KEY_URL_HEADERS => $urlHeaders, + self::DATA_KEY_PORT_HEADERS => $portHeaders, + ]; + return $new; + } + + private function checkTypeStringOrArray(array $array, string $field): void + { + foreach ($array as $item) { + if (!is_string($item)) { + throw new InvalidArgumentException("$field must be string type"); + } + if (trim($item) === '') { + throw new InvalidArgumentException("$field cannot be empty strings"); + } + } + } + + public function withoutTrustedHosts(): self + { + $new = clone $this; + $new->trustedHosts = []; + return $new; + } + + /** + * Request's attribute name to which trusted path data is added. + * + * The list starts with the server and the last item is the client itself. + * + * @return static + * + * @see getElementsByRfc7239 + */ + public function withAttributeIps(?string $attribute): self + { + if ($attribute === '') { + throw new RuntimeException('Attribute should not be empty'); + } + $new = clone $this; + $new->attributeIps = $attribute; + return $new; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $actualHost = $request->getServerParams()['REMOTE_ADDR'] ?? null; + if ($actualHost === null) { + // Validation is not possible. + return $this->handleNotTrusted($request, $handler); + } + + $trustedHostData = null; + $trustedHeaders = []; + $ipValidator = ($this->ipValidator ?? Ip::rule())->disallowSubnet()->disallowNegation(); + foreach ($this->trustedHosts as $data) { + // collect all trusted headers + $trustedHeaders = array_merge($trustedHeaders, $data[self::DATA_KEY_TRUSTED_HEADERS]); + if ($trustedHostData !== null) { + // trusted hosts already found + continue; + } + if ($this->isValidHost($actualHost, $data[self::DATA_KEY_HOSTS], $ipValidator)) { + $trustedHostData = $data; + } + } + $untrustedHeaders = array_diff($trustedHeaders, $trustedHostData[self::DATA_KEY_TRUSTED_HEADERS] ?? []); + $request = $this->removeHeaders($request, $untrustedHeaders); + if ($trustedHostData === null) { + // No trusted host at all. + return $this->handleNotTrusted($request, $handler); + } + [$ipListType, $ipHeader, $hostList] = $this->getIpList($request, $trustedHostData[self::DATA_KEY_IP_HEADERS]); + $hostList = array_reverse($hostList); // the first item should be the closest to the server + if ($ipListType === null) { + $hostList = $this->getFormattedIpList($hostList); + } elseif ($ipListType === self::IP_HEADER_TYPE_RFC7239) { + $hostList = $this->getElementsByRfc7239($hostList); + } + array_unshift($hostList, ['ip' => $actualHost]); // server's ip to first position + $hostDataList = []; + do { + $hostData = array_shift($hostList); + if (!isset($hostData['ip'])) { + $hostData = $this->reverseObfuscate($hostData, $hostDataList, $hostList, $request); + if ($hostData === null) { + continue; + } + if (!isset($hostData['ip'])) { + break; + } + } + $ip = $hostData['ip']; + if (!$this->isValidHost($ip, ['any'], $ipValidator)) { + // invalid IP + break; + } + $hostDataList[] = $hostData; + if (!$this->isValidHost($ip, $trustedHostData[self::DATA_KEY_HOSTS], $ipValidator)) { + // not trusted host + break; + } + } while (count($hostList) > 0); + + if ($this->attributeIps !== null) { + $request = $request->withAttribute($this->attributeIps, $hostDataList); + } + + $uri = $request->getUri(); + // find HTTP host + foreach ($trustedHostData[self::DATA_KEY_HOST_HEADERS] as $hostHeader) { + if (!$request->hasHeader($hostHeader)) { + continue; + } + if ($hostHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($hostData['httpHost'])) { + $uri = $uri->withHost($hostData['httpHost']); + break; + } + $host = $request->getHeaderLine($hostHeader); + if (filter_var($host, FILTER_VALIDATE_DOMAIN) !== false) { + $uri = $uri->withHost($host); + break; + } + } + + // find protocol + foreach ($trustedHostData[self::DATA_KEY_PROTOCOL_HEADERS] as $protocolHeader => $protocols) { + if (!$request->hasHeader($protocolHeader)) { + continue; + } + if ($protocolHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($hostData['protocol'])) { + $uri = $uri->withScheme($hostData['protocol']); + break; + } + $protocolHeaderValue = $request->getHeaderLine($protocolHeader); + foreach ($protocols as $protocol => $acceptedValues) { + if (in_array($protocolHeaderValue, $acceptedValues, true)) { + $uri = $uri->withScheme($protocol); + break 2; + } + } + } + $urlParts = $this->getUrl($request, $trustedHostData[self::DATA_KEY_URL_HEADERS]); + if ($urlParts !== null) { + [$path, $query] = $urlParts; + $uri = $uri->withPath($path); + if ($query !== null) { + $uri = $uri->withQuery($query); + } + } + + // find port + foreach ($trustedHostData[self::DATA_KEY_PORT_HEADERS] as $portHeader) { + if (!$request->hasHeader($portHeader)) { + continue; + } + if ($portHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($hostData['port']) && $this->checkPort((string)$hostData['port'])) { + $uri = $uri->withPort($hostData['port']); + break; + } + $port = $request->getHeaderLine($portHeader); + if ($this->checkPort($port)) { + $uri = $uri->withPort((int)$port); + break; + } + } + + return $handler->handle($request->withUri($uri)->withAttribute('requestClientIp', $hostData['ip'])); + } + + /** + * Validate host by range + * + * This method can be extendable by overwriting eg. with reverse DNS verification. + */ + protected function isValidHost(string $host, array $ranges, Ip $validator): bool + { + return $validator->ranges($ranges)->validate($host)->isValid(); + } + + /** + * Reverse obfuscating host data + * + * RFC 7239 allows to use obfuscated host data. In this case, either specifying the + * IP address or dropping the proxy endpoint is required to determine validated route. + * + * By default it does not perform any transformation on the data. You can override this method. + * + * @param array $hostData + * @param array $hostDataListValidated + * @param array $hostDataListRemaining + * @param RequestInterface $request + * + * @return array|null reverse obfuscated host data or null. + * In case of null data is discarded and the process continues with the next portion of host data. + * If the return value is an array, it must contain at least the `ip` key. + * + * @see getElementsByRfc7239 + * @link https://tools.ietf.org/html/rfc7239#section-6.2 + * @link https://tools.ietf.org/html/rfc7239#section-6.3 + */ + protected function reverseObfuscate( + array $hostData, + array $hostDataListValidated, + array $hostDataListRemaining, + RequestInterface $request + ): ?array { + return $hostData; + } + + private function handleNotTrusted(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($this->attributeIps !== null) { + $request = $request->withAttribute($this->attributeIps, null); + } + return $handler->handle($request->withAttribute('requestClientIp', null)); + } + + private function prepareProtocolHeaders(array $protocolHeaders): array + { + $output = []; + foreach ($protocolHeaders as $header => $protocolAndAcceptedValues) { + $header = strtolower($header); + if (is_callable($protocolAndAcceptedValues)) { + $output[$header] = $protocolAndAcceptedValues; + continue; + } + if (!is_array($protocolAndAcceptedValues)) { + throw new RuntimeException('Accepted values is not an array nor callable'); + } + if (count($protocolAndAcceptedValues) === 0) { + throw new RuntimeException('Accepted values cannot be an empty array'); + } + $output[$header] = []; + foreach ($protocolAndAcceptedValues as $protocol => $acceptedValues) { + if (!is_string($protocol)) { + throw new RuntimeException('The protocol must be a string'); + } + if ($protocol === '') { + throw new RuntimeException('The protocol cannot be empty'); + } + $output[$header][$protocol] = array_map('strtolower', (array)$acceptedValues); + } + } + return $output; + } + + private function removeHeaders(ServerRequestInterface $request, array $headers): ServerRequestInterface + { + foreach ($headers as $header) { + $request = $request->withoutAttribute($header); + } + return $request; + } + + private function getIpList(RequestInterface $request, array $ipHeaders): array + { + foreach ($ipHeaders as $ipHeader) { + $type = null; + if (is_array($ipHeader)) { + $type = array_shift($ipHeader); + $ipHeader = array_shift($ipHeader); + } + if ($request->hasHeader($ipHeader)) { + return [$type, $ipHeader, $request->getHeader($ipHeader)]; + } + } + return [null, null, []]; + } + + /** + * @see getElementsByRfc7239 + */ + private function getFormattedIpList(array $forwards): array + { + $list = []; + foreach ($forwards as $ip) { + $list[] = ['ip' => $ip]; + } + return $list; + } + + /** + * Forwarded elements by RFC7239 + * + * The structure of the elements: + * - `host`: IP or obfuscated hostname or "unknown" + * - `ip`: IP address (only if presented) + * - `by`: used user-agent by proxy (only if presented) + * - `port`: port number received by proxy (only if presented) + * - `protocol`: protocol received by proxy (only if presented) + * - `httpHost`: HTTP host received by proxy (only if presented) + * + * @link https://tools.ietf.org/html/rfc7239 + * + * @return array proxy data elements + */ + private function getElementsByRfc7239(array $forwards): array + { + $list = []; + foreach ($forwards as $forward) { + $data = HeaderValueHelper::getParameters($forward); + if (!isset($data['for'])) { + // Invalid item, the following items will be dropped + break; + } + $pattern = '/^(?' . IpHelper::IPV4_PATTERN . '|unknown|_[\w\.-]+|[[]' . IpHelper::IPV6_PATTERN . '[]])(?::(?[\w\.-]+))?$/'; + if (preg_match($pattern, $data['for'], $matches) === 0) { + // Invalid item, the following items will be dropped + break; + } + $ipData = []; + $host = $matches['host']; + $obfuscatedHost = $host === 'unknown' || strpos($host, '_') === 0; + if (!$obfuscatedHost) { + // IPv4 & IPv6 + $ipData['ip'] = strpos($host, '[') === 0 ? trim($host /* IPv6 */, '[]') : $host; + } + $ipData['host'] = $host; + if (isset($matches['port'])) { + $port = $matches['port']; + if (!$obfuscatedHost && !$this->checkPort($port)) { + // Invalid port, the following items will be dropped + break; + } + $ipData['port'] = $obfuscatedHost ? $port : (int)$port; + } + + // copy other properties + foreach (['proto' => 'protocol', 'host' => 'httpHost', 'by' => 'by'] as $source => $destination) { + if (isset($data[$source])) { + $ipData[$destination] = $data[$source]; + } + } + if (isset($ipData['httpHost']) && filter_var($ipData['httpHost'], FILTER_VALIDATE_DOMAIN) === false) { + // remove not valid HTTP host + unset($ipData['httpHost']); + } + + $list[] = $ipData; + } + return $list; + } + + private function getUrl(RequestInterface $request, array $urlHeaders): ?array + { + foreach ($urlHeaders as $header) { + if (!$request->hasHeader($header)) { + continue; + } + $url = $request->getHeaderLine($header); + if (strpos($url, '/') === 0) { + return array_pad(explode('?', $url, 2), 2, null); + } + } + return null; + } + + private function checkPort(string $port): bool + { + return preg_match('/^\d{1,5}$/', $port) === 1 && (int)$port <= 65535; + } +} diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/BasicNetworkResolverTest.php b/tests/BasicNetworkResolverTest.php new file mode 100644 index 0000000..635e915 --- /dev/null +++ b/tests/BasicNetworkResolverTest.php @@ -0,0 +1,179 @@ + ['http', [], null, 'http'], + 'httpsNotModify' => ['https', [], null, 'https'], + 'httpNotMatchedProtocolHeader' => [ + 'http', + ['x-forwarded-proto' => ['https']], + ['test' => ['https' => 'https']], + 'http', + ], + 'httpNotMatchedProtocolHeaderValue' => [ + 'http', + ['x-forwarded-proto' => ['https']], + ['x-forwarded-proto' => ['https' => 'test']], + 'http', + ], + 'httpToHttps' => [ + 'http', + ['x-forwarded-proto' => ['https']], + ['x-forwarded-proto' => ['https' => 'https']], + 'https', + ], + 'httpToHttpsDefault' => [ + 'http', + ['x-forwarded-proto' => ['https']], + ['x-forwarded-proto' => null], + 'https', + ], + 'httpToHttpsUpperCase' => [ + 'http', + ['x-forwarded-proto' => ['https']], + ['x-forwarded-proto' => ['https' => 'HTTPS']], + 'https', + ], + 'httpToHttpsMultiValue' => [ + 'http', + ['x-forwarded-proto' => ['https']], + ['x-forwarded-proto' => ['https' => ['on', 's', 'https']]], + 'https', + ], + 'httpsToHttp' => [ + 'https', + ['x-forwarded-proto' => 'http'], + ['x-forwarded-proto' => ['http' => 'http']], + 'http', + ], + 'httpToHttpsWithCallback' => [ + 'http', + ['x-forwarded-proto' => 'test any-https **'], + [ + 'x-forwarded-proto' => function (array $values, string $header, ServerRequestInterface $request) { + return stripos($values[0], 'https') !== false ? 'https' : 'http'; + }, + ], + 'https', + ], + 'httpWithCallbackNull' => [ + 'http', + ['x-forwarded-proto' => 'test any-https **'], + [ + 'x-forwarded-proto' => function (array $values, string $header, ServerRequestInterface $request) { + return null; + }, + ], + 'http', + ], + ]; + } + + /** + * @dataProvider schemeDataProvider + */ + public function testScheme(string $scheme, array $headers, ?array $protocolHeaders, string $expectedScheme): void + { + $request = $this->createRequestWithSchemaAndHeaders($scheme, $headers); + $requestHandler = new MockRequestHandler(); + + $middleware = new BasicNetworkResolver(); + if ($protocolHeaders !== null) { + foreach ($protocolHeaders as $header => $values) { + $middleware = $middleware->withAddedProtocolHeader($header, $values); + } + } + $middleware->process($request, $requestHandler); + $resultRequest = $requestHandler->processedRequest; + /* @var $resultRequest ServerRequestInterface */ + $this->assertSame($expectedScheme, $resultRequest->getUri()->getScheme()); + } + + public function testWithoutProtocolHeaders(): void + { + $request = $this->createRequestWithSchemaAndHeaders('http', [ + 'x-forwarded-proto' => ['https'], + ]); + $requestHandler = new MockRequestHandler(); + + $middleware = (new BasicNetworkResolver()) + ->withAddedProtocolHeader('x-forwarded-proto') + ->withoutProtocolHeaders(); + $middleware->process($request, $requestHandler); + $resultRequest = $requestHandler->processedRequest; + /* @var $resultRequest ServerRequestInterface */ + $this->assertSame('http', $resultRequest->getUri()->getScheme()); + } + + public function testWithoutProtocolHeadersMulti(): void + { + $request = $this->createRequestWithSchemaAndHeaders('http', [ + 'x-forwarded-proto' => ['https'], + 'x-forwarded-proto-2' => ['https'], + ]); + $requestHandler = new MockRequestHandler(); + + $middleware = (new BasicNetworkResolver()) + ->withAddedProtocolHeader('x-forwarded-proto') + ->withAddedProtocolHeader('x-forwarded-proto-2') + ->withoutProtocolHeaders([ + 'x-forwarded-proto', + 'x-forwarded-proto-2', + ]); + $middleware->process($request, $requestHandler); + $resultRequest = $requestHandler->processedRequest; + /* @var $resultRequest ServerRequestInterface */ + $this->assertSame('http', $resultRequest->getUri()->getScheme()); + } + + public function testWithoutProtocolHeader(): void + { + $request = $this->createRequestWithSchemaAndHeaders('https', [ + 'x-forwarded-proto' => ['https'], + 'x-forwarded-proto-2' => ['http'], + ]); + $requestHandler = new MockRequestHandler(); + + $middleware = (new BasicNetworkResolver()) + ->withAddedProtocolHeader('x-forwarded-proto') + ->withAddedProtocolHeader('x-forwarded-proto-2') + ->withoutProtocolHeader('x-forwarded-proto'); + $middleware->process($request, $requestHandler); + $resultRequest = $requestHandler->processedRequest; + /* @var $resultRequest ServerRequestInterface */ + $this->assertSame('http', $resultRequest->getUri()->getScheme()); + + $middleware = $middleware->withoutProtocolHeader('x-forwarded-proto-2'); + $middleware->process($request, $requestHandler); + $resultRequest = $requestHandler->processedRequest; + /* @var $resultRequest ServerRequestInterface */ + $this->assertSame('https', $resultRequest->getUri()->getScheme()); + } + + private function createRequestWithSchemaAndHeaders( + string $scheme = 'http', + array $headers = [] + ): ServerRequestInterface { + $request = new ServerRequest(); + + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + $uri = $request->getUri()->withScheme($scheme); + return $request->withUri($uri); + } +} diff --git a/tests/ForceSecureConnectionTest.php b/tests/ForceSecureConnectionTest.php new file mode 100644 index 0000000..e7a0487 --- /dev/null +++ b/tests/ForceSecureConnectionTest.php @@ -0,0 +1,224 @@ +withCSP(); + + $this->assertNotSame($middleware, $new); + } + + public function testWithHSTSImmutability(): void + { + $middleware = new ForceSecureConnection(new ResponseFactory()); + $new = $middleware->withHSTS(); + + $this->assertNotSame($middleware, $new); + } + + public function testWithRedirectionImmutability(): void + { + $middleware = new ForceSecureConnection(new ResponseFactory()); + $new = $middleware->withRedirection(); + + $this->assertNotSame($middleware, $new); + } + + public function testWithoutCSPImmutability(): void + { + $middleware = new ForceSecureConnection(new ResponseFactory()); + $new = $middleware->withoutCSP(); + + $this->assertNotSame($middleware, $new); + } + + public function testWithoutHSTSImmutability(): void + { + $middleware = new ForceSecureConnection(new ResponseFactory()); + $new = $middleware->withoutHSTS(); + + $this->assertNotSame($middleware, $new); + } + + public function testWithoutRedirectionImmutability(): void + { + $middleware = new ForceSecureConnection(new ResponseFactory()); + $new = $middleware->withoutRedirection(); + + $this->assertNotSame($middleware, $new); + } + + public function testRedirectionFromHttp(): void + { + $middleware = (new ForceSecureConnection(new ResponseFactory())) + ->withoutCSP() + ->withoutHSTS() + ->withRedirection(Status::SEE_OTHER); + $request = $this->createServerRequest(); + $request = $request->withUri($request->getUri()->withScheme('http')); + $handler = $this->createHandler(); + + $response = $middleware->process($request, $handler); + + $this->assertFalse($handler->isCalled); + $this->assertTrue($response->hasHeader(Header::LOCATION)); + $this->assertSame(Status::SEE_OTHER, $response->getStatusCode()); + $this->assertSame('https://test.org/index.php', $response->getHeaderLine(Header::LOCATION)); + } + + public function testWithHSTS(): void + { + $middleware = (new ForceSecureConnection(new ResponseFactory())) + ->withoutRedirection() + ->withoutCSP() + ->withHSTS(42, true); + $request = $this->createServerRequest(); + $handler = $this->createHandler(); + + $response = $middleware->process($request, $handler); + + $this->assertTrue($handler->isCalled); + $this->assertTrue($response->hasHeader(Header::STRICT_TRANSPORT_SECURITY)); + $this->assertSame('max-age=42; includeSubDomains', $response->getHeaderLine(Header::STRICT_TRANSPORT_SECURITY)); + } + + public function testWithHSTSNoSubdomains(): void + { + $middleware = (new ForceSecureConnection(new ResponseFactory())) + ->withoutRedirection() + ->withoutCSP() + ->withHSTS(1440, false); + $request = $this->createServerRequest(); + $handler = $this->createHandler(); + + $response = $middleware->process($request, $handler); + + $this->assertTrue($handler->isCalled); + $this->assertTrue($response->hasHeader(Header::STRICT_TRANSPORT_SECURITY)); + $this->assertSame('max-age=1440', $response->getHeaderLine(Header::STRICT_TRANSPORT_SECURITY)); + } + + public function testWithCSP(): void + { + $middleware = (new ForceSecureConnection(new ResponseFactory())) + ->withoutRedirection() + ->withoutHSTS() + ->withCSP(); + $request = $this->createServerRequest(); + $handler = $this->createHandler(); + + $response = $middleware->process($request, $handler); + + $this->assertTrue($handler->isCalled); + $this->assertTrue($response->hasHeader(Header::CONTENT_SECURITY_POLICY)); + } + + public function testWithCSPCustomDirectives(): void + { + $middleware = (new ForceSecureConnection(new ResponseFactory())) + ->withoutRedirection() + ->withoutHSTS() + ->withCSP('default-src https:; report-uri /csp-violation-report-endpoint/'); + $request = $this->createServerRequest(); + $handler = $this->createHandler(); + + $response = $middleware->process($request, $handler); + + $this->assertTrue($handler->isCalled); + $this->assertTrue($response->hasHeader(Header::CONTENT_SECURITY_POLICY)); + $this->assertSame( + $response->getHeaderLine(Header::CONTENT_SECURITY_POLICY), + 'default-src https:; report-uri /csp-violation-report-endpoint/' + ); + } + + public function testSecurityHeadersOnRedirection(): void + { + $middleware = (new ForceSecureConnection(new ResponseFactory())) + ->withRedirection() + ->withCSP() + ->withHSTS(); + $request = $this->createServerRequest(); + $request = $request->withUri($request->getUri()->withScheme('http')); + $handler = $this->createHandler(); + + $response = $middleware->process($request, $handler); + + $this->assertFalse($handler->isCalled); + $this->assertTrue($response->hasHeader(Header::LOCATION)); + $this->assertTrue($response->hasHeader(Header::STRICT_TRANSPORT_SECURITY)); + $this->assertFalse($response->hasHeader(Header::CONTENT_SECURITY_POLICY)); + } + + public function testWithoutRedirection(): void + { + $middleware = (new ForceSecureConnection(new ResponseFactory()))->withoutRedirection(); + $request = $this->createServerRequest(); + $request = $request->withUri($request->getUri()->withScheme('http')); + $handler = $this->createHandler(); + + $response = $middleware->process($request, $handler); + + $this->assertFalse($response->hasHeader(Header::LOCATION)); + } + + public function testWithoutCSP(): void + { + $middleware = (new ForceSecureConnection(new ResponseFactory()))->withoutCSP(); + $request = $this->createServerRequest(); + $handler = $this->createHandler(); + + $response = $middleware->process($request, $handler); + + $this->assertFalse($response->hasHeader(Header::CONTENT_SECURITY_POLICY)); + } + + public function testWithoutHSTS(): void + { + $middleware = (new ForceSecureConnection(new ResponseFactory()))->withoutHSTS(); + $request = $this->createServerRequest(); + $handler = $this->createHandler(); + + $response = $middleware->process($request, $handler); + + $this->assertTrue($handler->isCalled); + $this->assertFalse($response->hasHeader(Header::STRICT_TRANSPORT_SECURITY)); + } + + private function createHandler(): RequestHandlerInterface + { + return new class () implements RequestHandlerInterface { + public bool $isCalled = false; + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $this->isCalled = true; + return new Response(); + } + }; + } + + private function createServerRequest(): ServerRequestInterface + { + return (new ServerRequestFactory())->createServerRequest(Method::GET, 'https://test.org/index.php'); + } +} diff --git a/tests/HttpCacheTest.php b/tests/HttpCacheTest.php new file mode 100644 index 0000000..df4799c --- /dev/null +++ b/tests/HttpCacheTest.php @@ -0,0 +1,116 @@ +createMiddlewareWithLastModified($time + 1); + $response = $middleware->process($this->createServerRequest(Method::PATCH), $this->createRequestHandler()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Last-Modified')); + } + + public function testModifiedResultWithLastModified(): void + { + $time = time(); + $middleware = $this->createMiddlewareWithLastModified($time + 1); + $headers = [ + 'If-Modified-Since' => gmdate('D, d M Y H:i:s', $time) . 'GMT', + ]; + $response = $middleware->process($this->createServerRequest(Method::GET, $headers), $this->createRequestHandler()); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testModifiedResultWithEtag(): void + { + $etag = 'test-etag'; + $middleware = $this->createMiddlewareWithETag($etag); + $headers = [ + 'If-None-Match' => $etag, + ]; + $response = $middleware->process($this->createServerRequest(Method::GET, $headers), $this->createRequestHandler()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($response->getHeaderLine('Etag'), $this->generateEtag($etag)); + } + + public function testNotModifiedResultWithLastModified(): void + { + $time = time(); + $middleware = $this->createMiddlewareWithLastModified($time - 1); + $headers = [ + 'If-Modified-Since' => gmdate('D, d M Y H:i:s', $time) . ' GMT', + ]; + $response = $middleware->process($this->createServerRequest(Method::GET, $headers), $this->createRequestHandler()); + $this->assertEquals(304, $response->getStatusCode()); + $this->assertEmpty((string)$response->getBody()); + $this->assertEquals(gmdate('D, d M Y H:i:s', $time - 1) . ' GMT', $response->getHeaderLine('Last-Modified')); + } + + public function testNotModifiedResultWithEtag(): void + { + $etag = 'test-etag'; + $middleware = $this->createMiddlewareWithETag($etag); + $headers = [ + 'If-None-Match' => $this->generateEtag($etag), + ]; + $response = $middleware->process($this->createServerRequest(Method::GET, $headers), $this->createRequestHandler()); + $this->assertEquals(304, $response->getStatusCode()); + $this->assertEmpty((string)$response->getBody()); + } + + private function createMiddlewareWithLastModified(int $lastModified): HttpCache + { + $middleware = new HttpCache(); + return $middleware->withLastModified(fn () => $lastModified); + } + + private function createMiddlewareWithETag(string $etag): HttpCache + { + $middleware = new HttpCache(); + return $middleware->withEtagSeed(fn () => $etag); + } + + private function createRequestHandler(): RequestHandlerInterface + { + $requestHandler = $this->createMock(RequestHandlerInterface::class); + $requestHandler->method('handle')->willReturn(new Response(Status::OK)); + return $requestHandler; + } + + private function createServerRequest(string $method = Method::GET, array $headers = []): ServerRequestInterface + { + $request = (new ServerRequest())->withMethod($method); + + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + return $request; + } + + private function generateEtag(string $seed, ?string $weakEtag = null): string + { + $etag = '"' . rtrim(base64_encode(sha1($seed, true)), '=') . '"'; + return $weakEtag ? 'W/' . $etag : $etag; + } +} diff --git a/tests/IpFilterTest.php b/tests/IpFilterTest.php new file mode 100644 index 0000000..528109b --- /dev/null +++ b/tests/IpFilterTest.php @@ -0,0 +1,82 @@ + '8.8.8.8', + ]; + + private const ALLOWED_IP = '1.1.1.1'; + + private MockObject|ResponseFactoryInterface $responseFactoryMock; + private MockObject|RequestHandlerInterface $requestHandlerMock; + private IpFilter $ipFilter; + + protected function setUp(): void + { + parent::setUp(); + $this->responseFactoryMock = $this->createMock(ResponseFactoryInterface::class); + $this->requestHandlerMock = $this->createMock(RequestHandlerInterface::class); + $this->ipFilter = new IpFilter(Ip::rule()->ranges([self::ALLOWED_IP]), $this->responseFactoryMock); + } + + public function testProcessReturnsAccessDeniedResponseWhenIpIsNotAllowed(): void + { + $this->setUpResponseFactory(); + $requestMock = $this->createMock(ServerRequestInterface::class); + $requestMock + ->expects($this->once()) + ->method('getServerParams') + ->willReturn(self::REQUEST_PARAMS); + + $this->requestHandlerMock + ->expects($this->never()) + ->method('handle') + ->with($requestMock); + + $response = $this->ipFilter->process($requestMock, $this->requestHandlerMock); + $this->assertEquals(403, $response->getStatusCode()); + } + + public function testProcessCallsRequestHandlerWhenRemoteAddressIsAllowed(): void + { + $requestParams = [ + 'REMOTE_ADDR' => self::ALLOWED_IP, + ]; + $requestMock = $this->createMock(ServerRequestInterface::class); + $requestMock + ->expects($this->once()) + ->method('getServerParams') + ->willReturn($requestParams); + + $this->requestHandlerMock + ->expects($this->once()) + ->method('handle') + ->with($requestMock); + + $this->ipFilter->process($requestMock, $this->requestHandlerMock); + } + + private function setUpResponseFactory(): void + { + $response = new Response(Status::FORBIDDEN); + $this->responseFactoryMock + ->expects($this->once()) + ->method('createResponse') + ->willReturn($response); + } +} diff --git a/tests/RedirectTest.php b/tests/RedirectTest.php new file mode 100644 index 0000000..8aab790 --- /dev/null +++ b/tests/RedirectTest.php @@ -0,0 +1,106 @@ +expectException(InvalidArgumentException::class); + $this->createRedirectMiddleware()->process($this->createRequest(), $this->createRequestHandler()); + } + + public function testGenerateUri(): void + { + $middleware = $this->createRedirectMiddleware()->toRoute('test/route', [ + 'param1' => 1, + 'param2' => 2, + ]); + + $response = $middleware->process($this->createRequest(), $this->createRequestHandler()); + $header = $response->getHeader('Location'); + + $this->assertSame($header[0], 'test/route?param1=1¶m2=2'); + } + + public function testTemporaryReturnCode303(): void + { + $middleware = $this->createRedirectMiddleware() + ->toRoute('test/route') + ->temporary(); + + $response = $middleware->process($this->createRequest(), $this->createRequestHandler()); + + $this->assertSame($response->getStatusCode(), 303); + } + + public function testPermanentReturnCode301(): void + { + $middleware = $this->createRedirectMiddleware() + ->toRoute('test/route') + ->permanent(); + + $response = $middleware->process($this->createRequest(), $this->createRequestHandler()); + + $this->assertSame($response->getStatusCode(), 301); + } + + public function testStatusReturnCode400(): void + { + $middleware = $this->createRedirectMiddleware() + ->toRoute('test/route') + ->withStatus(400); + + $response = $middleware->process($this->createRequest(), $this->createRequestHandler()); + + $this->assertSame($response->getStatusCode(), 400); + } + + public function testSetUri(): void + { + $middleware = $this->createRedirectMiddleware() + ->toUrl('test/custom/route'); + + $response = $middleware->process($this->createRequest(), $this->createRequestHandler()); + $header = $response->getHeader('Location'); + + $this->assertSame($header[0], 'test/custom/route'); + } + + private function createRequestHandler(): RequestHandlerInterface + { + $requestHandler = $this->createMock(RequestHandlerInterface::class); + $requestHandler + ->method('handle') + ->willReturn((new ResponseFactory())->createResponse()); + + return $requestHandler; + } + + private function createRequest(string $method = Method::GET, string $uri = '/'): ServerRequestInterface + { + return (new ServerRequestFactory())->createServerRequest($method, $uri); + } + + private function createRedirectMiddleware(): Redirect + { + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator + ->method('generate') + ->willReturnCallback(fn ($name, $params) => $name . '?' . http_build_query($params)); + + return new Redirect(new ResponseFactory(), $urlGenerator); + } +} diff --git a/tests/SubFolderTest.php b/tests/SubFolderTest.php new file mode 100644 index 0000000..8fae124 --- /dev/null +++ b/tests/SubFolderTest.php @@ -0,0 +1,211 @@ +urlGeneratorUriPrefix = ''; + $this->lastRequest = null; + $this->aliases = new Aliases(['@baseUrl' => '/default/web']); + } + + public function testDefault(): void + { + $request = $this->createRequest($uri = '/', $script = '/index.php'); + $mw = $this->createMiddleware(null, '@baseUrl'); + + $this->process($mw, $request); + + $this->assertSame('/default/web', $this->aliases->get('@baseUrl')); + $this->assertSame('', $this->urlGeneratorUriPrefix); + $this->assertSame($uri, $this->getRequestPath()); + } + + public function testCustomPrefix(): void + { + $request = $this->createRequest($uri = '/custom_public/index.php?test', $script = '/index.php'); + $mw = $this->createMiddleware('/custom_public', '@baseUrl'); + + $this->process($mw, $request); + + $this->assertSame('/custom_public', $this->aliases->get('@baseUrl')); + $this->assertSame('/custom_public', $this->urlGeneratorUriPrefix); + $this->assertSame('/index.php', $this->getRequestPath()); + } + + public function testAutoPrefix(): void + { + $request = $this->createRequest($uri = '/public/', $script = '/public/index.php'); + $mw = $this->createMiddleware(null, '@baseUrl'); + + $this->process($mw, $request); + + $this->assertSame('/public', $this->aliases->get('@baseUrl')); + $this->assertSame('/public', $this->urlGeneratorUriPrefix); + $this->assertSame('/', $this->getRequestPath()); + } + + public function testAutoPrefixLogn(): void + { + $prefix = '/root/php/dev-server/project-42/index_html/public/web'; + $uri = "{$prefix}/"; + $script = "{$prefix}/index.php"; + $request = $this->createRequest($uri, $script); + $mw = $this->createMiddleware(null, '@baseUrl'); + + $this->process($mw, $request); + + $this->assertSame($prefix, $this->aliases->get('@baseUrl')); + $this->assertSame($prefix, $this->urlGeneratorUriPrefix); + $this->assertSame('/', $this->getRequestPath()); + } + + public function testAutoPrefixAndUriWithoutTrailingSlash(): void + { + $request = $this->createRequest($uri = '/public', $script = '/public/index.php'); + $mw = $this->createMiddleware(null, '@baseUrl'); + + $this->process($mw, $request); + + $this->assertSame('/public', $this->aliases->get('@baseUrl')); + $this->assertSame('/public', $this->urlGeneratorUriPrefix); + $this->assertSame('/', $this->getRequestPath()); + } + + public function testAutoPrefixFullUrl(): void + { + $request = $this->createRequest($uri = '/public/index.php?test', $script = '/public/index.php'); + $mw = $this->createMiddleware(null, '@baseUrl'); + + $this->process($mw, $request); + + $this->assertSame('/public', $this->aliases->get('@baseUrl')); + $this->assertSame('/public', $this->urlGeneratorUriPrefix); + $this->assertSame('/index.php', $this->getRequestPath()); + } + + public function testFailedAutoPrefix(): void + { + $request = $this->createRequest($uri = '/web/index.php', $script = '/public/index.php'); + $mw = $this->createMiddleware(null, '@baseUrl'); + + $this->process($mw, $request); + + $this->assertSame('/default/web', $this->aliases->get('@baseUrl')); + $this->assertSame('', $this->urlGeneratorUriPrefix); + $this->assertSame($uri, $this->getRequestPath()); + } + + public function testCustomPrefixWithTrailingSlash(): void + { + $request = $this->createRequest($uri = '/web/', $script = '/public/index.php'); + $mw = $this->createMiddleware('/web/', '@baseUrl'); + + $this->expectException(BadUriPrefixException::class); + $this->expectExceptionMessage('Wrong URI prefix value'); + + $this->process($mw, $request); + } + + public function testCustomPrefixFromMiddleOfUri(): void + { + $request = $this->createRequest($uri = '/web/middle/public', $script = '/public/index.php'); + $mw = $this->createMiddleware('/middle', '@baseUrl'); + + $this->expectException(BadUriPrefixException::class); + $this->expectExceptionMessage('URI prefix does not match'); + + $this->process($mw, $request); + } + + public function testCustomPrefixDoesNotMatch(): void + { + $request = $this->createRequest($uri = '/web/', $script = '/public/index.php'); + $mw = $this->createMiddleware('/other_prefix', '@baseUrl'); + + $this->expectException(BadUriPrefixException::class); + $this->expectExceptionMessage('URI prefix does not match'); + + $this->process($mw, $request); + } + + public function testCustomPrefixDoesNotMatchCompletely(): void + { + $request = $this->createRequest($uri = '/project1/web/', $script = '/public/index.php'); + $mw = $this->createMiddleware('/project1/we', '@baseUrl'); + + $this->expectException(BadUriPrefixException::class); + $this->expectExceptionMessage('URI prefix does not match completely'); + + $this->process($mw, $request); + } + + public function testAutoPrefixDoesNotMatchCompletely(): void + { + $request = $this->createRequest($uri = '/public/web/', $script = '/pub/index.php'); + $mw = $this->createMiddleware(null, '@baseUrl'); + + $this->process($mw, $request); + + $this->assertSame('/default/web', $this->aliases->get('@baseUrl')); + $this->assertSame('', $this->urlGeneratorUriPrefix); + $this->assertSame($uri, $this->getRequestPath()); + } + + private function process(SubFolder $middleware, ServerRequestInterface $request): ResponseInterface + { + $handler = new class () implements RequestHandlerInterface { + public ?ServerRequestInterface $request = null; + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $this->request = $request; + return new Response(); + } + }; + + $this->lastRequest = &$handler->request; + return $middleware->process($request, $handler); + } + + private function getRequestPath(): string + { + return $this->lastRequest->getUri()->getPath(); + } + + private function createMiddleware(?string $prefix = null, ?string $alias = null): SubFolder + { + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->method('setUriPrefix')->willReturnCallback(function ($prefix) { + $this->urlGeneratorUriPrefix = $prefix; + }); + + $urlGenerator->method('getUriPrefix')->willReturnReference($this->urlGeneratorUriPrefix); + return new SubFolder($urlGenerator, $this->aliases, $prefix, $alias); + } + + private function createRequest(string $uri = '/', string $scriptPath = '/'): ServerRequestInterface + { + return new ServerRequest(['SCRIPT_NAME' => $scriptPath], [], [], [], null, Method::GET, $uri); + } +} diff --git a/tests/TagRequestTest.php b/tests/TagRequestTest.php new file mode 100644 index 0000000..fd19f12 --- /dev/null +++ b/tests/TagRequestTest.php @@ -0,0 +1,31 @@ +createMock(ServerRequestInterface::class); + + $request + ->expects($this->once()) + ->method('withAttribute') + ->with( + $this->equalTo('requestTag'), + $this->isType('string') + ) + ->willReturnSelf(); + + $handler = $this->createMock(RequestHandlerInterface::class); + + (new TagRequest())->process($request, $handler); + } +} diff --git a/tests/TestAsset/MockRequestHandler.php b/tests/TestAsset/MockRequestHandler.php new file mode 100644 index 0000000..4989903 --- /dev/null +++ b/tests/TestAsset/MockRequestHandler.php @@ -0,0 +1,40 @@ +responseStatusCode = $responseStatusCode; + } + + public function setHandleExcaption(?Throwable $throwable): self + { + $this->handleException = $throwable; + return $this; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + if ($this->handleException !== null) { + throw $this->handleException; + } + + $this->processedRequest = $request; + return new Response($this->responseStatusCode); + } +} diff --git a/tests/TrustedHostsNetworkResolverTest.php b/tests/TrustedHostsNetworkResolverTest.php new file mode 100644 index 0000000..6113861 --- /dev/null +++ b/tests/TrustedHostsNetworkResolverTest.php @@ -0,0 +1,297 @@ + [ + ['x-forwarded-for' => ['9.9.9.9', '5.5.5.5', '2.2.2.2']], + ['REMOTE_ADDR' => '127.0.0.1'], + [ + ['hosts' => ['8.8.8.8', '127.0.0.1'], 'ipHeaders' => ['x-forwarded-for']], + ], + '2.2.2.2', + ], + 'xForwardLevel2' => [ + ['x-forwarded-for' => ['9.9.9.9', '5.5.5.5', '2.2.2.2']], + ['REMOTE_ADDR' => '127.0.0.1'], + [ + ['hosts' => ['8.8.8.8', '127.0.0.1', '2.2.2.2'], 'ipHeaders' => ['x-forwarded-for']], + ], + '5.5.5.5', + ], + 'rfc7239Level1' => [ + ['forwarded' => ['for=9.9.9.9', 'for=5.5.5.5', 'for=2.2.2.2']], + ['REMOTE_ADDR' => '127.0.0.1'], + [ + [ + 'hosts' => ['8.8.8.8', '127.0.0.1'], + 'ipHeaders' => [[TrustedHostsNetworkResolver::IP_HEADER_TYPE_RFC7239, 'forwarded']], + ], + ], + '2.2.2.2', + ], + 'rfc7239Level2' => [ + ['forwarded' => ['for=9.9.9.9', 'for=5.5.5.5', 'for=2.2.2.2']], + ['REMOTE_ADDR' => '127.0.0.1'], + [ + [ + 'hosts' => ['8.8.8.8', '127.0.0.1', '2.2.2.2'], + 'ipHeaders' => [[TrustedHostsNetworkResolver::IP_HEADER_TYPE_RFC7239, 'forwarded']], + ], + ], + '5.5.5.5', + ], + 'rfc7239Level2HostAndProtocol' => [ + ['forwarded' => ['for=9.9.9.9', 'proto=https;for=5.5.5.5;host=test', 'for=2.2.2.2']], + ['REMOTE_ADDR' => '127.0.0.1'], + [ + [ + 'hosts' => ['8.8.8.8', '127.0.0.1', '2.2.2.2'], + 'ipHeaders' => [[TrustedHostsNetworkResolver::IP_HEADER_TYPE_RFC7239, 'forwarded']], + 'hostHeaders' => ['forwarded'], + 'protocolHeaders' => ['forwarded' => ['http' => 'http', 'https' => 'https']], + ], + ], + '5.5.5.5', + 'test', + 'https', + ], + 'rfc7239Level2HostAndProtocolAndUrl' => [ + [ + 'forwarded' => ['for=9.9.9.9', 'proto=https;for=5.5.5.5;host=test', 'for=2.2.2.2'], + 'x-rewrite-url' => ['/test?test=test'], + ], + ['REMOTE_ADDR' => '127.0.0.1'], + [ + [ + 'hosts' => ['8.8.8.8', '127.0.0.1', '2.2.2.2'], + 'ipHeaders' => [[TrustedHostsNetworkResolver::IP_HEADER_TYPE_RFC7239, 'forwarded']], + 'hostHeaders' => ['forwarded'], + 'protocolHeaders' => ['forwarded' => ['http' => 'http', 'https' => 'https']], + 'urlHeaders' => ['x-rewrite-url'], + ], + ], + '5.5.5.5', + 'test', + 'https', + '/test', + 'test=test', + ], + 'rfc7239Level2AnotherHost&AnotherProtocol&Url' => [ + [ + 'forwarded' => ['for=9.9.9.9', 'proto=https;for=5.5.5.5;host=test', 'for=2.2.2.2'], + 'x-rewrite-url' => ['/test?test=test'], + 'x-forwarded-host' => ['test.another'], + 'x-forwarded-proto' => ['on'], + ], + ['REMOTE_ADDR' => '127.0.0.1'], + [ + [ + 'hosts' => ['8.8.8.8', '127.0.0.1', '2.2.2.2'], + 'ipHeaders' => [[TrustedHostsNetworkResolver::IP_HEADER_TYPE_RFC7239, 'forwarded']], + 'hostHeaders' => ['x-forwarded-host', 'forwarded'], + 'protocolHeaders' => [ + 'x-forwarded-proto' => ['http' => 'http'], + 'forwarded' => ['http' => 'http', 'https' => 'https'], + ], + 'urlHeaders' => ['x-rewrite-url'], + ], + ], + '5.5.5.5', + 'test.another', + 'https', + '/test', + 'test=test', + ], + 'rfc7239Level2AnotherHost&AnotherProtocol&Url&Port' => [ + [ + 'forwarded' => ['for=9.9.9.9', 'proto=https;for="5.5.5.5:123";host=test', 'for=2.2.2.2'], + 'x-rewrite-url' => ['/test?test=test'], + 'x-forwarded-host' => ['test.another'], + 'x-forwarded-proto' => ['on'], + ], + ['REMOTE_ADDR' => '127.0.0.1'], + [ + [ + 'hosts' => ['8.8.8.8', '127.0.0.1', '2.2.2.2'], + 'ipHeaders' => [[TrustedHostsNetworkResolver::IP_HEADER_TYPE_RFC7239, 'forwarded']], + 'hostHeaders' => ['x-forwarded-host', 'forwarded'], + 'protocolHeaders' => [ + 'x-forwarded-proto' => ['http' => 'http'], + 'forwarded' => ['http' => 'http', 'https' => 'https'], + ], + 'urlHeaders' => ['x-rewrite-url'], + 'portHeaders' => ['forwarded'], + ], + ], + '5.5.5.5', + 'test.another', + 'https', + '/test', + 'test=test', + 123, + ], + ]; + } + + /** + * @dataProvider trustedDataProvider + */ + public function testTrusted( + array $headers, + array $serverParams, + array $trustedHosts, + string $expectedClientIp, + ?string $expectedHttpHost = null, + string $expectedHttpScheme = 'http', + string $expectedPath = '/', + string $expectedQuery = '', + ?int $expectedPort = null + ): void { + $request = $this->createRequestWithSchemaAndHeaders('http', $headers, $serverParams); + $requestHandler = new MockRequestHandler(); + + $middleware = new TrustedHostsNetworkResolver(); + foreach ($trustedHosts as $data) { + $middleware = $middleware->withAddedTrustedHosts( + $data['hosts'], + $data['ipHeaders'] ?? [], + $data['protocolHeaders'] ?? [], + $data['hostHeaders'] ?? [], + $data['urlHeaders'] ?? [], + $data['portHeaders'] ?? [], + $data['trustedHeaders'] ?? null + ); + } + $response = $middleware->process($request, $requestHandler); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame($expectedClientIp, $requestHandler->processedRequest->getAttribute('requestClientIp')); + if ($expectedHttpHost !== null) { + $this->assertSame($expectedHttpHost, $requestHandler->processedRequest->getUri()->getHost()); + } + $this->assertSame($expectedHttpScheme, $requestHandler->processedRequest->getUri()->getScheme()); + $this->assertSame($expectedPath, $requestHandler->processedRequest->getUri()->getPath()); + $this->assertSame($expectedQuery, $requestHandler->processedRequest->getUri()->getQuery()); + $this->assertSame($expectedPort, $requestHandler->processedRequest->getUri()->getPort()); + } + + public function notTrustedDataProvider(): array + { + return [ + 'none' => [ + [], + ['REMOTE_ADDR' => '127.0.0.1'], + [], + ], + 'x-forwarded-for' => [ + ['x-forwarded-for' => ['9.9.9.9', '5.5.5.5', '2.2.2.2']], + ['REMOTE_ADDR' => '127.0.0.1'], + [['hosts' => ['8.8.8.8'], 'ipHeaders' => ['x-forwarded-for']]], + ], + 'rfc7239' => [ + ['x-forwarded-for' => ['for=9.9.9.9', 'for=5.5.5.5', 'for=2.2.2.2']], + ['REMOTE_ADDR' => '127.0.0.1'], + [['hosts' => ['8.8.8.8'], 'ipHeaders' => ['x-forwarded-for']]], + ], + ]; + } + + /** + * @dataProvider notTrustedDataProvider + */ + public function testNotTrusted(array $headers, array $serverParams, array $trustedHosts): void + { + $request = $this->createRequestWithSchemaAndHeaders('http', $headers, $serverParams); + $requestHandler = new MockRequestHandler(); + + $middleware = new TrustedHostsNetworkResolver(); + foreach ($trustedHosts as $data) { + $middleware = $middleware->withAddedTrustedHosts( + $data['hosts'], + $data['ipHeaders'] ?? [], + $data['protocolHeaders'] ?? [], + [], + [], + [], + $data['trustedHeaders'] ?? [] + ); + } + $middleware->process($request, $requestHandler); + $this->assertNull($request->getAttribute('requestClientIp')); + } + + public function addedTrustedHostsInvalidParameterDataProvider(): array + { + return [ + 'hostsEmpty' => ['hosts' => []], + 'hostsEmptyString' => ['hosts' => ['']], + 'hostsNumeric' => ['hosts' => [888]], + 'hostsSpaces' => ['hosts' => [' ']], + 'hostsNotDomain' => ['host' => ['-apple']], + 'urlHeadersEmpty' => ['urlHeaders' => ['']], + 'urlHeadersNumeric' => ['urlHeaders' => [888]], + 'urlHeadersSpaces' => ['urlHeaders' => [' ']], + 'trustedHeadersEmpty' => ['trustedHeaders' => ['']], + 'trustedHeadersNumeric' => ['trustedHeaders' => [888]], + 'trustedHeadersSpaces' => ['trustedHeaders' => [' ']], + 'protocolHeadersNumeric' => ['protocolHeaders' => ['http' => 888]], + 'ipHeadersEmptyString' => ['ipHeaders' => [' ']], + 'ipHeadersNumeric' => ['ipHeaders' => [888]], + 'ipHeadersInvalidType' => ['ipHeaders' => [['---', 'aaa']]], + 'ipHeadersInvalidTypeValue' => [ + 'ipHeaders' => [ + [ + TrustedHostsNetworkResolver::IP_HEADER_TYPE_RFC7239, + 888, + ], + ], + ], + ]; + } + + /** + * @dataProvider addedTrustedHostsInvalidParameterDataProvider + */ + public function testAddedTrustedHostsInvalidParameter(array $data): void + { + $this->expectException(InvalidArgumentException::class); + (new TrustedHostsNetworkResolver()) + ->withAddedTrustedHosts( + $data['hosts'] ?? [], + $data['ipHeaders'] ?? [], + $data['protocolHeaders'] ?? [], + $data['hostHeaders'] ?? [], + $data['urlHeaders'] ?? [], + $data['portHeaders'] ?? [], + $data['trustedHeaders'] ?? null + ); + } + + private function createRequestWithSchemaAndHeaders( + string $scheme = 'http', + array $headers = [], + array $serverParams = [] + ): ServerRequestInterface { + $request = new ServerRequest($serverParams); + + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + $uri = $request->getUri()->withScheme($scheme)->withPath('/'); + return $request->withUri($uri); + } +}