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

TrustedHostsNetworkResolver::withIpValidator()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

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

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
358
        array $hostDataListRemaining,
0 ignored issues
show
Unused Code introduced by
The parameter $hostDataListRemaining is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

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

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
359
        RequestInterface $request
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

359
        /** @scrutinizer ignore-unused */ RequestInterface $request

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

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