Passed
Push β€” master ( 80aac7...ae3a29 )
by Alexander
08:15 queued 05:14
created

withAddedTrustedHosts()   B

Complexity

Conditions 11
Paths 17

Size

Total Lines 61
Code Lines 38

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 33
CRAP Score 11.4402

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 11
eloc 38
c 1
b 0
f 0
nc 17
nop 7
dl 0
loc 61
ccs 33
cts 39
cp 0.8462
crap 11.4402
rs 7.3166

How to fix   Long Method    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
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Web\Middleware;
6
7
use Psr\Http\Message\RequestInterface;
8
use Psr\Http\Message\ResponseInterface;
9
use Psr\Http\Message\ServerRequestInterface;
10
use Psr\Http\Server\MiddlewareInterface;
11
use Psr\Http\Server\RequestHandlerInterface;
12
use Yiisoft\Http\HeaderHelper;
13
use Yiisoft\NetworkUtilities\IpHelper;
14
use Yiisoft\Validator\Rule\Ip;
15
16
/**
17
 * Trusted hosts network resolver
18
 *
19
 * ```php
20
 * (new TrustedHostsNetworkResolver($responseFactory))
21
 * ->withAddedTrustedHosts(
22
 *   // List of secure hosts including $ _SERVER['REMOTE_ADDR'], can specify IPv4, IPv6, domains and aliases (see {{Ip}})
23
 *   ['1.1.1.1', '2.2.2.1/3', '2001::/32', 'localhost']
24
 *   // IP list headers. For advanced handling headers, see the constants IP_HEADER_TYPE_ *.
25
 *   // Headers containing multiple sub-elements (eg RFC 7239) must also be listed for other relevant types
26
 *   // (eg. host headers), otherwise they will only be used as an IP list.
27
 *   ['x-forwarded-for', [TrustedHostsNetworkResolver::IP_HEADER_TYPE_RFC7239, 'forwarded']]
28
 *   // protocol headers with accepted protocols and values. Matching of values ​​is case insensitive.
29
 *   ['front-end-https' => ['https' => 'on']],
30
 *   // Host headers
31
 *   ['forwarded', 'x-forwarded-for']
32
 *   // URL headers
33
 *   ['x-rewrite-url'],
34
 *   // Trusted headers. It is a good idea to list all relevant headers.
35
 *   ['x-forwarded-for', 'forwarded', ...]
36
 * );
37
 * ->withAddedTrustedHosts(...)
38
 * ;
39
 * ```
40
 */
