Completed
Pull Request — master (#157)
by
unknown
02:09
created

getForwardedElements()   C

Complexity

Conditions 14
Paths 9

Size

Total Lines 41
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 16.7094

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 14
eloc 24
c 2
b 0
f 0
nc 9
nop 1
dl 0
loc 41
ccs 19
cts 25
cp 0.76
crap 16.7094
rs 6.2666

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace Yiisoft\Yii\Web\Middleware;
4
5
use Psr\Http\Message\RequestInterface;
6
use Psr\Http\Message\ResponseFactoryInterface;
7
use Psr\Http\Message\ResponseInterface;
8
use Psr\Http\Message\ServerRequestInterface;
9
use Psr\Http\Server\MiddlewareInterface;
10
use Psr\Http\Server\RequestHandlerInterface;
11
use Yiisoft\NetworkUtilities\IpHelper;
12
use Yiisoft\Validator\Rule\Ip;
13
use Yiisoft\Yii\Web\Helper\HeaderHelper;
14
15
class TrustedHostsNetworkResolver implements MiddlewareInterface
16
{
17
    public const IP_HEADER_TYPE_RFC7239 = 'rfc7239';
18
19
    private const DEFAULT_IP_HEADERS = [
20
        [self::IP_HEADER_TYPE_RFC7239, 'forward'], // https://tools.ietf.org/html/rfc7239
21
        'x-forwarded-for', // common
22
    ];
23
24
    private const DEFAULT_HOST_HEADERS = [
25
        'x-forwarded-host', // common
26
    ];
27
28
    private const DEFAULT_URL_HEADERS = [
29
        'x-rewrite-url', // Microsoft
30
    ];
31
32
    private const DEFAULT_PROTOCOL_HEADERS = [
33
        'x-forwarded-proto' => ['http' => 'http', 'https' => 'https'], // common
34
        'front-end-https' => ['https' => 'on'], // Microsoft
35
    ];
36
37
    private const DEFAULT_TRUSTED_HEADERS = [
38
        // common:
39
        'x-forwarded-for',
40
        'x-forwarded-host',
41
        'x-forwarded-proto',
42
43
        // RFC:
44
        'forward',
45
46
        // Microsoft:
47
        'front-end-https',
48
        'x-rewrite-url',
49
    ];
50
51
    private const DATA_KEY_HOSTS = 'hosts';
52
    private const DATA_KEY_IP_HEADERS = 'ipHeaders';
53
    private const DATA_KEY_HOST_HEADERS = 'hostHeaders';
54
    private const DATA_KEY_URL_HEADERS = 'urlHeaders';
55
    private const DATA_KEY_PROTOCOL_HEADERS = 'protocolHeaders';
56
    private const DATA_KEY_TRUSTED_HEADERS = 'trustedHeaders';
57
58
    private $trustedHosts = [];
59
60
    /**
61
     * @var string|null
62
     */
63
    private $attributeIps;
64
65
    /**
66
     * @var ResponseFactoryInterface
67
     */
68
    private $responseFactory;
69
    /**
70
     * @var Chain|null
71
     */
72
    private $notTrustedBranch;
73
74
    /**
75
     * @var Ip|null
76
     */
77
    private $ipValidator;
78
79 10
    public function __construct(ResponseFactoryInterface $responseFactory)
80
    {
81 10
        $this->responseFactory = $responseFactory;
82
    }
83
84
    /**
85
     * @return static
86
     */
87
    public function withIpValidator(Ip $ipValidator)
88
    {
89
        $new = clone $this;
90
        $ipValidator = clone $ipValidator;
91
        // force disable unacceptable validation
92
        $new->ipValidator = $ipValidator->disallowSubnet()->disallowNegation();
93
        return $new;
94
    }
95
96
    /**
97
     * @return static
98
     */
99 1
    public function withNotTrustedBranch(?MiddlewareInterface $middleware)
100
    {
101 1
        $new = clone $this;
102 1
        $new->notTrustedBranch = $middleware;
103 1
        return $new;
104
    }
105
106
    /**
107
     * @return static
108
     */
109 8
    public function withAddedTrustedHosts(
110
        array $hosts,
111
        ?array $ipHeaders = null,
112
        ?array $protocolHeaders = null,
113
        ?array $hostHeaders = null,
114
        ?array $urlHeaders = null,
115
        ?array $trustedHeaders = null
116
    ) {
117 8
        $new = clone $this;
118 8
        $ipHeaders = $ipHeaders ?? self::DEFAULT_IP_HEADERS;
119 8
        foreach ($ipHeaders as $ipHeader) {
120 8
            if (\is_string($ipHeader)) {
121 8
                continue;
122
            }
123 8
            if (!\is_array($ipHeader)) {
124
                throw new \InvalidArgumentException('Type of ipHeader is not a string and not array');
125
            }
126 8
            if (count($ipHeader) !== 2) {
127
                throw new \InvalidArgumentException('The ipHeader array must have exactly 2 elements');
128
            }
129 8
            [$type, $header] = $ipHeader;
130 8
            if (!\is_string($type)) {
131
                throw new \InvalidArgumentException('The type is not a string');
132
            }
133 8
            if (!\is_string($header)) {
134
                throw new \InvalidArgumentException('The header is not a string');
135
            }
136 8
            if ($type === self::IP_HEADER_TYPE_RFC7239) {
137 8
                continue;
138
            }
139
140
            throw new \InvalidArgumentException("Not supported IP header type: $type");
141
        }
142 8
        $new->trustedHosts[] = [
143 8
            self::DATA_KEY_HOSTS => $hosts,
144 8
            self::DATA_KEY_IP_HEADERS => $ipHeaders,
145 8
            self::DATA_KEY_PROTOCOL_HEADERS => $this->prepareProtocolHeaders($protocolHeaders ?? self::DEFAULT_PROTOCOL_HEADERS),
146 8
            self::DATA_KEY_TRUSTED_HEADERS => $trustedHeaders ?? self::DEFAULT_TRUSTED_HEADERS,
147 8
            self::DATA_KEY_HOST_HEADERS => $hostHeaders ?? self::DEFAULT_HOST_HEADERS,
148 8
            self::DATA_KEY_URL_HEADERS => $urlHeaders ?? self::DEFAULT_URL_HEADERS,
149
        ];
150 8
        return $new;
151
    }
152
153
    /**
154
     * @return static
155
     */
156
    public function withoutTrustedHosts()
157
    {
158
        $new = clone $this;
159
        $new->trustedHosts = [];
160
        return $new;
161
    }
162
163
    /**
164
     * @return static
165
     */
166
    public function withAttributeIps(?string $attribute)
167
    {
168
        if ($attribute === '') {
169
            throw new \RuntimeException('Attribute should not be empty');
170
        }
171
        $new = clone $this;
172
        $new->attributeIps = $attribute;
173
        return $new;
174
    }
175
176
    /**
177
     * Process an incoming server request.
178
     *
179
     * Processes an incoming server request in order to produce a response.
180
     * If unable to produce the response itself, it may delegate to the provided
181
     * request handler to do so.
182
     */
183 10
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
184
    {
185 10
        $actualHost = $request->getServerParams()['REMOTE_ADDR'];
186 10
        $trustedHostData = null;
187 10
        $trustedHeaders = [];
188 10
        $ipValidator = $this->ipValidator ?? new Ip();
189 10
        foreach ($this->trustedHosts as $data) {
190
            // collect all trusted headers
191 8
            $trustedHeaders = array_merge($trustedHeaders, $data[self::DATA_KEY_TRUSTED_HEADERS]);
192 8
            if ($trustedHostData !== null) {
193
                // trusted hosts already found
194
                continue;
195
            }
196 8
            if ($this->isValidHost($actualHost, $data[self::DATA_KEY_HOSTS], $ipValidator)) {
197 6
                $trustedHostData = $data;
198
            }
199
        }
200 10
        $untrustedHeaders = array_diff($trustedHeaders, $trustedHostData[self::DATA_KEY_TRUSTED_HEADERS] ?? []);
201 10
        $request = $this->removeHeaders($request, $untrustedHeaders);
202 10
        if ($trustedHostData === null) {
203
            // No trusted host at all.
204 4
            if ($this->notTrustedBranch !== null) {
205 1
                return $this->notTrustedBranch->process($request, $handler);
206
            }
207 3
            $response = $this->responseFactory->createResponse(412);
208 3
            $response->getBody()->write('Unable to verify your network.');
209 3
            return $response;
210
        }
211 6
        [$type, $ipList] = $this->getIpList($request, $trustedHostData[self::DATA_KEY_IP_HEADERS]);
212 6
        $ipList = array_reverse($ipList);       // the first item should be the closest to the server
213 6
        if ($type === null) {
214 2
            $ipList = $this->getFormattedIpList($ipList);
215 4
        } elseif ($type === self::IP_HEADER_TYPE_RFC7239) {
216 4
            $ipList = $this->getForwardedElements($ipList);
217
        }
218 6
        array_unshift($ipList, ['ip' => $actualHost]);  // server's ip to first position
219 6
        $ipDataList = [];
220
        do {
221 6
            $ipData = array_shift($ipList);
222 6
            if (!isset($ipData['ip'])) {
223
                $ipData = $this->reverseObfuscate($ipData, $ipDataList, $ipList, $request);
224
                if (!isset($ipData['ip'])) {
225
                    break;
226
                }
227
            }
228 6
            $ip = $ipData['ip'];
229 6
            if (!$this->isValidHost($ip, ['any'], $ipValidator)) {
230
                break;
231
            }
232 6
            $ipDataList[] = $ipData;
233 6
            if (!$this->isValidHost($ip, $trustedHostData[self::DATA_KEY_HOSTS], $ipValidator)) {
234 6
                break;
235
            }
236 6
        } while (count($ipList) > 0);
237
238 6
        if ($this->attributeIps !== null) {
239
            $request = $request->withAttribute($this->attributeIps, $ipDataList);
240
        }
241
242 6
        $uri = $request->getUri();
243 6
        if (isset($ipData['httpHost'])) {
244 2
            $uri = $uri->withHost($ipData['httpHost']);
245
        } else {
246
            // find host from headers
247 4
            $host = $this->getHttpHost($request, $trustedHostData[self::DATA_KEY_HOST_HEADERS]);
248 4
            if ($host !== null) {
249
                $uri = $uri->withHost($host);
250
            }
251
        }
252 6
        if (isset($ipData['protocol'])) {
253 2
            $uri = $uri->withScheme($ipData['protocol']);
254
        } else {
255
            // find scheme from headers
256 4
            $scheme = $this->getScheme($request, $trustedHostData[self::DATA_KEY_PROTOCOL_HEADERS]);
257 4
            if ($scheme !== null) {
258
                $uri = $uri->withScheme($scheme);
259
            }
260
        }
261 6
        $urlParts = $this->getUrl($request, $trustedHostData[self::DATA_KEY_URL_HEADERS]);
262 6
        if ($urlParts !== null) {
263 1
            [$path, $query] = $urlParts;
264 1
            $uri = $uri->withPath($path);
265 1
            if ($query !== null) {
266 1
                $uri = $uri->withQuery($query);
267
            }
268
        }
269 6
        return $handler->handle($request->withUri($uri)->withAttribute('requestClientIp', $ipData['ip']));
270
    }
271
272
    /**
273
     * Validate host by range
274
     *
275
     * This method can be extendable by overwriting eg. with reverse DNS verification.
276
     */
277 8
    protected function isValidHost(string $host, array $ranges, Ip $validator): bool
278
    {
279 8
        return $validator->ranges($ranges)->validate($host)->isValid();
280
    }
281
282
    /**
283
     * Reverse obfuscating host data
284
     *
285
     * The base operation does not perform any transformation on the data.
286
     * This method can be extendable by overwriting eg.
287
     */
288
    protected function reverseObfuscate(
289
        array $ipData,
290
        array $ipDataListValidated,
0 ignored issues
show
Unused Code introduced by
The parameter $ipDataListValidated is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

290
        /** @scrutinizer ignore-unused */ array $ipDataListValidated,

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
291
        array $ipDataListRemaining,
0 ignored issues
show
Unused Code introduced by
The parameter $ipDataListRemaining is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

291
        /** @scrutinizer ignore-unused */ array $ipDataListRemaining,

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
292
        RequestInterface $request
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

292
        /** @scrutinizer ignore-unused */ RequestInterface $request

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
293
    ): array {
294
        return $ipData;
295
    }
296
297 8
    private function prepareProtocolHeaders(array $protocolHeaders): array
298
    {
299 8
        $output = [];
300 8
        foreach ($protocolHeaders as $header => $protocolAndAcceptedValues) {
301 8
            $header = strtolower($header);
302 8
            if (\is_callable($protocolAndAcceptedValues)) {
303
                $output[$header] = $protocolAndAcceptedValues;
304
                continue;
305
            }
306 8
            if (!\is_array($protocolAndAcceptedValues)) {
307
                throw new \RuntimeException('Accepted values is not an array nor callable');
308
            }
309 8
            if (count($protocolAndAcceptedValues) === 0) {
310
                throw new \RuntimeException('Accepted values cannot be an empty array');
311
            }
312 8
            $output[$header] = [];
313 8
            foreach ($protocolAndAcceptedValues as $protocol => $acceptedValues) {
314 8
                if (!\is_string($protocol)) {
315
                    throw new \RuntimeException('The protocol must be a string');
316
                }
317 8
                if ($protocol === '') {
318
                    throw new \RuntimeException('The protocol cannot be empty');
319
                }
320 8
                $output[$header][$protocol] = array_map('strtolower', (array)$acceptedValues);
321
            }
322
        }
323 8
        return $output;
324
    }
325
326 10
    private function removeHeaders(ServerRequestInterface $request, array $headers): ServerRequestInterface
327
    {
328 10
        foreach ($headers as $header) {
329 2
            $request = $request->withoutAttribute($header);
330
        }
331 10
        return $request;
332
    }
333
334 6
    private function getIpList(RequestInterface $request, array $ipHeaders): array
335
    {
336 6
        foreach ($ipHeaders as $ipHeader) {
337 6
            $type = null;
338 6
            if (\is_array($ipHeader)) {
339 6
                $type = array_shift($ipHeader);
340 6
                $ipHeader = array_shift($ipHeader);
341
            }
342 6
            if ($request->hasHeader($ipHeader)) {
343 6
                return [$type, $request->getHeader($ipHeader)];
344
            }
345
        }
346
        return [null, []];
347
    }
348
349 2
    private function getFormattedIpList(array $forwards): array
350
    {
351 2
        $list = [];
352 2
        foreach ($forwards as $ip) {
353 2
            $list[] = ['ip' => $ip];
354
        }
355 2
        return $list;
356
    }
357
358
    /**
359
     * Forwarded elements by RFC7239
360
     *
361
     * @link https://tools.ietf.org/html/rfc7239
362
     */
363 4
    private function getForwardedElements(array $forwards): array
364
    {
365 4
        $list = [];
366 4
        foreach ($forwards as $forward) {
367 4
            $data = HeaderHelper::getParameters($forward);
368 4
            if (!isset($data['for'])) {
369
                // Invalid item, the following items will be dropped
370
                break;
371
            }
372 4
            $pattern = '/^(?<host>' . IpHelper::IPV4_PATTERN . '|unknown|_[\w\.-]+|[[]' . IpHelper::IPV6_PATTERN . '[]])(?::(?<port>[\w\.-]+))?$/';
373 4
            if (preg_match($pattern, $data['for'], $matches) === 0) {
374
                // Invalid item, the following items will be dropped
375
                break;
376
            }
377 4
            $ipData = [];
378 4
            $host = $matches['host'];
379 4
            $obfuscatedHost = $host === 'unknown' || strpos($host, '_') === 0;
380 4
            if (!$obfuscatedHost) {
381
                // IPv4 & IPv6
382 4
                $ipData['ip'] = strpos($host, '[') === 0 ? trim($host /* IPv6 */, '[]') : $host;
383
            }
384 4
            $ipData['host'] = $host;
385 4
            if (isset($matches['port'])) {
386
                $port = $matches['port'];
387
                if (!$obfuscatedHost && (preg_match('/^\d{1,5}$/', $port) === 0 || (int)$port > 65535)) {
388
                    // Invalid port, the following items will be dropped
389
                    break;
390
                }
391
                $ipData['port'] = $obfuscatedHost ? $port : (int)$port;
392
            }
393
394
            // copy other properties
395 4
            foreach (['proto' => 'protocol', 'host' => 'httpHost', 'by' => 'by'] as $source => $destination) {
396 4
                if (isset($data[$source])) {
397 2
                    $ipData[$destination] = $data[$source];
398
                }
399
            }
400
401 4
            $list[] = $ipData;
402
        }
403 4
        return $list;
404
    }
405
406 4
    private function getHttpHost(RequestInterface $request, array $hostHeaders): ?string
407
    {
408 4
        foreach ($hostHeaders as $header) {
409 4
            if (!$request->hasHeader($header)) {
410 4
                continue;
411
            }
412
            $host = $request->getHeaderLine($header);
413
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) !== false) {
414
                return $host;
415
            }
416
        }
417 4
        return null;
418
    }
419
420 4
    private function getScheme(RequestInterface $request, array $protocolHeaders): ?string
421
    {
422 4
        foreach ($protocolHeaders as $header => $ref) {
423 4
            if (!$request->hasHeader($header)) {
424 4
                continue;
425
            }
426
            $value = strtolower($request->getHeaderLine($header));
427
            foreach ($ref as $protocol => $acceptedValues) {
428
                if (\in_array($value, $acceptedValues, true)) {
429
                    return $protocol;
430
                }
431
            }
432
        }
433 4
        return null;
434
    }
435
436 6
    private function getUrl(RequestInterface $request, array $urlHeaders): ?array
437
    {
438 6
        foreach ($urlHeaders as $header) {
439 6
            if (!$request->hasHeader($header)) {
440 5
                continue;
441
            }
442 1
            $url = $request->getHeaderLine($header);
443 1
            if (strpos($url, '/') === 0) {
444 1
                return array_pad(explode('?', $url, 2), 2, null);
445
            }
446
        }
447 5
        return null;
448
    }
449
}
450