Passed
Pull Request β€” master (#276)
by Alexander
13:06
created

getElementsByRfc7239()   C

Complexity

Conditions 15
Paths 9

Size

Total Lines 45
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 20
CRAP Score 15.4989

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 20
cts 23
cp 0.8696
crap 15.4989
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\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 27
        $new->ipValidator = $ipValidator;
78
        return $new;
79 27
    }
80 27
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
    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 26
        ?array $trustedHeaders = null
108
    ): self {
109
        $new = clone $this;
110
        foreach ($ipHeaders as $ipHeader) {
111
            if (\is_string($ipHeader)) {
112
                continue;
113
            }
114
            if (!\is_array($ipHeader)) {
115
                throw new \InvalidArgumentException('Type of ipHeader is not a string and not array');
116
            }
117 26
            if (count($ipHeader) !== 2) {
118 26
                throw new \InvalidArgumentException('The ipHeader array must have exactly 2 elements');
119 10
            }
120 4
            [$type, $header] = $ipHeader;
121
            if (!\is_string($type)) {
122 6
                throw new \InvalidArgumentException('The type is not a string');
123
            }
124
            if (!\is_string($header)) {
125 6
                throw new \InvalidArgumentException('The header is not a string');
126
            }
127
            if ($type === self::IP_HEADER_TYPE_RFC7239) {
128 6
                continue;
129 6
            }
130
131
            throw new \InvalidArgumentException("Not supported IP header type: $type");
132 6
        }
133
        if (count($hosts) === 0) {
134
            throw new \InvalidArgumentException('Empty hosts not allowed');
135 6
        }
136 6
        $trustedHeaders = $trustedHeaders ?? self::DEFAULT_TRUSTED_HEADERS;
137
        $protocolHeaders = $this->prepareProtocolHeaders($protocolHeaders);
138
        $this->checkTypeStringOrArray($hosts, 'hosts');
139
        $this->checkTypeStringOrArray($trustedHeaders, 'trustedHeaders');
140
        $this->checkTypeStringOrArray($hostHeaders, 'hostHeaders');
141 26
        $this->checkTypeStringOrArray($urlHeaders, 'urlHeaders');
142 16
        $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 10
                throw new \InvalidArgumentException("'$host' host is not a domain and not an IP address");
148 10
            }
149 10
        }
150 10
        $new->trustedHosts[] = [
151
            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
            self::DATA_KEY_HOST_HEADERS => $hostHeaders,
156
            self::DATA_KEY_URL_HEADERS => $urlHeaders,
157
            self::DATA_KEY_PORT_HEADERS => $portHeaders,
158 10
        ];
159 10
        return $new;
160 10
    }
161 10
162 10
    private function checkTypeStringOrArray(array $array, string $field): void
163 10
    {
164 10
        foreach ($array as $item) {
165 10
            if (!is_string($item)) {
166
                throw new \InvalidArgumentException("$field must be string type");
167 10
            }
168
            if (trim($item) === '') {
169
                throw new \InvalidArgumentException("$field cannot be empty strings");
170 10
            }
171
        }
172 10
    }
173 10
174
    public function withoutTrustedHosts(): self
175
    {
176 10
        $new = clone $this;
177
        $new->trustedHosts = [];
178
        return $new;
179
    }