41
final class TrustedHostsNetworkResolver implements MiddlewareInterface
42
{
43
    public const IP_HEADER_TYPE_RFC7239 = 'rfc7239';
44
45
    public const DEFAULT_TRUSTED_HEADERS = [
46
        // common:
47
        'x-forwarded-for',
48
        'x-forwarded-host',
49
        'x-forwarded-proto',
50
        'x-forwarded-port',
51
52
        // RFC:
53
        'forward',
54
55
        // Microsoft:
56
        'front-end-https',
57
        'x-rewrite-url',
58
    ];
59
60
    private const DATA_KEY_HOSTS = 'hosts';
61
    private const DATA_KEY_IP_HEADERS = 'ipHeaders';
62
    private const DATA_KEY_HOST_HEADERS = 'hostHeaders';
63
    private const DATA_KEY_URL_HEADERS = 'urlHeaders';
64
    private const DATA_KEY_PROTOCOL_HEADERS = 'protocolHeaders';
65
    private const DATA_KEY_TRUSTED_HEADERS = 'trustedHeaders';
66
    private const DATA_KEY_PORT_HEADERS = 'portHeaders';
67
68
    private array $trustedHosts = [];
69
70
    private ?string $attributeIps = null;
71
72
    private ?Ip $ipValidator = null;
73
74
    public function withIpValidator(Ip $ipValidator): self
75
    {
76
        $new = clone $this;
77
        $new->ipValidator = $ipValidator;
78
        return $new;
79
    }
80
81
    /**
82
     * With added trusted hosts and related headers
83
     *
84
     * The header lists are evaluated in the order they were specified.
85
     * If you specify multiple headers by type (eg IP headers), you must ensure that the irrelevant header is removed
86
     * eg. web server application, otherwise spoof clients can be use this vulnerability.
87
     *
88
     * @param string[] $hosts List of trusted hosts IP addresses. If `isValidHost` is extended, then can use
89
     * domain names with reverse DNS resolving eg. yiiframework.com, * .yiiframework.com.
90
     * @param array $ipHeaders List of headers containing IP lists.
91
     * @param array $protocolHeaders List of headers containing protocol. eg. ['x-forwarded-for' => ['http' => 'http', 'https' => ['on', 'https']]]
92
     * @param string[] $hostHeaders List of headers containing HTTP host.
93
     * @param string[] $urlHeaders List of headers containing HTTP URL.
94
     * @param string[] $portHeaders List of headers containing port number.
95
     * @param string[]|null $trustedHeaders List of trusted headers. Removed from the request, if in checking process
96
     * are classified as untrusted by hosts.
97
     * @return static
98
     */
99 26
    public function withAddedTrustedHosts(
100
        array $hosts,
101
        // Defining default headers is not secure!
102
        array $ipHeaders = [],
103
        array $protocolHeaders = [],
104
        array $hostHeaders = [],
105
        array $urlHeaders = [],
106
        array $portHeaders = [],
107
        ?array $trustedHeaders = null
108
    ): self {
109 26
        $new = clone $this;
110 26
        foreach ($ipHeaders as $ipHeader) {
111 10
            if (\is_string($ipHeader)) {
112 4
                continue;
113
            }
114 6
            if (!\is_array($ipHeader)) {
115
                throw new \InvalidArgumentException('Type of ipHeader is not a string and not array');
116
            }
117 6
            if (count($ipHeader) !== 2) {
118
                throw new \InvalidArgumentException('The ipHeader array must have exactly 2 elements');
119
            }
120 6
            [$type, $header] = $ipHeader;
121 6
            if (!\is_string($type)) {
122
                throw new \InvalidArgumentException('The type is not a string');
123
            }
124 6
            if (!\is_string($header)) {
125
                throw new \InvalidArgumentException('The header is not a string');
126
            }
127 6
            if ($type === self::IP_HEADER_TYPE_RFC7239) {
128 6
                continue;
129
            }
130
131
            throw new \InvalidArgumentException("Not supported IP header type: $type");
132
        }
133 26
        if (count($hosts) === 0) {
134 16
            throw new \InvalidArgumentException('Empty hosts not allowed');
135
        }
136 10
        $trustedHeaders = $trustedHeaders ?? self::DEFAULT_TRUSTED_HEADERS;
137 10
        $protocolHeaders = $this->prepareProtocolHeaders($protocolHeaders);
138 10
        $this->checkTypeStringOrArray($hosts, 'hosts');
139 10
        $this->checkTypeStringOrArray($trustedHeaders, 'trustedHeaders');
140 10
        $this->checkTypeStringOrArray($hostHeaders, 'hostHeaders');
141 10
        $this->checkTypeStringOrArray($urlHeaders, 'urlHeaders');
142 10
        $this->checkTypeStringOrArray($portHeaders, 'portHeaders');
143
144 10
        foreach ($hosts as $host) {
145 10
            $host = str_replace('*', 'wildcard', $host);        // wildcard is allowed in host
146 10
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) === false) {
147
                throw new \InvalidArgumentException("'$host' host is not a domain and not an IP address");
148
            }
149
        }
150 10
        $new->trustedHosts[] = [
151 10
            self::DATA_KEY_HOSTS => $hosts,
152 10
            self::DATA_KEY_IP_HEADERS => $ipHeaders,
153 10
            self::DATA_KEY_PROTOCOL_HEADERS => $protocolHeaders,
154 10
            self::DATA_KEY_TRUSTED_HEADERS => $trustedHeaders,
155 10
            self::DATA_KEY_HOST_HEADERS => $hostHeaders,
156 10
            self::DATA_KEY_URL_HEADERS => $urlHeaders,
157 10
            self::DATA_KEY_PORT_HEADERS => $portHeaders,
158
        ];
159 10
        return $new;
160
    }
161
162 10
    private function checkTypeStringOrArray(array $array, string $field): void
163
    {
164 10
        foreach ($array as $item) {
165 10
            if (!is_string($item)) {
166
                throw new \InvalidArgumentException("$field must be string type");
167
            }
168 10
            if (trim($item) === '') {
169
                throw new \InvalidArgumentException("$field cannot be empty strings");
170
            }
171
        }
172 10
    }
173
174
    public function withoutTrustedHosts(): self
175
    {
176
        $new = clone $this;
177
        $new->trustedHosts = [];
178
        return $new;
179
    }
180
181
    /**
182
     * Request's attribute name to which trusted path data is added.
183
     *
184
     * The list starts with the server and the last item is the client itself.
185
     *
186
     * @return static
187
     * @see getElementsByRfc7239
188
     */
189
    public function withAttributeIps(?string $attribute): self
