Passed
Pull Request β€” master (#157)
by Alexander
12:42
created

TrustedHostsNetworkResolver::process()   F

Complexity

Conditions 37
Paths > 20000

Size

Total Lines 124
Code Lines 80

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 37
eloc 80
c 3
b 0
f 0
nc 57605
nop 2
dl 0
loc 124
rs 0

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

363
        /** @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...
364
        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

364
        /** @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...
365
        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

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