180 10
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
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
200
    {
201
        $actualHost = $request->getServerParams()['REMOTE_ADDR'] ?? null;
202
        if ($actualHost === null) {
203
            // Validation is not possible.
204
            return $this->handleNotTrusted($request, $handler);
205
        }
206
207 11
        $trustedHostData = null;
208
        $trustedHeaders = [];
209 11
        $ipValidator = ($this->ipValidator ?? new Ip())->disallowSubnet()->disallowNegation();
210 11
        foreach ($this->trustedHosts as $data) {
211
            // collect all trusted headers
212
            $trustedHeaders = array_merge($trustedHeaders, $data[self::DATA_KEY_TRUSTED_HEADERS]);
213
            if ($trustedHostData !== null) {
214
                // trusted hosts already found
215 11
                continue;
216 11
            }
217 11
            if ($this->isValidHost($actualHost, $data[self::DATA_KEY_HOSTS], $ipValidator)) {
218 11
                $trustedHostData = $data;
219
            }
220 10
        }
221 10
        $untrustedHeaders = array_diff($trustedHeaders, $trustedHostData[self::DATA_KEY_TRUSTED_HEADERS] ?? []);
222
        $request = $this->removeHeaders($request, $untrustedHeaders);
223
        if ($trustedHostData === null) {
224
            // No trusted host at all.
225 10
            return $this->handleNotTrusted($request, $handler);
226 8
        }
227
        [$ipListType, $ipHeader, $hostList] = $this->getIpList($request, $trustedHostData[self::DATA_KEY_IP_HEADERS]);
228
        $hostList = array_reverse($hostList);       // the first item should be the closest to the server
229 11
        if ($ipListType === null) {
230 11
            $hostList = $this->getFormattedIpList($hostList);
231 11
        } elseif ($ipListType === self::IP_HEADER_TYPE_RFC7239) {
232
            $hostList = $this->getElementsByRfc7239($hostList);
233 3
        }
234
        array_unshift($hostList, ['ip' => $actualHost]);  // server's ip to first position
235 8
        $hostDataList = [];
236 8
        do {
237 8
            $hostData = array_shift($hostList);
238 2
            if (!isset($hostData['ip'])) {
239 6
                $hostData = $this->reverseObfuscate($hostData, $hostDataList, $hostList, $request);
240 6
                if ($hostData === null) {
241
                    continue;
242 8
                }
243 8
                if (!isset($hostData['ip'])) {
244
                    break;
245 8
                }
246 8
            }
247
            $ip = $hostData['ip'];
248
            if (!$this->isValidHost($ip, ['any'], $ipValidator)) {
249
                // invalid IP
250
                break;
251
            }
252
            $hostDataList[] = $hostData;
253
            if (!$this->isValidHost($ip, $trustedHostData[self::DATA_KEY_HOSTS], $ipValidator)) {
254
                // not trusted host
255 8
                break;
256 8
            }
257
        } while (count($hostList) > 0);
258
259
        if ($this->attributeIps !== null) {
260 8
            $request = $request->withAttribute($this->attributeIps, $hostDataList);
261 8
        }
262
263 8
        $uri = $request->getUri();
264
        // find HTTP host
265 8
        foreach ($trustedHostData[self::DATA_KEY_HOST_HEADERS] as $hostHeader) {
266
            if (!$request->hasHeader($hostHeader)) {
267 8
                continue;
268
            }
269
            if ($hostHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($hostData['httpHost'])) {
270
                $uri = $uri->withHost($hostData['httpHost']);
271 8
                break;
272
            }
273 8
            $host = $request->getHeaderLine($hostHeader);
274 4
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) !== false) {
275
                $uri = $uri->withHost($host);
276
                break;
277 4
            }
278 2
        }
279 2
280
        // find protocol
281 2
        foreach ($trustedHostData[self::DATA_KEY_PROTOCOL_HEADERS] as $protocolHeader => $protocols) {
282 2
            if (!$request->hasHeader($protocolHeader)) {
283 2
                continue;
284 2
            }
285
            if ($protocolHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($hostData['protocol'])) {
286
                $uri = $uri->withScheme($hostData['protocol']);
287
                break;
288
            }
289 8
            $protocolHeaderValue = $request->getHeaderLine($protocolHeader);
290 4
            foreach ($protocols as $protocol => $acceptedValues) {
291
                if (\in_array($protocolHeaderValue, $acceptedValues, true)) {
292
                    $uri = $uri->withScheme($protocol);
293 4
                    break 2;
294 2
                }
295 2
            }
296
        }
297 2
        $urlParts = $this->getUrl($request, $trustedHostData[self::DATA_KEY_URL_HEADERS]);
298 2
        if ($urlParts !== null) {
299 2
            [$path, $query] = $urlParts;
300 2
            $uri = $uri->withPath($path);
301 2
            if ($query !== null) {
302
                $uri = $uri->withQuery($query);
303
            }
304
        }
305 8
306 8
        // find port
307 3
        foreach ($trustedHostData[self::DATA_KEY_PORT_HEADERS] as $portHeader) {
308 3
            if (!$request->hasHeader($portHeader)) {
309 3
                continue;
310 3
            }
311
            if ($portHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($hostData['port']) && $this->checkPort((string)$hostData['port'])) {
312
                $uri = $uri->withPort($hostData['port']);
313
                break;
314
            }
315 8
            $port = $request->getHeaderLine($portHeader);
316 1
            if ($this->checkPort($port)) {
317
                $uri = $uri->withPort((int)$port);
318
                break;
319 1
            }
320 1
        }
321 1
322
        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 8
    protected function isValidHost(string $host, array $ranges, Ip $validator): bool
331
    {
332
        return $validator->ranges($ranges)->validate($host)->isValid();
333
    }
