getElementsByRfc7239()   C
last analyzed

Complexity

Conditions 15
Paths 9

Size

Total Lines 45
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 23
CRAP Score 15.7308

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 15
eloc 26
c 1
b 0
f 0
nc 9
nop 1
dl 0
loc 45
ccs 23
cts 27
cp 0.8519
crap 15.7308
rs 5.9166

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
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\HeaderValueHelper;
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
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
     *
98
     * @return static
99
     */
100 26
    public function withAddedTrustedHosts(
101
        array $hosts,
102
        // Defining default headers is not secure!
103
        array $ipHeaders = [],
104
        array $protocolHeaders = [],
105
        array $hostHeaders = [],
106
        array $urlHeaders = [],
107
        array $portHeaders = [],
108
        ?array $trustedHeaders = null
109
    ): self {
110 26
        $new = clone $this;
111 26
        foreach ($ipHeaders as $ipHeader) {
112 10
            if (\is_string($ipHeader)) {
113 4
                continue;
114
            }
115 6
            if (!\is_array($ipHeader)) {
116
                throw new \InvalidArgumentException('Type of ipHeader is not a string and not array');
117
            }
118 6
            if (count($ipHeader) !== 2) {
119
                throw new \InvalidArgumentException('The ipHeader array must have exactly 2 elements');
120
            }
121 6
            [$type, $header] = $ipHeader;
122 6
            if (!\is_string($type)) {
123
                throw new \InvalidArgumentException('The type is not a string');
124
            }
125 6
            if (!\is_string($header)) {
126
                throw new \InvalidArgumentException('The header is not a string');
127
            }
128 6
            if ($type === self::IP_HEADER_TYPE_RFC7239) {
129 6
                continue;
130
            }
131
132
            throw new \InvalidArgumentException("Not supported IP header type: $type");
133
        }
134 26
        if (count($hosts) === 0) {
135 16
            throw new \InvalidArgumentException('Empty hosts not allowed');
136
        }
137 10
        $trustedHeaders = $trustedHeaders ?? self::DEFAULT_TRUSTED_HEADERS;
138 10
        $protocolHeaders = $this->prepareProtocolHeaders($protocolHeaders);
139 10
        $this->checkTypeStringOrArray($hosts, 'hosts');
140 10
        $this->checkTypeStringOrArray($trustedHeaders, 'trustedHeaders');
141 10
        $this->checkTypeStringOrArray($hostHeaders, 'hostHeaders');
142 10
        $this->checkTypeStringOrArray($urlHeaders, 'urlHeaders');
143 10
        $this->checkTypeStringOrArray($portHeaders, 'portHeaders');
144
145 10
        foreach ($hosts as $host) {
146 10
            $host = str_replace('*', 'wildcard', $host);        // wildcard is allowed in host
147 10
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) === false) {
148
                throw new \InvalidArgumentException("'$host' host is not a domain and not an IP address");
149
            }
150
        }
151 10
        $new->trustedHosts[] = [
152 10
            self::DATA_KEY_HOSTS => $hosts,
153 10
            self::DATA_KEY_IP_HEADERS => $ipHeaders,
154 10
            self::DATA_KEY_PROTOCOL_HEADERS => $protocolHeaders,
155 10
            self::DATA_KEY_TRUSTED_HEADERS => $trustedHeaders,
156 10
            self::DATA_KEY_HOST_HEADERS => $hostHeaders,
157 10
            self::DATA_KEY_URL_HEADERS => $urlHeaders,
158 10
            self::DATA_KEY_PORT_HEADERS => $portHeaders,
159
        ];
160 10
        return $new;
161
    }
162
163 10
    private function checkTypeStringOrArray(array $array, string $field): void
164
    {
165 10
        foreach ($array as $item) {
166 10
            if (!is_string($item)) {
167
                throw new \InvalidArgumentException("$field must be string type");
168
            }
169 10
            if (trim($item) === '') {
170
                throw new \InvalidArgumentException("$field cannot be empty strings");
171
            }
172
        }
173 10
    }
