Passed
Pull Request β€” master (#276)
by Dmitriy
12:33
created

getElementsByRfc7239()   C

Complexity

Conditions 15
Paths 9

Size

Total Lines 45
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 15.4394

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

358
        /** @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...
359
        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

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

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