334
335
    /**
336
     * Reverse obfuscating host data
337
     *
338 10
     * 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 10
     *
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
    private function handleNotTrusted(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
365
    {
366
        if ($this->attributeIps !== null) {
367
            $request = $request->withAttribute($this->attributeIps, null);
368
        }
369
        return $handler->handle($request->withAttribute('requestClientIp', null));
370
    }
371
372 3
    private function prepareProtocolHeaders(array $protocolHeaders): array
373
    {
374 3
        $output = [];
375
        foreach ($protocolHeaders as $header => $protocolAndAcceptedValues) {
376
            $header = strtolower($header);
377 3
            if (\is_callable($protocolAndAcceptedValues)) {
378
                $output[$header] = $protocolAndAcceptedValues;
379
                continue;
380 10
            }
381
            if (!\is_array($protocolAndAcceptedValues)) {
382 10
                throw new \RuntimeException('Accepted values is not an array nor callable');
383 10
            }
384 4
            if (count($protocolAndAcceptedValues) === 0) {
385 4
                throw new \RuntimeException('Accepted values cannot be an empty array');
386
            }
387
            $output[$header] = [];
388
            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 4
            }
397 4
        }
398
        return $output;
399
    }
400 4
401
    private function removeHeaders(ServerRequestInterface $request, array $headers): ServerRequestInterface
402
    {
403 4
        foreach ($headers as $header) {
404
            $request = $request->withoutAttribute($header);
405
        }
406 10
        return $request;
407
    }
408
409 11
    private function getIpList(RequestInterface $request, array $ipHeaders): array
410
    {
411 11
        foreach ($ipHeaders as $ipHeader) {
412
            $type = null;
413
            if (\is_array($ipHeader)) {
414 11
                $type = array_shift($ipHeader);
415
                $ipHeader = array_shift($ipHeader);
416
            }
417 8
            if ($request->hasHeader($ipHeader)) {
418
                return [$type, $ipHeader, $request->getHeader($ipHeader)];
419 8
            }
420 8
        }
421 8
        return [null, null, []];
422 6
    }
423 6
424
    /**
425 8
     * @see getElementsByRfc7239
426 8
     */
427
    private function getFormattedIpList(array $forwards): array
428
    {
429
        $list = [];
430
        foreach ($forwards as $ip) {
431
            $list[] = ['ip' => $ip];
432
        }
433
        return $list;
434
    }
435 2
436
    /**
437 2
     * Forwarded elements by RFC7239
438 2
     *
439 2
     * The structure of the elements:
440
     * - `host`: IP or obfuscated hostname or "unknown"
441 2
     * - `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
    private function getElementsByRfc7239(array $forwards): array
451
    {
452
        $list = [];
453
        foreach ($forwards as $forward) {
454
            $data = HeaderHelper::getParameters($forward);
455
            if (!isset($data['for'])) {
456
                // Invalid item, the following items will be dropped
457
                break;
458 6
            }
459
            $pattern = '/^(?<host>' . IpHelper::IPV4_PATTERN . '|unknown|_[\w\.-]+|[[]' . IpHelper::IPV6_PATTERN . '[]])(?::(?<port>[\w\.-]+))?$/';
460 6
            if (preg_match($pattern, $data['for'], $matches) === 0) {
461 6
                // Invalid item, the following items will be dropped
462 6
                break;
463 6
            }
464
            $ipData = [];
465
            $host = $matches['host'];
466
            $obfuscatedHost = $host === 'unknown' || strpos($host, '_') === 0;
467 6
            if (!$obfuscatedHost) {
468 6
                // IPv4 & IPv6
469
                $ipData['ip'] = strpos($host, '[') === 0 ? trim($host /* IPv6 */, '[]') : $host;
470
            }
471
            $ipData['host'] = $host;
472 6
            if (isset($matches['port'])) {
473 6
                $port = $matches['port'];
474 6
                if (!$obfuscatedHost && !$this->checkPort($port)) {
475 6
                    // Invalid port, the following items will be dropped
476
                    break;
477 6
                }
478
                $ipData['port'] = $obfuscatedHost ? $port : (int)$port;
479 6
            }
480 6
481 1
            // copy other properties
482 1
            foreach (['proto' => 'protocol', 'host' => 'httpHost', 'by' => 'by'] as $source => $destination) {
483
                if (isset($data[$source])) {
484
                    $ipData[$destination] = $data[$source];
485
                }
486 1
            }
487
            if (isset($ipData['httpHost']) && filter_var($ipData['httpHost'], FILTER_VALIDATE_DOMAIN) === false) {
488
                // remove not valid HTTP host
489
                unset($ipData['httpHost']);
490 6
            }
491 6
492 4
            $list[] = $ipData;
493
        }
494
        return $list;
495 6
    }
496
497
    private function getUrl(RequestInterface $request, array $urlHeaders): ?array
498
    {
499
        foreach ($urlHeaders as $header) {
500 6
            if (!$request->hasHeader($header)) {
501
                continue;
502 6
            }
503
            $url = $request->getHeaderLine($header);
504
            if (strpos($url, '/') === 0) {
505 8
                return array_pad(explode('?', $url, 2), 2, null);
506
            }
507 8
        }
508 3
        return null;
509
    }
510
511 3
    private function checkPort(string $port): bool
512 3
    {
513 3
        return preg_match('/^\d{1,5}$/', $port) === 1 && (int)$port <= 65535;
514
    }
515
}
516