190
    {
191
        if ($attribute === '') {
192
            throw new \RuntimeException('Attribute should not be empty');
193
        }
194
        $new = clone $this;
195
        $new->attributeIps = $attribute;
196
        return $new;
197
    }
198
199 11
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
200
    {
201 11
        $actualHost = $request->getServerParams()['REMOTE_ADDR'] ?? null;
202 11
        if ($actualHost === null) {
203
            // Validation is not possible.
204
            return $this->handleNotTrusted($request, $handler);
205
        }
206
207 11
        $trustedHostData = null;
208 11
        $trustedHeaders = [];
209 11
        $ipValidator = ($this->ipValidator ?? new Ip())->disallowSubnet()->disallowNegation();
210 11
        foreach ($this->trustedHosts as $data) {
211
            // collect all trusted headers
212 10
            $trustedHeaders = array_merge($trustedHeaders, $data[self::DATA_KEY_TRUSTED_HEADERS]);
213 10
            if ($trustedHostData !== null) {
214
                // trusted hosts already found
215
                continue;
216
            }
217 10
            if ($this->isValidHost($actualHost, $data[self::DATA_KEY_HOSTS], $ipValidator)) {
218 8
                $trustedHostData = $data;
219
            }
220
        }
221 11
        $untrustedHeaders = array_diff($trustedHeaders, $trustedHostData[self::DATA_KEY_TRUSTED_HEADERS] ?? []);
222 11
        $request = $this->removeHeaders($request, $untrustedHeaders);
223 11
        if ($trustedHostData === null) {
224
            // No trusted host at all.
225 3
            return $this->handleNotTrusted($request, $handler);
226
        }
227 8
        [$ipListType, $ipHeader, $hostList] = $this->getIpList($request, $trustedHostData[self::DATA_KEY_IP_HEADERS]);
228 8
        $hostList = array_reverse($hostList);       // the first item should be the closest to the server
229 8
        if ($ipListType === null) {
230 2
            $hostList = $this->getFormattedIpList($hostList);
231 6
        } elseif ($ipListType === self::IP_HEADER_TYPE_RFC7239) {
232 6
            $hostList = $this->getElementsByRfc7239($hostList);
233
        }
234 8
        array_unshift($hostList, ['ip' => $actualHost]);  // server's ip to first position
235 8
        $hostDataList = [];
236
        do {
237 8
            $hostData = array_shift($hostList);
238 8
            if (!isset($hostData['ip'])) {
239
                $hostData = $this->reverseObfuscate($hostData, $hostDataList, $hostList, $request);
240
                if ($hostData === null) {
241
                    continue;
242
                }
243
                if (!isset($hostData['ip'])) {
244
                    break;
245
                }
246
            }
247 8
            $ip = $hostData['ip'];
248 8
            if (!$this->isValidHost($ip, ['any'], $ipValidator)) {
249
                // invalid IP
250
                break;
251
            }
252 8
            $hostDataList[] = $hostData;
253 8
            if (!$this->isValidHost($ip, $trustedHostData[self::DATA_KEY_HOSTS], $ipValidator)) {
254
                // not trusted host
255 8
                break;
256
            }
257 8
        } while (count($hostList) > 0);
258
259 8
        if ($this->attributeIps !== null) {
260
            $request = $request->withAttribute($this->attributeIps, $hostDataList);
261
        }
262
263 8
        $uri = $request->getUri();
264
        // find HTTP host
265 8
        foreach ($trustedHostData[self::DATA_KEY_HOST_HEADERS] as $hostHeader) {
266 4
            if (!$request->hasHeader($hostHeader)) {
267
                continue;
268
            }
269 4
            if ($hostHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($hostData['httpHost'])) {
270 2
                $uri = $uri->withHost($hostData['httpHost']);
271 2
                break;
272
            }
273 2
            $host = $request->getHeaderLine($hostHeader);
274 2
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) !== false) {
275 2
                $uri = $uri->withHost($host);
276 2
                break;
277
            }
278
        }
279
280
        // find protocol
281 8
        foreach ($trustedHostData[self::DATA_KEY_PROTOCOL_HEADERS] as $protocolHeader => $protocols) {
282 4
            if (!$request->hasHeader($protocolHeader)) {
283
                continue;
284
            }
285 4
            if ($protocolHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($hostData['protocol'])) {
286 2
                $uri = $uri->withScheme($hostData['protocol']);
287 2
                break;
288
            }
289 2
            $protocolHeaderValue = $request->getHeaderLine($protocolHeader);
290 2
            foreach ($protocols as $protocol => $acceptedValues) {
291 2
                if (\in_array($protocolHeaderValue, $acceptedValues, true)) {
292 2
                    $uri = $uri->withScheme($protocol);
293 2
                    break 2;
294
                }
295
            }
296
        }
