Skip to content
This repository has been archived by the owner on Jun 29, 2022. It is now read-only.

Commit

Permalink
Add basic network resolver middleware #119 (#125)
Browse files Browse the repository at this point in the history
  • Loading branch information
kamarton authored and devanych committed Feb 1, 2021
1 parent bfec573 commit 538f89c
Show file tree
Hide file tree
Showing 3 changed files with 350 additions and 0 deletions.
141 changes: 141 additions & 0 deletions src/Middleware/BasicNetworkResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php


namespace Yiisoft\Yii\Web\Middleware;


use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
* Basic network resolver updates an instance of server request with protocol from special headers.
*
* It can be used in the following cases:
* - not required IP resolve to access the user's IP
* - user's IP is already resolved (eg `ngx_http_realip_module` or similar)
*/
class BasicNetworkResolver implements MiddlewareInterface
{
private const DEFAULT_PROTOCOL_AND_ACCEPTABLE_VALUES = [
'http' => ['http'],
'https' => ['https', 'on'],
];

private $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 callable|array|null $values
* @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 = [];
} else {
foreach ($headers as $header) {
$new = $new->withoutProtocolHeader($header);
}
}
return $new;
}
}
175 changes: 175 additions & 0 deletions tests/Middleware/BasicNetworkResolverTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
<?php


namespace Yiisoft\Yii\Web\Tests\Middleware;

use Nyholm\Psr7\ServerRequest;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Yiisoft\Yii\Web\Middleware\BasicNetworkResolver;
use Yiisoft\Yii\Web\Tests\Middleware\Mock\MockRequestHandler;

class BasicNetworkResolverTest extends TestCase
{

public function schemeDataProvider(): array
{
return [
'httpNotModify' => ['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',
]
];
}

protected function newRequestWithSchemaAndHeaders(
string $scheme = 'http',
array $headers = []
): ServerRequestInterface {
$request = new ServerRequest('GET', '/', $headers);
$uri = $request->getUri()->withScheme($scheme);
return $request->withUri($uri);
}

/**
* @dataProvider schemeDataProvider
*/
public function testScheme(string $scheme, array $headers, ?array $protocolHeaders, string $expectedScheme): void
{
$request = $this->newRequestWithSchemaAndHeaders($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->newRequestWithSchemaAndHeaders('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->newRequestWithSchemaAndHeaders('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->newRequestWithSchemaAndHeaders('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());
}

}
34 changes: 34 additions & 0 deletions tests/Middleware/Mock/MockRequestHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php


namespace Yiisoft\Yii\Web\Tests\Middleware\Mock;


use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

class MockRequestHandler implements RequestHandlerInterface
{
/**
* @var ServerRequestInterface
*/
public $processedRequest;

/**
* @var int
*/
private $responseStatus;

public function __construct(int $responseStatus = 200)
{
$this->responseStatus = $responseStatus;
}

public function handle(ServerRequestInterface $request): ResponseInterface
{
$this->processedRequest = $request;
return new Response($this->responseStatus);
}
}

0 comments on commit 538f89c

Please sign in to comment.