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);
+ }
+}