174
175
    public function withoutTrustedHosts(): self
176
    {
177
        $new = clone $this;
178
        $new->trustedHosts = [];
179
        return $new;
180
    }
181
182
    /**
183
     * Request's attribute name to which trusted path data is added.
184
     *
185
     * The list starts with the server and the last item is the client itself.
186
     *
187
     * @return static
188
     *
189
     * @see getElementsByRfc7239
190
     */
191
    public function withAttributeIps(?string $attribute): self
192
    {
193
        if ($attribute === '') {
194
            throw new \RuntimeException('Attribute should not be empty');
195
        }
196
        $new = clone $this;
197
        $new->attributeIps = $attribute;
198
        return $new;
199
    }
200
201 11
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
202
    {
203 11
        $actualHost = $request->getServerParams()['REMOTE_ADDR'] ?? null;
204 11
        if ($actualHost === null) {
205
            // Validation is not possible.
206
            return $this->handleNotTrusted($request, $handler);
207
        }
208
209 11
        $trustedHostData = null;
210 11
        $trustedHeaders = [];
211 11
        $ipValidator = ($this->ipValidator ?? Ip::rule())->disallowSubnet()->disallowNegation();
212 11
        foreach ($this->trustedHosts as $data) {
213
            // collect all trusted headers
214 10
            $trustedHeaders = array_merge($trustedHeaders, $data[self::DATA_KEY_TRUSTED_HEADERS]);
215 10
            if ($trustedHostData !== null) {
216
                // trusted hosts already found
217
                continue;
218
            }
219 10
            if ($this->isValidHost($actualHost, $data[self::DATA_KEY_HOSTS], $ipValidator)) {
220 8
                $trustedHostData = $data;
221
            }
222
        }
223 11
        $untrustedHeaders = array_diff($trustedHeaders, $trustedHostData[self::DATA_KEY_TRUSTED_HEADERS] ?? []);
224 11
        $request = $this->removeHeaders($request, $untrustedHeaders);
225 11
        if ($trustedHostData === null) {
226
            // No trusted host at all.
227 3
            return $this->handleNotTrusted($request, $handler);
228
        }
229 8
        [$ipListType, $ipHeader, $hostList] = $this->getIpList($request, $trustedHostData[self::DATA_KEY_IP_HEADERS]);
230 8
        $hostList = array_reverse($hostList);       // the first item should be the closest to the server
231 8
        if ($ipListType === null) {
232 2
            $hostList = $this->getFormattedIpList($hostList);
233 6
        } elseif ($ipListType === self::IP_HEADER_TYPE_RFC7239) {
234 6
            $hostList = $this->getElementsByRfc7239($hostList);
235
        }
236 8
        array_unshift($hostList, ['ip' => $actualHost]);  // server's ip to first position
237 8
        $hostDataList = [];
238
        do {
239 8
            $hostData = array_shift($hostList);
240 8
            if (!isset($hostData['ip'])) {
241
                $hostData = $this->reverseObfuscate($hostData, $hostDataList, $hostList, $request);
242
                if ($hostData === null) {
243
                    continue;
244
                }
245
                if (!isset($hostData['ip'])) {
246
                    break;
247
                }
248
            }
249 8
            $ip = $hostData['ip'];
250 8
            if (!$this->isValidHost($ip, ['any'], $ipValidator)) {
251
                // invalid IP
252
                break;
253
            }
254 8
            $hostDataList[] = $hostData;
255 8
            if (!$this->isValidHost($ip, $trustedHostData[self::DATA_KEY_HOSTS], $ipValidator)) {
256
                // not trusted host
257 8
                break;
258
            }
259 8
        } while (count($hostList) > 0);
260
261 8
        if ($this->attributeIps !== null) {
262
            $request = $request->withAttribute($this->attributeIps, $hostDataList);
263
        }
264
265 8
        $uri = $request->getUri();
266
        // find HTTP host
267 8
        foreach ($trustedHostData[self::DATA_KEY_HOST_HEADERS] as $hostHeader) {
268 4
            if (!$request->hasHeader($hostHeader)) {
269
                continue;
270
            }
271 4
            if ($hostHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($hostData['httpHost'])) {
272 2
                $uri = $uri->withHost($hostData['httpHost']);
273 2
                break;
274
            }
275 2
            $host = $request->getHeaderLine($hostHeader);
276 2
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) !== false) {
277 2
                $uri = $uri->withHost($host);
278 2
                break;
279
            }