297 8
        $urlParts = $this->getUrl($request, $trustedHostData[self::DATA_KEY_URL_HEADERS]);
298 8
        if ($urlParts !== null) {
299 3
            [$path, $query] = $urlParts;
300 3
            $uri = $uri->withPath($path);
301 3
            if ($query !== null) {
302 3
                $uri = $uri->withQuery($query);
303
            }
304
        }
305
306
        // find port
307 8
        foreach ($trustedHostData[self::DATA_KEY_PORT_HEADERS] as $portHeader) {
308 1
            if (!$request->hasHeader($portHeader)) {
309
                continue;
310
            }
311 1
            if ($portHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($hostData['port']) && $this->checkPort((string)$hostData['port'])) {
312 1
                $uri = $uri->withPort($hostData['port']);
313 1
                break;
314
            }
315
            $port = $request->getHeaderLine($portHeader);
316
            if ($this->checkPort($port)) {
317
                $uri = $uri->withPort((int)$port);
318
                break;
319
            }
320
        }
321
322 8
        return $handler->handle($request->withUri($uri)->withAttribute('requestClientIp', $hostData['ip']));
323
    }
324
325
    /**
326
     * Validate host by range
327
     *
328
     * This method can be extendable by overwriting eg. with reverse DNS verification.
329
     */
330 10
    protected function isValidHost(string $host, array $ranges, Ip $validator): bool
331
    {
332 10
        return $validator->ranges($ranges)->validate($host)->isValid();
333
    }
334
335
    /**
336
     * Reverse obfuscating host data
337
     *
338
     * RFC 7239 allows to use obfuscated host data. In this case, either specifying the
339
     * IP address or dropping the proxy endpoint is required to determine validated route.
340
     *
341
     * By default it does not perform any transformation on the data. You can override this method.
342
     *
343
     * @param array $hostData
344
     * @param array $hostDataListValidated
345
     * @param array $hostDataListRemaining
346
     * @param RequestInterface $request
347
     * @return array|null reverse obfuscated host data or null.
348
     * In case of null data is discarded and the process continues with the next portion of host data.
349
     * If the return value is an array, it must contain at least the `ip` key.
350
     *
351
     * @see getElementsByRfc7239
352
     * @link https://tools.ietf.org/html/rfc7239#section-6.2
353
     * @link https://tools.ietf.org/html/rfc7239#section-6.3
354
     */
