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

TrustedHostsNetworkResolver::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
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\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