280
        }
281
282
        // find protocol
283 8
        foreach ($trustedHostData[self::DATA_KEY_PROTOCOL_HEADERS] as $protocolHeader => $protocols) {
284 4
            if (!$request->hasHeader($protocolHeader)) {
285
                continue;
286
            }
287 4
            if ($protocolHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($hostData['protocol'])) {
288 2
                $uri = $uri->withScheme($hostData['protocol']);
289 2
                break;
290
            }
291 2
            $protocolHeaderValue = $request->getHeaderLine($protocolHeader);
292 2
            foreach ($protocols as $protocol => $acceptedValues) {
293 2
                if (\in_array($protocolHeaderValue, $acceptedValues, true)) {
294 2
                    $uri = $uri->withScheme($protocol);
295 2
                    break 2;
296
                }
297
            }
298
        }
299 8
        $urlParts = $this->getUrl($request, $trustedHostData[self::DATA_KEY_URL_HEADERS]);
300 8
        if ($urlParts !== null) {
301 3
            [$path, $query] = $urlParts;
302 3
            $uri = $uri->withPath($path);
303 3
            if ($query !== null) {
304 3
                $uri = $uri->withQuery($query);
305
            }
306
        }
307
308
        // find port
309 8
        foreach ($trustedHostData[self::DATA_KEY_PORT_HEADERS] as $portHeader) {
310 1
            if (!$request->hasHeader($portHeader)) {
311
                continue;
312
            }
313 1
            if ($portHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($hostData['port']) && $this->checkPort((string)$hostData['port'])) {
314 1
                $uri = $uri->withPort($hostData['port']);
315 1
                break;
316
            }
317
            $port = $request->getHeaderLine($portHeader);
318
            if ($this->checkPort($port)) {
319
                $uri = $uri->withPort((int)$port);
320
                break;
321
            }
322
        }
323
324 8
        return $handler->handle($request->withUri($uri)->withAttribute('requestClientIp', $hostData['ip']));
325
    }
326
327
    /**
328
     * Validate host by range
329
     *
330
     * This method can be extendable by overwriting eg. with reverse DNS verification.
331
     */
332 10
    protected function isValidHost(string $host, array $ranges, Ip $validator): bool
333
    {
334 10
        return $validator->ranges($ranges)->validate($host)->isValid();
335
    }
336
337
    /**
338
     * Reverse obfuscating host data
339
     *
340
     * RFC 7239 allows to use obfuscated host data. In this case, either specifying the
341
     * IP address or dropping the proxy endpoint is required to determine validated route.
342
     *
343
     * By default it does not perform any transformation on the data. You can override this method.
344
     *
345
     * @param array $hostData
346
     * @param array $hostDataListValidated
347
     * @param array $hostDataListRemaining
348
     * @param RequestInterface $request
349
     *
350
     * @return array|null reverse obfuscated host data or null.
351
     * In case of null data is discarded and the process continues with the next portion of host data.
352
     * If the return value is an array, it must contain at least the `ip` key.
353
     *
354
     * @see getElementsByRfc7239
355
     * @link https://tools.ietf.org/html/rfc7239#section-6.2
356
     * @link https://tools.ietf.org/html/rfc7239#section-6.3
357
     */
358
    protected function reverseObfuscate(
359
        array $hostData,
360
        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

360
        /** @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...
361
        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

361
        /** @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...
362
        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

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