From 53961f9a82ac0d5af61c66647ddcdd022a0729c4 Mon Sep 17 00:00:00 2001 From: Evgeniy Zyubin Date: Thu, 18 Nov 2021 23:20:41 +0300 Subject: [PATCH] Cleanup, add docblocs, add test cases, raise psalm error level (#2) --- psalm.xml | 2 +- src/BasicNetworkResolver.php | 167 ++++++++++------- src/ForceSecureConnection.php | 71 ++++--- src/HttpCache.php | 160 ++++++++-------- src/IpFilter.php | 39 ++-- src/Redirect.php | 57 +++++- src/SubFolder.php | 27 ++- src/TrustedHostsNetworkResolver.php | 218 ++++++++++++++++------ tests/BasicNetworkResolverTest.php | 83 +++++++- tests/ForceSecureConnectionTest.php | 90 ++++----- tests/HttpCacheTest.php | 77 ++++++-- tests/IpFilterTest.php | 79 ++++++-- tests/RedirectTest.php | 37 +++- tests/TestAsset/MockRequestHandler.php | 2 +- tests/TrustedHostsNetworkResolverTest.php | 17 +- 15 files changed, 759 insertions(+), 367 deletions(-) diff --git a/psalm.xml b/psalm.xml index e88a799..baa9178 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,6 +1,6 @@ + * @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). + * Returns a new instance with added the specified protocol 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. @@ -94,27 +46,31 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface * - 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. - * }); + * ->withProtocolHeader( + * 'x-forwarded-proto', + * function (array $values, string $header, ServerRequestInterface $request): ?string { + * 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. + * - 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'] + * 'http' => ['http'], + * 'https' => ['https'], * ]); * ``` * - * @param string $header - * @param array|callable|null $values - * - * @return self + * @param string $header The protocol header name. + * @param array|callable|null $values The protocol header values. * * @see DEFAULT_PROTOCOL_AND_ACCEPTABLE_VALUES + * + * @return self */ - public function withAddedProtocolHeader(string $header, $values = null): self + public function withAddedProtocolHeader(string $header, array|callable $values = null): self { $new = clone $this; $header = strtolower($header); @@ -129,11 +85,7 @@ public function withAddedProtocolHeader(string $header, $values = null): self return $new; } - if (!is_array($values)) { - throw new RuntimeException('Accepted values is not array nor callable.'); - } - - if (count($values) === 0) { + if (empty($values)) { throw new RuntimeException('Accepted values cannot be an empty array.'); } @@ -145,15 +97,24 @@ public function withAddedProtocolHeader(string $header, $values = null): self } if ($protocol === '') { - throw new RuntimeException('The protocol cannot be an empty string'); + throw new RuntimeException('The protocol cannot be an empty string.'); } - $new->protocolHeaders[$header][$protocol] = array_map('strtolower', (array) $acceptedValues); + $new->protocolHeaders[$header][$protocol] = array_map('\strtolower', (array) $acceptedValues); } return $new; } + /** + * Returns a new instance without the specified protocol header. + * + * @param string $header The protocol header name. + * + * @see withAddedProtocolHeader() + * + * @return self + */ public function withoutProtocolHeader(string $header): self { $new = clone $this; @@ -161,6 +122,15 @@ public function withoutProtocolHeader(string $header): self return $new; } + /** + * Returns a new instance without the specified protocol headers. + * + * @param string[] $headers The protocol header names. If `null` is specified all protocol headers will be removed. + * + * @see withoutProtocolHeader() + * + * @return self + */ public function withoutProtocolHeaders(?array $headers = null): self { $new = clone $this; @@ -176,4 +146,59 @@ public function withoutProtocolHeaders(?array $headers = null): self return $new; } + + /** + * {@inheritDoc} + * + * @throws RuntimeException If wrong URI scheme protocol. + */ + 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((string) $newScheme)); + } + + return $handler->handle($request); + } } diff --git a/src/ForceSecureConnection.php b/src/ForceSecureConnection.php index 70680b1..86210ed 100644 --- a/src/ForceSecureConnection.php +++ b/src/ForceSecureConnection.php @@ -49,26 +49,11 @@ public function __construct(ResponseFactoryInterface $responseFactory) $this->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 + * Returns a new instance and enables redirection from HTTP to HTTPS. * - * @param int $statusCode - * @param int|null $port + * @param int $statusCode The response status code of redirection. + * @param int|null $port The redirection port. * * @return self */ @@ -81,6 +66,13 @@ public function withRedirection(int $statusCode = Status::MOVED_PERMANENTLY, int return $new; } + /** + * Returns a new instance and disables redirection from HTTP to HTTPS. + * + * @see withRedirection() + * + * @return self + */ public function withoutRedirection(): self { $new = clone $this; @@ -89,11 +81,11 @@ public function withoutRedirection(): self } /** - * Add Content-Security-Policy header to response. + * Returns a new instance with added the `Content-Security-Policy` header to response. * - * @see Header::CONTENT_SECURITY_POLICY + * @param string $directives The directives {@see DEFAULT_CSP_DIRECTIVES}. * - * @param string $directives + * @see Header::CONTENT_SECURITY_POLICY * * @return self */ @@ -105,6 +97,13 @@ public function withCSP(string $directives = self::DEFAULT_CSP_DIRECTIVES): self return $new; } + /** + * Returns a new instance without the `Content-Security-Policy` header in response. + * + * @see withCSP() + * + * @return self + */ public function withoutCSP(): self { $new = clone $this; @@ -113,12 +112,10 @@ public function withoutCSP(): self } /** - * Add Strict-Transport-Security header to each response. + * Returns a new instance with added the `Strict-Transport-Security` header to response. * - * @see Header::STRICT_TRANSPORT_SECURITY - * - * @param int $maxAge - * @param bool $subDomains + * @param int $maxAge The max age {@see DEFAULT_HSTS_MAX_AGE}. + * @param bool $subDomains Whether to add the `includeSubDomains` option to the header value. * * @return self */ @@ -131,6 +128,13 @@ public function withHSTS(int $maxAge = self::DEFAULT_HSTS_MAX_AGE, bool $subDoma return $new; } + /** + * Returns a new instance without the `Strict-Transport-Security` header in response. + * + * @see withHSTS() + * + * @return self + */ public function withoutHSTS(): self { $new = clone $this; @@ -138,6 +142,21 @@ public function withoutHSTS(): self return $new; } + 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))); + } + private function addCSP(ResponseInterface $response): ResponseInterface { return $this->addCSP diff --git a/src/HttpCache.php b/src/HttpCache.php index b7cd8f0..04e5999 100644 --- a/src/HttpCache.php +++ b/src/HttpCache.php @@ -25,57 +25,114 @@ */ final class HttpCache implements MiddlewareInterface { - private const DEFAULT_HEADER = 'public, max-age=3600'; + /** + * @var callable|null + */ + private $lastModified = null; /** - * @var callable A PHP callback that returns the UNIX timestamp of the last modification time. + * @var callable|null + */ + private $etagSeed = null; + + private bool $weakEtag = false; + private mixed $params = null; + private ?string $cacheControlHeader = 'public, max-age=3600'; + + /** + * Returns a new instance with the specified callable that generates the last modified. + * + * @param callable A PHP callback that returns the UNIX timestamp of the last modification time. + * * The callback's signature should be: * * ```php * function (ServerRequestInterface $request, mixed $params): int; * ``` * - * where `$request` is the {@see ServerRequestInterface} object that this filter is currently handling; - * `$params` takes the value of {@see params}. The callback should return a UNIX timestamp. + * Where `$request` is the {@see ServerRequestInterface} object that this filter is currently handling; + * `$params` takes the value of {@see withParams()}. The callback should return a UNIX timestamp. * * @see http://tools.ietf.org/html/rfc7232#section-2.2 + * + * @return self */ - private $lastModified; + public function withLastModified(callable $lastModified): self + { + $new = clone $this; + $new->lastModified = $lastModified; + return $new; + } /** - * @var callable A PHP callback that generates the ETag seed string. + * Returns a new instance with the specified callable that generates the ETag seed string. + * + * @param callable A PHP callback that generates the ETag seed string. + * * The callback's signature should be: * * ```php * function (ServerRequestInterface $request, mixed $params): string; * ``` * - * where `$request` is the {@see ServerRequestInterface} object that this middleware is currently handling; - * `$params` takes the value of {@see $params}. The callback should return a string serving + * Where `$request` is the {@see ServerRequestInterface} object that this middleware is currently handling; + * `$params` takes the value of {@see withParams()}. The callback should return a string serving * as the seed for generating an ETag. + * + * @return self */ - private $etagSeed; + public function withEtagSeed(callable $etagSeed): self + { + $new = clone $this; + $new->etagSeed = $etagSeed; + return $new; + } /** - * @var bool Whether to generate weak ETags. + * Returns a new instance with weak ETags generation enabled. Disabled by default. * * Weak ETags should be used if the content should be considered semantically equivalent, but not byte-equal. * * @see http://tools.ietf.org/html/rfc7232#section-2.3 + * + * @return self */ - private bool $weakEtag = false; + public function withWeakTag(): self + { + $new = clone $this; + $new->weakEtag = true; + return $new; + } /** - * @var mixed Additional parameters that should be passed to the {@see} and {@see etagSeed} callbacks. + * Returns a new instance with the specified additional parameters for ETag seed string generation. + * + * @param mixed Additional parameters that should be passed to the {@see withEtagSeed()} callbacks. + * + * @return self */ - private mixed $params = null; + public function withParams(mixed $params): self + { + $new = clone $this; + $new->params = $params; + return $new; + } /** - * @var string|null The value of the `Cache-Control` HTTP header. If null, the header will not be sent. + * Returns a new instance with the specified value of the `Cache-Control` HTTP header. + * + * @param string|null The value of the `Cache-Control` HTTP header. If null, the header will not be sent. * * @see http://tools.ietf.org/html/rfc2616#section-14.9 + * + * @return self */ - private ?string $cacheControlHeader = self::DEFAULT_HEADER; + public function withCacheControlHeader(?string $header): self + { + $new = clone $this; + $new->cacheControlHeader = $header; + return $new; + } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { @@ -86,12 +143,9 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface return $handler->handle($request); } - $lastModified = null; - if ($this->lastModified !== null) { - $lastModified = ($this->lastModified)($request, $this->params); - } - + $lastModified = $this->lastModified === null ? null : ($this->lastModified)($request, $this->params); $etag = null; + if ($this->etagSeed !== null) { $seed = ($this->etagSeed)($request, $this->params); @@ -114,7 +168,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $response = $response->withHeader(Header::ETAG, $etag); } - // https://tools.ietf.org/html/rfc7232#section-4.1 + // See: https://tools.ietf.org/html/rfc7232#section-4.1 if ($lastModified !== null && (!$cacheIsValid || $etag === null)) { $response = $response->withHeader( Header::LAST_MODIFIED, @@ -128,7 +182,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface /** * Validates if the HTTP cache contains valid content. If both Last-Modified and ETag are null, returns false. * - * @param ServerRequestInterface $request + * @param ServerRequestInterface $request The server request instance. * @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. @@ -138,9 +192,16 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface private function validateCache(ServerRequestInterface $request, ?int $lastModified, ?string $etag): bool { if ($request->hasHeader(Header::IF_NONE_MATCH)) { + $header = preg_split( + '/[\s,]+/', + str_replace('-gzip', '', $request->getHeaderLine(Header::IF_NONE_MATCH)), + -1, + PREG_SPLIT_NO_EMPTY, + ); + // 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); + return $etag !== null && !empty($header) && in_array($etag, $header, true); } if ($request->hasHeader(Header::IF_MODIFIED_SINCE)) { @@ -163,57 +224,4 @@ 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 index 9ff6c44..94eea5c 100644 --- a/src/IpFilter.php +++ b/src/IpFilter.php @@ -12,6 +12,9 @@ use Yiisoft\Http\Status; use Yiisoft\Validator\Rule\Ip; +/** + * IpFilter validates the IP received in the request. + */ final class IpFilter implements MiddlewareInterface { private Ip $ipValidator; @@ -19,10 +22,12 @@ final class IpFilter implements MiddlewareInterface private ?string $clientIpAttribute; /** - * @param Ip $ipValidator Client IP validator. The properties of the validator can be modified up to the moment of processing. - * @param ResponseFactoryInterface $responseFactory - * @param string|null $clientIpAttribute Attribute name of client IP. If NULL, then 'REMOTE_ADDR' value of the server parameters is processed. - * If the value is not null, then the attribute specified must have a value, otherwise the request will closed with forbidden. + * @param Ip $ipValidator Client IP validator. The properties of the validator + * can be modified up to the moment of processing. + * @param ResponseFactoryInterface $responseFactory The response factory instance. + * @param string|null $clientIpAttribute Attribute name of client IP. If `null`, then `REMOTE_ADDR` value + * of the server parameters is processed. If the value is not `null`, then the attribute specified + * must have a value, otherwise the request will closed with forbidden. */ public function __construct( Ip $ipValidator, @@ -34,6 +39,14 @@ public function __construct( $this->clientIpAttribute = $clientIpAttribute; } + /** + * Returns a new instance with the specified client IP validator. + * + * @param Ip $ipValidator Client IP validator. The properties of the validator + * can be modified up to the moment of processing. + * + * @return self + */ public function withIpValidator(Ip $ipValidator): self { $new = clone $this; @@ -41,22 +54,18 @@ public function withIpValidator(Ip $ipValidator): self 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); + $clientIp = $request->getAttribute($this->clientIpAttribute); } - if ($clientIp === null || !$this->ipValidator->disallowNegation()->disallowSubnet()->validate($clientIp)->isValid()) { + $clientIp ??= $request->getServerParams()['REMOTE_ADDR'] ?? null; + + 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; diff --git a/src/Redirect.php b/src/Redirect.php index 8e41780..451992c 100644 --- a/src/Redirect.php +++ b/src/Redirect.php @@ -4,15 +4,18 @@ namespace Yiisoft\Yii\Middleware; -use InvalidArgumentException; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use RuntimeException; use Yiisoft\Http\Status; use Yiisoft\Router\UrlGeneratorInterface; +/** + * This middleware generates and adds a `Location` header to the response. + */ final class Redirect implements MiddlewareInterface { private ?string $uri = null; @@ -28,6 +31,13 @@ public function __construct(ResponseFactoryInterface $responseFactory, UrlGenera $this->urlGenerator = $urlGenerator; } + /** + * Returns a new instance with the specified URL for redirection. + * + * @param string $url URL for redirection. + * + * @return self + */ public function toUrl(string $url): self { $new = clone $this; @@ -35,6 +45,16 @@ public function toUrl(string $url): self return $new; } + /** + * Returns a new instance with the specified route data for redirection. + * + * If a redirect URL has been set {@see toUrl()}, the route data will be ignored, since the URL is a priority. + * + * @param string $name The route name for redirection. + * @param array $parameters The route parameters for redirection. + * + * @return self + */ public function toRoute(string $name, array $parameters = []): self { $new = clone $this; @@ -43,13 +63,27 @@ public function toRoute(string $name, array $parameters = []): self return $new; } - public function withStatus(int $code): self + /** + * Returns a new instance with the specified status code of the response for redirection. + * + * @param int $statusCode The status code of the response for redirection. + * + * @return self + */ + public function withStatus(int $statusCode): self { $new = clone $this; - $new->statusCode = $code; + $new->statusCode = $statusCode; return $new; } + /** + * Returns a new instance with the response status code of permanent redirection. + * + * @see Status::MOVED_PERMANENTLY + * + * @return self + */ public function permanent(): self { $new = clone $this; @@ -57,19 +91,32 @@ public function permanent(): self return $new; } + /** + * Returns a new instance with the response status code of temporary redirection. + * + * @see Status::FOUND + * + * @return self + */ public function temporary(): self { $new = clone $this; - $new->statusCode = Status::SEE_OTHER; + $new->statusCode = Status::FOUND; return $new; } + /** + * {@inheritDoc} + * + * @throws RuntimeException If the data for redirection was not set earlier. + */ 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.'); + throw new RuntimeException('Either `toUrl()` or `toRoute()` method should be used.'); } + /** @psalm-suppress PossiblyNullArgument */ $uri = $this->uri ?? $this->urlGenerator->generate($this->route, $this->parameters); return $this->responseFactory diff --git a/src/SubFolder.php b/src/SubFolder.php index f01760c..f3dc35d 100644 --- a/src/SubFolder.php +++ b/src/SubFolder.php @@ -12,6 +12,7 @@ use Yiisoft\Router\UrlGeneratorInterface; use Yiisoft\Yii\Middleware\Exception\BadUriPrefixException; +use function is_string; use function strlen; use function strpos; use function substr; @@ -26,6 +27,13 @@ final class SubFolder implements MiddlewareInterface private ?string $prefix; private ?string $alias; + /** + * @param UrlGeneratorInterface $uriGenerator The URI generator instance. + * @param Aliases $aliases The aliases instance. + * @param string|null $prefix URI prefix the specified immediately after the domain part. + * The prefix value usually begins with a slash and must not end with a slash. + * @param string|null $alias The path alias {@see Aliases::get()}. + */ public function __construct( UrlGeneratorInterface $uriGenerator, Aliases $aliases, @@ -41,7 +49,7 @@ public function __construct( /** * {@inheritDoc} * - * @throws BadUriPrefixException + * @throws BadUriPrefixException If wrong URI prefix. */ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { @@ -49,41 +57,48 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $path = $uri->getPath(); $prefix = $this->prefix; $auto = $prefix === null; + /** @var string $prefix */ $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 (is_string($scriptName) && strpos($scriptName, '/', 1) !== false) { + $position = strrpos($scriptName, '/'); + $tmpPrefix = substr($scriptName, 0, $position === false ? null : $position); + if (strpos($path, $tmpPrefix) === 0) { $prefix = $tmpPrefix; $length = strlen($prefix); } } } elseif ($length > 0) { + /** @var string $prefix */ if ($prefix[-1] === '/') { - throw new BadUriPrefixException('Wrong URI prefix value'); + throw new BadUriPrefixException('Wrong URI prefix value.'); } if (strpos($path, $prefix) !== 0) { - throw new BadUriPrefixException('URI prefix does not match'); + 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'); + throw new BadUriPrefixException('URI prefix does not match completely.'); } } else { $request = $request->withUri($uri->withPath($newPath)); + /** @var string $prefix */ $this->uriGenerator->setUriPrefix($prefix); if ($this->alias !== null) { diff --git a/src/TrustedHostsNetworkResolver.php b/src/TrustedHostsNetworkResolver.php index fc8f68b..017dcb8 100644 --- a/src/TrustedHostsNetworkResolver.php +++ b/src/TrustedHostsNetworkResolver.php @@ -34,13 +34,13 @@ use function trim; /** - * Trusted hosts network resolver + * Trusted hosts network resolver. * * ```php * (new TrustedHostsNetworkResolver($responseFactory)) * ->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'] + * // 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. @@ -53,7 +53,7 @@ * ['x-rewrite-url'], * // Trusted headers. It is a good idea to list all relevant headers. * ['x-forwarded-for', 'forwarded', ...] - * ); + * ) * ->withAddedTrustedHosts(...) * ; * ``` @@ -86,36 +86,28 @@ class TrustedHostsNetworkResolver implements MiddlewareInterface 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 + * Returns a new instance with the 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 string[] $hosts List of trusted hosts IP addresses. If {@see isValidHost()} method 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 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 + * @return self */ public function withAddedTrustedHosts( array $hosts, @@ -128,34 +120,44 @@ public function withAddedTrustedHosts( ?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'); + 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'); + 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'); + throw new InvalidArgumentException('The type is not a string.'); } + if (!is_string($header)) { - throw new InvalidArgumentException('The header is not a string'); + 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"); + throw new InvalidArgumentException("Not supported IP header type: $type."); } + if (count($hosts) === 0) { - throw new InvalidArgumentException('Empty hosts not allowed'); + 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'); @@ -164,10 +166,12 @@ public function withAddedTrustedHosts( 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, @@ -177,21 +181,15 @@ public function withAddedTrustedHosts( 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"); - } - } + return $new; } + /** + * Returns a new instance without the trusted hosts and related headers. + * + * @return self + */ public function withoutTrustedHosts(): self { $new = clone $this; @@ -200,27 +198,45 @@ public function withoutTrustedHosts(): self } /** - * Request's attribute name to which trusted path data is added. + * Returns a new instance with the specified 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 + * @param string|null $attribute The request attribute name. * - * @see getElementsByRfc7239 + * @see getElementsByRfc7239() + * + * @return self */ public function withAttributeIps(?string $attribute): self { if ($attribute === '') { - throw new RuntimeException('Attribute should not be empty'); + throw new RuntimeException('Attribute should not be empty string.'); } + $new = clone $this; $new->attributeIps = $attribute; return $new; } + /** + * Returns a new instance with the specified client IP validator. + * + * @param Ip $ipValidator Client IP validator. + * + * @return self + */ + public function withIpValidator(Ip $ipValidator): self + { + $new = clone $this; + $new->ipValidator = $ipValidator; + 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); @@ -229,49 +245,65 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $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; } } + + /** @psalm-suppress PossiblyNullArgument, PossiblyNullArrayAccess */ $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 + $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 + + 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; @@ -288,11 +320,18 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface if (!$request->hasHeader($hostHeader)) { continue; } - if ($hostHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($hostData['httpHost'])) { + + 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; @@ -304,11 +343,18 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface if (!$request->hasHeader($protocolHeader)) { continue; } - if ($protocolHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($hostData['protocol'])) { + + 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); @@ -316,10 +362,13 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } } } + $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); } @@ -330,22 +379,30 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface if (!$request->hasHeader($portHeader)) { continue; } - if ($portHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($hostData['port']) && $this->checkPort((string)$hostData['port'])) { + + 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); + $uri = $uri->withPort((int) $port); break; } } - return $handler->handle($request->withUri($uri)->withAttribute('requestClientIp', $hostData['ip'])); + return $handler->handle($request->withUri($uri)->withAttribute('requestClientIp', $hostData['ip'] ?? null)); } /** - * Validate host by range + * Validate host by range. * * This method can be extendable by overwriting eg. with reverse DNS verification. */ @@ -371,7 +428,7 @@ protected function isValidHost(string $host, array $ranges, Ip $validator): bool * 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 + * @see getElementsByRfc7239() * @link https://tools.ietf.org/html/rfc7239#section-6.2 * @link https://tools.ietf.org/html/rfc7239#section-6.3 */ @@ -384,40 +441,52 @@ protected function reverseObfuscate( return $hostData; } - private function handleNotTrusted(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { + 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'); + 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'); + 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'); + throw new RuntimeException('The protocol must be a string.'); } + if ($protocol === '') { - throw new RuntimeException('The protocol cannot be empty'); + throw new RuntimeException('The protocol cannot be empty.'); } - $output[$header][$protocol] = array_map('strtolower', (array)$acceptedValues); + + $output[$header][$protocol] = array_map('\strtolower', (array)$acceptedValues); } } + return $output; } @@ -426,6 +495,7 @@ private function removeHeaders(ServerRequestInterface $request, array $headers): foreach ($headers as $header) { $request = $request->withoutAttribute($header); } + return $request; } @@ -433,14 +503,17 @@ 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, []]; } @@ -450,9 +523,11 @@ private function getIpList(RequestInterface $request, array $ipHeaders): array private function getFormattedIpList(array $forwards): array { $list = []; + foreach ($forwards as $ip) { $list[] = ['ip' => $ip]; } + return $list; } @@ -474,31 +549,42 @@ private function getFormattedIpList(array $forwards): array 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\.-]+))?$/'; + + $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; } @@ -508,6 +594,7 @@ private function getElementsByRfc7239(array $forwards): array $ipData[$destination] = $data[$source]; } } + if (isset($ipData['httpHost']) && filter_var($ipData['httpHost'], FILTER_VALIDATE_DOMAIN) === false) { // remove not valid HTTP host unset($ipData['httpHost']); @@ -515,6 +602,7 @@ private function getElementsByRfc7239(array $forwards): array $list[] = $ipData; } + return $list; } @@ -524,11 +612,14 @@ private function getUrl(RequestInterface $request, array $urlHeaders): ?array if (!$request->hasHeader($header)) { continue; } + $url = $request->getHeaderLine($header); + if (strpos($url, '/') === 0) { return array_pad(explode('?', $url, 2), 2, null); } } + return null; } @@ -536,4 +627,17 @@ private function checkPort(string $port): bool { return preg_match('/^\d{1,5}$/', $port) === 1 && (int)$port <= 65535; } + + 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"); + } + } + } } diff --git a/tests/BasicNetworkResolverTest.php b/tests/BasicNetworkResolverTest.php index 635e915..b2f6229 100644 --- a/tests/BasicNetworkResolverTest.php +++ b/tests/BasicNetworkResolverTest.php @@ -7,9 +7,13 @@ use HttpSoft\Message\ServerRequest; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; +use RuntimeException; +use stdClass; use Yiisoft\Yii\Middleware\BasicNetworkResolver; use Yiisoft\Yii\Middleware\Tests\TestAsset\MockRequestHandler; +use function stripos; + final class BasicNetworkResolverTest extends TestCase { public function schemeDataProvider(): array @@ -89,31 +93,92 @@ public function testScheme(string $scheme, array $headers, ?array $protocolHeade { $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 schemeArrayFailureDataProvider(): array + { + return [ + 'int-key' => [['https'], 'The protocol must be type of string.'], + 'empty-array' => [[], 'Accepted values cannot be an empty array.'], + 'empty-string-key' => [['' => 'http'], 'The protocol cannot be an empty string.'], + ]; + } + + /** + * @dataProvider schemeArrayFailureDataProvider + */ + public function testArraySchemeFailure(array $schemeValues, string $errorMessage): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($errorMessage); + + (new BasicNetworkResolver())->withAddedProtocolHeader('x-forwarded-proto', $schemeValues); + } + + public function schemeCallableFailureDataProvider(): array + { + return [ + 'int' => [1], + 'float' => [1.1], + 'true' => [true], + 'false' => [false], + 'array' => [['https']], + 'empty-array' => [[]], + 'empty-string' => [''], + 'object' => [new StdClass()], + 'callable' => [static fn () => 'https'], + ]; + } + + /** + * @dataProvider schemeCallableFailureDataProvider + */ + public function testCallableSchemeFailure(mixed $scheme): void + { + $request = $this->createMock(ServerRequestInterface::class); + $request->method('hasHeader')->willReturn(true); + $request->method('getHeader')->willReturn($scheme); + + $middleware = (new BasicNetworkResolver()) + ->withAddedProtocolHeader('x-forwarded-proto', static fn () => $scheme) + ; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + $scheme === '' ? 'The scheme cannot be an empty string.' : 'The scheme is neither string nor null.', + ); + + $middleware->process($request, new MockRequestHandler()); + } + 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()); } @@ -124,17 +189,18 @@ public function testWithoutProtocolHeadersMulti(): void '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', - ]); + ->withoutProtocolHeaders(['x-forwarded-proto', 'x-forwarded-proto-2']) + ; + $middleware->process($request, $requestHandler); $resultRequest = $requestHandler->processedRequest; + /* @var $resultRequest ServerRequestInterface */ $this->assertSame('http', $resultRequest->getUri()->getScheme()); } @@ -145,20 +211,25 @@ public function testWithoutProtocolHeader(): void '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'); + ->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()); } diff --git a/tests/ForceSecureConnectionTest.php b/tests/ForceSecureConnectionTest.php index e7a0487..80b5e82 100644 --- a/tests/ForceSecureConnectionTest.php +++ b/tests/ForceSecureConnectionTest.php @@ -4,7 +4,6 @@ namespace Yiisoft\Yii\Middleware\Tests; -use HttpSoft\Message\Response; use HttpSoft\Message\ResponseFactory; use HttpSoft\Message\ServerRequestFactory; use PHPUnit\Framework\TestCase; @@ -18,61 +17,14 @@ final class ForceSecureConnectionTest extends TestCase { - // Immutability - public function testWithCSPImmutability(): void - { - $middleware = new ForceSecureConnection(new ResponseFactory()); - $new = $middleware->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); + ->withRedirection(Status::SEE_OTHER) + ; + $request = $this->createServerRequest(); $request = $request->withUri($request->getUri()->withScheme('http')); $handler = $this->createHandler(); @@ -90,7 +42,9 @@ public function testWithHSTS(): void $middleware = (new ForceSecureConnection(new ResponseFactory())) ->withoutRedirection() ->withoutCSP() - ->withHSTS(42, true); + ->withHSTS(42, true) + ; + $request = $this->createServerRequest(); $handler = $this->createHandler(); @@ -106,7 +60,9 @@ public function testWithHSTSNoSubdomains(): void $middleware = (new ForceSecureConnection(new ResponseFactory())) ->withoutRedirection() ->withoutCSP() - ->withHSTS(1440, false); + ->withHSTS(1440, false) + ; + $request = $this->createServerRequest(); $handler = $this->createHandler(); @@ -122,7 +78,9 @@ public function testWithCSP(): void $middleware = (new ForceSecureConnection(new ResponseFactory())) ->withoutRedirection() ->withoutHSTS() - ->withCSP(); + ->withCSP() + ; + $request = $this->createServerRequest(); $handler = $this->createHandler(); @@ -137,7 +95,9 @@ public function testWithCSPCustomDirectives(): void $middleware = (new ForceSecureConnection(new ResponseFactory())) ->withoutRedirection() ->withoutHSTS() - ->withCSP('default-src https:; report-uri /csp-violation-report-endpoint/'); + ->withCSP('default-src https:; report-uri /csp-violation-report-endpoint/') + ; + $request = $this->createServerRequest(); $handler = $this->createHandler(); @@ -147,7 +107,7 @@ public function testWithCSPCustomDirectives(): void $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/' + 'default-src https:; report-uri /csp-violation-report-endpoint/', ); } @@ -156,7 +116,9 @@ public function testSecurityHeadersOnRedirection(): void $middleware = (new ForceSecureConnection(new ResponseFactory())) ->withRedirection() ->withCSP() - ->withHSTS(); + ->withHSTS() + ; + $request = $this->createServerRequest(); $request = $request->withUri($request->getUri()->withScheme('http')); $handler = $this->createHandler(); @@ -204,6 +166,18 @@ public function testWithoutHSTS(): void $this->assertFalse($response->hasHeader(Header::STRICT_TRANSPORT_SECURITY)); } + public function testImmutability(): void + { + $middleware = new ForceSecureConnection(new ResponseFactory()); + + $this->assertNotSame($middleware, $middleware->withRedirection()); + $this->assertNotSame($middleware, $middleware->withoutRedirection()); + $this->assertNotSame($middleware, $middleware->withCSP()); + $this->assertNotSame($middleware, $middleware->withoutCSP()); + $this->assertNotSame($middleware, $middleware->withHSTS()); + $this->assertNotSame($middleware, $middleware->withoutHSTS()); + } + private function createHandler(): RequestHandlerInterface { return new class () implements RequestHandlerInterface { @@ -212,7 +186,7 @@ private function createHandler(): RequestHandlerInterface public function handle(ServerRequestInterface $request): ResponseInterface { $this->isCalled = true; - return new Response(); + return (new ResponseFactory())->createResponse(); } }; } diff --git a/tests/HttpCacheTest.php b/tests/HttpCacheTest.php index df4799c..42c5615 100644 --- a/tests/HttpCacheTest.php +++ b/tests/HttpCacheTest.php @@ -26,7 +26,8 @@ public function testNotCacheableMethods(): void $time = time(); $middleware = $this->createMiddlewareWithLastModified($time + 1); $response = $middleware->process($this->createServerRequest(Method::PATCH), $this->createRequestHandler()); - $this->assertEquals(200, $response->getStatusCode()); + + $this->assertSame(Status::OK, $response->getStatusCode()); $this->assertFalse($response->hasHeader('Last-Modified')); } @@ -34,54 +35,102 @@ 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()); + + $response = $middleware->process( + $this->createServerRequest(Method::GET, $headers), + $this->createRequestHandler(), + ); + + $this->assertSame(Status::OK, $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)); + + $response = $middleware->process( + $this->createServerRequest(Method::GET, $headers), + $this->createRequestHandler(), + ); + + $this->assertSame(Status::OK, $response->getStatusCode()); + $this->assertSame($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')); + + $response = $middleware->process( + $this->createServerRequest(Method::GET, $headers), + $this->createRequestHandler(), + ); + + $this->assertSame(Status::NOT_MODIFIED, $response->getStatusCode()); + $this->assertEmpty((string) $response->getBody()); + $this->assertSame(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()); + + $response = $middleware->process( + $this->createServerRequest(Method::GET, $headers), + $this->createRequestHandler(), + ); + + $this->assertSame(Status::NOT_MODIFIED, $response->getStatusCode()); + $this->assertEmpty((string) $response->getBody()); + } + + public function testEmptyIfNoneMatchAndIfModifiedSinceHeaders(): void + { + $middleware = (new HttpCache()) + ->withEtagSeed(static fn () => 'test-etag') + ->withLastModified(static fn () => time() + 3600) + ; + + $response = $middleware->process($this->createServerRequest(), $this->createRequestHandler()); + + $this->assertSame(Status::OK, $response->getStatusCode()); + $this->assertEmpty((string) $response->getBody()); + } + + public function testImmutability(): void + { + $middleware = new HttpCache(); + + $this->assertNotSame($middleware, $middleware->withLastModified(static fn () => 3600)); + $this->assertNotSame($middleware, $middleware->withEtagSeed(static fn () => 'test-etag')); + $this->assertNotSame($middleware, $middleware->withWeakTag()); + $this->assertNotSame($middleware, $middleware->withParams(['key' => 'value'])); + $this->assertNotSame($middleware, $middleware->withCacheControlHeader('public, max-age=3600')); } private function createMiddlewareWithLastModified(int $lastModified): HttpCache { $middleware = new HttpCache(); - return $middleware->withLastModified(fn () => $lastModified); + return $middleware->withLastModified(static fn () => $lastModified); } private function createMiddlewareWithETag(string $etag): HttpCache diff --git a/tests/IpFilterTest.php b/tests/IpFilterTest.php index 528109b..953d10c 100644 --- a/tests/IpFilterTest.php +++ b/tests/IpFilterTest.php @@ -16,10 +16,6 @@ final class IpFilterTest extends TestCase { - private const REQUEST_PARAMS = [ - 'REMOTE_ADDR' => '8.8.8.8', - ]; - private const ALLOWED_IP = '1.1.1.1'; private MockObject|ResponseFactoryInterface $responseFactoryMock; @@ -34,49 +30,92 @@ protected function setUp(): void $this->ipFilter = new IpFilter(Ip::rule()->ranges([self::ALLOWED_IP]), $this->responseFactoryMock); } - public function testProcessReturnsAccessDeniedResponseWhenIpIsNotAllowed(): void + public function ipNotAllowedDataProvider(): array + { + return [ + 'not-allowed' => [['REMOTE_ADDR' => '8.8.8.8']], + 'not-exists' => [[]], + ]; + } + + /** + * @dataProvider ipNotAllowedDataProvider + */ + public function testProcessReturnsAccessDeniedResponseWhenIpIsNotAllowed(array $serverParams): void { - $this->setUpResponseFactory(); $requestMock = $this->createMock(ServerRequestInterface::class); $requestMock ->expects($this->once()) ->method('getServerParams') - ->willReturn(self::REQUEST_PARAMS); + ->willReturn($serverParams) + ; + + $this->responseFactoryMock + ->expects($this->once()) + ->method('createResponse') + ->willReturn(new Response(Status::FORBIDDEN)) + ; $this->requestHandlerMock ->expects($this->never()) ->method('handle') - ->with($requestMock); + ->with($requestMock) + ; $response = $this->ipFilter->process($requestMock, $this->requestHandlerMock); - $this->assertEquals(403, $response->getStatusCode()); + + $this->assertSame(Status::FORBIDDEN, $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); + ->willReturn(['REMOTE_ADDR' => self::ALLOWED_IP]) + ; $this->requestHandlerMock ->expects($this->once()) ->method('handle') - ->with($requestMock); + ->with($requestMock) + ->willReturn(new Response(Status::OK)) + ; + + $response = $this->ipFilter->process($requestMock, $this->requestHandlerMock); - $this->ipFilter->process($requestMock, $this->requestHandlerMock); + $this->assertSame(Status::OK, $response->getStatusCode()); } - private function setUpResponseFactory(): void + public function testProcessCallsRequestHandlerWithSetClientIpAttribute(): void { - $response = new Response(Status::FORBIDDEN); - $this->responseFactoryMock + $requestMock = $this->createMock(ServerRequestInterface::class); + $attributeName = 'test'; + + $requestMock ->expects($this->once()) - ->method('createResponse') - ->willReturn($response); + ->method('getAttribute') + ->with($attributeName) + ->willReturn(self::ALLOWED_IP) + ; + + $this->requestHandlerMock + ->expects($this->once()) + ->method('handle') + ->with($requestMock) + ->willReturn(new Response(Status::OK)) + ; + + $ipFilter = new IpFilter(Ip::rule()->ranges([self::ALLOWED_IP]), $this->responseFactoryMock, $attributeName); + $response = $ipFilter->process($requestMock, $this->requestHandlerMock); + + $this->assertSame(Status::OK, $response->getStatusCode()); + } + + public function testImmutability(): void + { + $this->assertNotSame($this->ipFilter, $this->ipFilter->withIpValidator(Ip::rule()->ranges([self::ALLOWED_IP]))); } } diff --git a/tests/RedirectTest.php b/tests/RedirectTest.php index 8aab790..3b66c1f 100644 --- a/tests/RedirectTest.php +++ b/tests/RedirectTest.php @@ -4,21 +4,26 @@ namespace Yiisoft\Yii\Web\Tests\Middleware; -use InvalidArgumentException; use HttpSoft\Message\ResponseFactory; use HttpSoft\Message\ServerRequestFactory; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use RuntimeException; use Yiisoft\Http\Method; +use Yiisoft\Http\Status; use Yiisoft\Router\UrlGeneratorInterface; use Yiisoft\Yii\Middleware\Redirect; +use function http_build_query; + final class RedirectTest extends TestCase { public function testInvalidArguments(): void { - $this->expectException(InvalidArgumentException::class); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Either `toUrl()` or `toRoute()` method should be used.'); + $this->createRedirectMiddleware()->process($this->createRequest(), $this->createRequestHandler()); } @@ -35,37 +40,40 @@ public function testGenerateUri(): void $this->assertSame($header[0], 'test/route?param1=1¶m2=2'); } - public function testTemporaryReturnCode303(): void + public function testTemporaryReturnCode302(): void { $middleware = $this->createRedirectMiddleware() ->toRoute('test/route') - ->temporary(); + ->temporary() + ; $response = $middleware->process($this->createRequest(), $this->createRequestHandler()); - $this->assertSame($response->getStatusCode(), 303); + $this->assertSame($response->getStatusCode(), Status::FOUND); } public function testPermanentReturnCode301(): void { $middleware = $this->createRedirectMiddleware() ->toRoute('test/route') - ->permanent(); + ->permanent() + ; $response = $middleware->process($this->createRequest(), $this->createRequestHandler()); - $this->assertSame($response->getStatusCode(), 301); + $this->assertSame($response->getStatusCode(), Status::MOVED_PERMANENTLY); } public function testStatusReturnCode400(): void { $middleware = $this->createRedirectMiddleware() ->toRoute('test/route') - ->withStatus(400); + ->withStatus(Status::BAD_REQUEST) + ; $response = $middleware->process($this->createRequest(), $this->createRequestHandler()); - $this->assertSame($response->getStatusCode(), 400); + $this->assertSame($response->getStatusCode(), Status::BAD_REQUEST); } public function testSetUri(): void @@ -79,6 +87,17 @@ public function testSetUri(): void $this->assertSame($header[0], 'test/custom/route'); } + public function testImmutability(): void + { + $middleware = $this->createRedirectMiddleware(); + + $this->assertNotSame($middleware, $middleware->toUrl('/')); + $this->assertNotSame($middleware, $middleware->toRoute('test/custom/route', ['id' => 42])); + $this->assertNotSame($middleware, $middleware->withStatus(Status::MOVED_PERMANENTLY)); + $this->assertNotSame($middleware, $middleware->permanent()); + $this->assertNotSame($middleware, $middleware->temporary()); + } + private function createRequestHandler(): RequestHandlerInterface { $requestHandler = $this->createMock(RequestHandlerInterface::class); diff --git a/tests/TestAsset/MockRequestHandler.php b/tests/TestAsset/MockRequestHandler.php index 4989903..6146559 100644 --- a/tests/TestAsset/MockRequestHandler.php +++ b/tests/TestAsset/MockRequestHandler.php @@ -22,7 +22,7 @@ public function __construct(int $responseStatusCode = Status::OK) $this->responseStatusCode = $responseStatusCode; } - public function setHandleExcaption(?Throwable $throwable): self + public function setHandleException(?Throwable $throwable): self { $this->handleException = $throwable; return $this; diff --git a/tests/TrustedHostsNetworkResolverTest.php b/tests/TrustedHostsNetworkResolverTest.php index 6113861..6f1887f 100644 --- a/tests/TrustedHostsNetworkResolverTest.php +++ b/tests/TrustedHostsNetworkResolverTest.php @@ -8,6 +8,7 @@ use HttpSoft\Message\ServerRequest; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; +use Yiisoft\Validator\Rule\Ip; use Yiisoft\Yii\Middleware\TrustedHostsNetworkResolver; use Yiisoft\Yii\Middleware\Tests\TestAsset\MockRequestHandler; @@ -216,8 +217,8 @@ public function testNotTrusted(array $headers, array $serverParams, array $trust { $request = $this->createRequestWithSchemaAndHeaders('http', $headers, $serverParams); $requestHandler = new MockRequestHandler(); - $middleware = new TrustedHostsNetworkResolver(); + foreach ($trustedHosts as $data) { $middleware = $middleware->withAddedTrustedHosts( $data['hosts'], @@ -226,7 +227,7 @@ public function testNotTrusted(array $headers, array $serverParams, array $trust [], [], [], - $data['trustedHeaders'] ?? [] + $data['trustedHeaders'] ?? [], ); } $middleware->process($request, $requestHandler); @@ -268,6 +269,7 @@ public function addedTrustedHostsInvalidParameterDataProvider(): array public function testAddedTrustedHostsInvalidParameter(array $data): void { $this->expectException(InvalidArgumentException::class); + (new TrustedHostsNetworkResolver()) ->withAddedTrustedHosts( $data['hosts'] ?? [], @@ -280,6 +282,17 @@ public function testAddedTrustedHostsInvalidParameter(array $data): void ); } + public function testImmutability(): void + { + $middleware = new TrustedHostsNetworkResolver(); + + $this->assertNotSame($middleware, $middleware->withAddedTrustedHosts(['8.8.8.8'])); + $this->assertNotSame($middleware, $middleware->withoutTrustedHosts()); + $this->assertNotSame($middleware, $middleware->withAttributeIps('test')); + $this->assertNotSame($middleware, $middleware->withAttributeIps(null)); + $this->assertNotSame($middleware, $middleware->withIpValidator(Ip::rule())); + } + private function createRequestWithSchemaAndHeaders( string $scheme = 'http', array $headers = [],