355
    protected function reverseObfuscate(
356
        array $hostData,
357
        array $hostDataListValidated,
0 ignored issues
show
Unused Code introduced by
The parameter $hostDataListValidated 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

357
        /** @scrutinizer ignore-unused */ array $hostDataListValidated,

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...
358
        array $hostDataListRemaining,
0 ignored issues
show
Unused Code introduced by
The parameter $hostDataListRemaining 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

358
        /** @scrutinizer ignore-unused */ array $hostDataListRemaining,

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...
359
        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

359
        /** @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...
360
    ): ?array {
361
        return $hostData;
362
    }
363
364 3
    private function handleNotTrusted(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
365
    {
366 3
        if ($this->attributeIps !== null) {
367
            $request = $request->withAttribute($this->attributeIps, null);
368
        }
369 3
        return $handler->handle($request->withAttribute('requestClientIp', null));
370
    }
371
372 10
    private function prepareProtocolHeaders(array $protocolHeaders): array
373
    {
374 10
        $output = [];
375 10
        foreach ($protocolHeaders as $header => $protocolAndAcceptedValues) {
376 4
            $header = strtolower($header);
377 4
            if (\is_callable($protocolAndAcceptedValues)) {
378
                $output[$header] = $protocolAndAcceptedValues;
379
                continue;
380
            }
381 4
            if (!\is_array($protocolAndAcceptedValues)) {
382
                throw new \RuntimeException('Accepted values is not an array nor callable');
383
            }
384 4
            if (count($protocolAndAcceptedValues) === 0) {
385
                throw new \RuntimeException('Accepted values cannot be an empty array');
386
            }
387 4
            $output[$header] = [];
388 4
            foreach ($protocolAndAcceptedValues as $protocol => $acceptedValues) {
389 4
                if (!\is_string($protocol)) {
390
                    throw new \RuntimeException('The protocol must be a string');
391
                }
392 4
                if ($protocol === '') {
393
                    throw new \RuntimeException('The protocol cannot be empty');
394
                }
395 4
                $output[$header][$protocol] = array_map('strtolower', (array)$acceptedValues);
396
            }
397
        }
398 10
        return $output;
399
    }
400
401 11
    private function removeHeaders(ServerRequestInterface $request, array $headers): ServerRequestInterface
402
    {
403 11
        foreach ($headers as $header) {
404
            $request = $request->withoutAttribute($header);
405
        }
406 11
        return $request;
407
    }
408
409 8
    private function getIpList(RequestInterface $request, array $ipHeaders): array
410
    {
411 8
        foreach ($ipHeaders as $ipHeader) {
412 8
            $type = null;
413 8
            if (\is_array($ipHeader)) {
414 6
                $type = array_shift($ipHeader);
415 6
                $ipHeader = array_shift($ipHeader);
416
            }
417 8
            if ($request->hasHeader($ipHeader)) {
418 8
                return [$type, $ipHeader, $request->getHeader($ipHeader)];
419
            }
420
        }
421
        return [null, null, []];
422
    }
423
424
    /**
425
     * @see getElementsByRfc7239
426
     */
427 2
    private function getFormattedIpList(array $forwards): array
428
    {
429 2
        $list = [];
430 2
        foreach ($forwards as $ip) {
431 2
            $list[] = ['ip' => $ip];
432
        }
433 2
        return $list;
434
    }
435
436
    /**
437
     * Forwarded elements by RFC7239
438
     *
439
     * The structure of the elements:
440
     * - `host`: IP or obfuscated hostname or "unknown"
441
     * - `ip`: IP address (only if presented)
442
     * - `by`: used user-agent by proxy (only if presented)
443
     * - `port`: port number received by proxy (only if presented)
444
     * - `protocol`: protocol received by proxy (only if presented)
445
     * - `httpHost`: HTTP host received by proxy (only if presented)
446
     *
447
     * @link https://tools.ietf.org/html/rfc7239
448
     * @return array proxy data elements
449
     */
450 6
    private function getElementsByRfc7239(array $forwards): array
451
    {
452 6
        $list = [];
453 6
        foreach ($forwards as $forward) {
454 6
            $data = HeaderHelper::getParameters($forward);
455 6
            if (!isset($data['for'])) {
456
                // Invalid item, the following items will be dropped
457
                break;
458
            }
459 6
            $pattern = '/^(?<host>' . IpHelper::IPV4_PATTERN . '|unknown|_[\w\.-]+|[[]' . IpHelper::IPV6_PATTERN . '[]])(?::(?<port>[\w\.-]+))?$/';
460 6
            if (preg_match($pattern, $data['for'], $matches) === 0) {
461
                // Invalid item, the following items will be dropped
462
                break;
463
            }
464 6
            $ipData = [];
465 6
            $host = $matches['host'];
466 6
            $obfuscatedHost = $host === 'unknown' || strpos($host, '_') === 0;
467 6
            if (!$obfuscatedHost) {
468
                // IPv4 & IPv6
469 6
                $ipData['ip'] = strpos($host, '[') === 0 ? trim($host /* IPv6 */, '[]') : $host;
470
            }
471 6
            $ipData['host'] = $host;
472 6
            if (isset($matches['port'])) {
473 1
                $port = $matches['port'];
474 1
                if (!$obfuscatedHost && !$this->checkPort($port)) {
475
                    // Invalid port, the following items will be dropped
476
                    break;
477
                }
478 1
                $ipData['port'] = $obfuscatedHost ? $port : (int)$port;
479
            }
480
481
            // copy other properties
482 6
            foreach (['proto' => 'protocol', 'host' => 'httpHost', 'by' => 'by'] as $source => $destination) {
483 6
                if (isset($data[$source])) {
484 4
                    $ipData[$destination] = $data[$source];
485
                }
486
            }
487 6
            if (isset($ipData['httpHost']) && filter_var($ipData['httpHost'], FILTER_VALIDATE_DOMAIN) === false) {
488
                // remove not valid HTTP host
489
                unset($ipData['httpHost']);
490
            }
491
492 6
            $list[] = $ipData;
493
        }
494 6
        return $list;
495
    }
496
497 8
    private function getUrl(RequestInterface $request, array $urlHeaders): ?array
498
    {
499 8
        foreach ($urlHeaders as $header) {
500 3
            if (!$request->hasHeader($header)) {
501
                continue;
502
            }
503 3
            $url = $request->getHeaderLine($header);
504 3
            if (strpos($url, '/') === 0) {
505 3
                return array_pad(explode('?', $url, 2), 2, null);
506
            }
507
        }
508 5
        return null;
509
    }
510
511 1
    private function checkPort(string $port): bool
512
    {
513 1
        return preg_match('/^\d{1,5}$/', $port) === 1 && (int)$port <= 65535;
514
    }
515
}
516