Passed
Pull Request β€” master (#157)
by
unknown
02:37
created

TrustedHostsNetworkResolver::reverseObfuscate()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 2
b 0
f 0
nc 1
nop 4
dl 0
loc 7
rs 10
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\Message\UriInterface;
10
use Psr\Http\Server\MiddlewareInterface;
11
use Psr\Http\Server\RequestHandlerInterface;
12
use Yiisoft\NetworkUtilities\IpHelper;
13
use Yiisoft\Validator\Rule\Ip;
14
use Yiisoft\Yii\Web\Helper\HeaderHelper;
15
16
/**
17
 * Trusted hosts network resolver
18
 *
19
 * Code example with comments:
20
 * ```
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
 */
43
class TrustedHostsNetworkResolver implements MiddlewareInterface
44
{
45
    public const IP_HEADER_TYPE_RFC7239 = 'rfc7239';
46
47
    public const DEFAULT_TRUSTED_HEADERS = [
48
        // common:
49
        'x-forwarded-for',
50
        'x-forwarded-host',
51
        'x-forwarded-proto',
52
        'x-forwarded-port',
53
54
        // RFC:
55
        'forward',
56
57
        // Microsoft:
58
        'front-end-https',
59
        'x-rewrite-url',
60
    ];
61
62
    private const DATA_KEY_HOSTS = 'hosts';
63
    private const DATA_KEY_IP_HEADERS = 'ipHeaders';
64
    private const DATA_KEY_HOST_HEADERS = 'hostHeaders';
65
    private const DATA_KEY_URL_HEADERS = 'urlHeaders';
66
    private const DATA_KEY_PROTOCOL_HEADERS = 'protocolHeaders';
67
    private const DATA_KEY_TRUSTED_HEADERS = 'trustedHeaders';
68
    private const DATA_KEY_PORT_HEADERS = 'portHeaders';
69
70
    private $trustedHosts = [];
71
72
    /**
73
     * @var string|null
74
     */
75
    private $attributeIps;
76
77
    /**
78
     * @var ResponseFactoryInterface
79
     */
80
    private $responseFactory;
81
82
    /**
83
     * @var Ip|null
84
     */
85
    private $ipValidator;
86
87
    public function __construct(ResponseFactoryInterface $responseFactory)
88
    {
89
        $this->responseFactory = $responseFactory;
90
    }
91
92
    /**
93
     * @return static
94
     */
95
    public function withIpValidator(Ip $ipValidator)
96
    {
97
        $new = clone $this;
98
        $new->ipValidator = $ipValidator;
99
        return $new;
100
    }
101
102
    /**
103
     * With added trusted hosts and related headers
104
     *
105
     * The header lists are evaluated in the order they were specified.
106
     * If you specify multiple headers by type (eg IP headers), you must ensure that the irrelevant header is removed
107
     * eg. web server application, otherwise spoof clients can be use this vulnerability.
108
     *
109
     * @param string[] $hosts List of trusted hosts IP addresses. If `isValidHost` is extended, then can use
110
     *                        domain names with reverse DNS resolving eg. yiiframework.com, * .yiiframework.com.
111
     * @param array $ipHeaders List of headers containing IP lists.
112
     * @param array $protocolHeaders List of headers containing protocol. eg. ['x-forwarded-for' => ['http' => 'http', 'https' => ['on', 'https']]]
113
     * @param string[] $hostHeaders List of headers containing HTTP host.
114
     * @param string[] $urlHeaders List of headers containing HTTP URL.
115
     * @param string[] $portHeaders List of headers containing port number.
116
     * @param string[]|null $trustedHeaders List of trusted headers. Removed from the request, if in checking process
117
     *                                      are classified as untrusted by hosts.
118
     * @return static
119
     */
120
    public function withAddedTrustedHosts(
121
        array $hosts,
122
        // Defining default headers is not secure!
123
        array $ipHeaders = [],
124
        array $protocolHeaders = [],
125
        array $hostHeaders = [],
126
        array $urlHeaders = [],
127
        array $portHeaders = [],
128
        ?array $trustedHeaders = null
129
    ) {
130
        $new = clone $this;
131
        foreach ($ipHeaders as $ipHeader) {
132
            if (\is_string($ipHeader)) {
133
                continue;
134
            }
135
            if (!\is_array($ipHeader)) {
136
                throw new \InvalidArgumentException('Type of ipHeader is not a string and not array');
137
            }
138
            if (count($ipHeader) !== 2) {
139
                throw new \InvalidArgumentException('The ipHeader array must have exactly 2 elements');
140
            }
141
            [$type, $header] = $ipHeader;
142
            if (!\is_string($type)) {
143
                throw new \InvalidArgumentException('The type is not a string');
144
            }
145
            if (!\is_string($header)) {
146
                throw new \InvalidArgumentException('The header is not a string');
147
            }
148
            if ($type === self::IP_HEADER_TYPE_RFC7239) {
149
                continue;
150
            }
151
152
            throw new \InvalidArgumentException("Not supported IP header type: $type");
153
        }
154
        if (count($hosts) === 0) {
155
            throw new \InvalidArgumentException("Empty hosts not allowed");
156
        }
157
        $trustedHeaders = $trustedHeaders ?? self::DEFAULT_TRUSTED_HEADERS;
158
        $protocolHeaders = $this->prepareProtocolHeaders($protocolHeaders);
159
        $this->checkTypeStringOrArray($hosts, 'hosts');
160
        $this->checkTypeStringOrArray($trustedHeaders, 'trustedHeaders');
161
        $this->checkTypeStringOrArray($hostHeaders, 'hostHeaders');
162
        $this->checkTypeStringOrArray($urlHeaders, 'urlHeaders');
163
        $this->checkTypeStringOrArray($portHeaders, 'portHeaders');
164
165
        foreach ($hosts as $host) {
166
            $host = str_replace('*', 'wildcard', $host);        // wildcard is allowed in host
167
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) === false) {
168
                throw new \InvalidArgumentException("'$host' host is not a domain and not an IP address");
169
            }
170
        }
171
        $new->trustedHosts[] = [
172
            self::DATA_KEY_HOSTS => $hosts,
173
            self::DATA_KEY_IP_HEADERS => $ipHeaders,
174
            self::DATA_KEY_PROTOCOL_HEADERS => $protocolHeaders,
175
            self::DATA_KEY_TRUSTED_HEADERS => $trustedHeaders,
176
            self::DATA_KEY_HOST_HEADERS => $hostHeaders,
177
            self::DATA_KEY_URL_HEADERS => $urlHeaders,
178
            self::DATA_KEY_PORT_HEADERS => $portHeaders,
179
        ];
180
        return $new;
181
    }
182
183
    private function checkTypeStringOrArray(array $array, string $field): void
184
    {
185
        foreach ($array as $item) {
186
            if (!is_string($item)) {
187
                throw new \InvalidArgumentException("$field must be string type");
188
            }
189
            if (trim($item) === '') {
190
                throw new \InvalidArgumentException("$field cannot be empty strings");
191
            }
192
        }
193
    }
194
195
    /**
196
     * @return static
197
     */
198
    public function withoutTrustedHosts()
199
    {
200
        $new = clone $this;
201
        $new->trustedHosts = [];
202
        return $new;
203
    }
204
205
    /**
206
     * Request's attribute name to which trusted path data is added.
207
     *
208
     * The list starts with the server and the last item is the client itself.
209
     *
210
     * @return static
211
     * @see getElementsByRfc7239
212
     */
213
    public function withAttributeIps(?string $attribute)
214
    {
215
        if ($attribute === '') {
216
            throw new \RuntimeException('Attribute should not be empty');
217
        }
218
        $new = clone $this;
219
        $new->attributeIps = $attribute;
220
        return $new;
221
    }
222
223
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
224
    {
225
        $actualHost = $request->getServerParams()['REMOTE_ADDR'] ?? null;
226
        if ($actualHost === null) {
227
            // Validation is not possible.
228
            return $this->handleNotTrusted($request, $handler);
229
        }
230
        $trustedHostData = null;
231
        $trustedHeaders = [];
232
        $ipValidator = ($this->ipValidator ?? new Ip())->disallowSubnet()->disallowNegation();
233
        foreach ($this->trustedHosts as $data) {
234
            // collect all trusted headers
235
            $trustedHeaders = array_merge($trustedHeaders, $data[self::DATA_KEY_TRUSTED_HEADERS]);
236
            if ($trustedHostData !== null) {
237
                // trusted hosts already found
238
                continue;
239
            }
240
            if ($this->isValidHost($actualHost, $data[self::DATA_KEY_HOSTS], $ipValidator)) {
241
                $trustedHostData = $data;
242
            }
243
        }
244
        $untrustedHeaders = array_diff($trustedHeaders, $trustedHostData[self::DATA_KEY_TRUSTED_HEADERS] ?? []);
245
        $request = $this->removeHeaders($request, $untrustedHeaders);
246
        if ($trustedHostData === null) {
247
            // No trusted host at all.
248
            return $this->handleNotTrusted($request, $handler);
249
        }
250
        [$ipListType, $ipHeader, $hostList] = $this->getIpList($request, $trustedHostData[self::DATA_KEY_IP_HEADERS]);
251
        $hostList = array_reverse($hostList);       // the first item should be the closest to the server
252
        if ($ipListType === null) {
253
            $hostList = $this->getFormattedIpList($hostList);
254
        } elseif ($ipListType === self::IP_HEADER_TYPE_RFC7239) {
255
            $hostList = $this->getElementsByRfc7239($hostList);
256
        }
257
        array_unshift($hostList, ['ip' => $actualHost]);  // server's ip to first position
258
        $hostDataList = [];
259
        do {
260
            $hostData = array_shift($hostList);
261
            if (!isset($hostData['ip'])) {
262
                $hostData = $this->reverseObfuscate($hostData, $hostDataList, $hostList, $request);
263
                if ($hostData === null) {
264
                    continue;
265
                }
266
                if (!isset($hostData['ip'])) {
267
                    break;
268
                }
269
            }
270
            $ip = $hostData['ip'];
271
            if (!$this->isValidHost($ip, ['any'], $ipValidator)) {
272
                // invalid IP
273
                break;
274
            }
275
            $hostDataList[] = $hostData;
276
            if (!$this->isValidHost($ip, $trustedHostData[self::DATA_KEY_HOSTS], $ipValidator)) {
277
                // not trusted host
278
                break;
279
            }
280
        } while (count($hostList) > 0);
281
282
        if ($this->attributeIps !== null) {
283
            $request = $request->withAttribute($this->attributeIps, $hostDataList);
284
        }
285
286
        $uri = $request->getUri();
287
        // find HTTP host
288
        foreach ($trustedHostData[self::DATA_KEY_HOST_HEADERS] as $hostHeader) {
289
            if (!$request->hasHeader($hostHeader)) {
290
                continue;
291
            }
292
            if ($hostHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($hostData['httpHost'])) {
293
                $uri = $uri->withHost($hostData['httpHost']);
294
                break;
295
            }
296
            $host = $request->getHeaderLine($hostHeader);
297
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) !== false) {
298
                $uri = $uri->withHost($host);
299
                break;
300
            }
301
        }
302
303
        // find protocol
304
        foreach ($trustedHostData[self::DATA_KEY_PROTOCOL_HEADERS] as $protocolHeader => $protocols) {
305
            if (!$request->hasHeader($protocolHeader)) {
306
                continue;
307
            }
308
            if ($protocolHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($hostData['protocol'])) {
309
                $uri = $uri->withScheme($hostData['protocol']);
310
                break;
311
            }
312
            $protocolHeaderValue = $request->getHeaderLine($protocolHeader);
313
            foreach ($protocols as $protocol => $acceptedValues) {
314
                if (\in_array($protocolHeaderValue, $acceptedValues)) {
315
                    $uri = $uri->withScheme($protocol);
316
                    break 2;
317
                }
318
            }
319
        }
320
        $urlParts = $this->getUrl($request, $trustedHostData[self::DATA_KEY_URL_HEADERS]);
321
        if ($urlParts !== null) {
322
            [$path, $query] = $urlParts;
323
            $uri = $uri->withPath($path);
324
            if ($query !== null) {
325
                $uri = $uri->withQuery($query);
326
            }
327
        }
328
329
        // find port
330
        foreach ($trustedHostData[self::DATA_KEY_PORT_HEADERS] as $portHeader) {
331
            if (!$request->hasHeader($portHeader)) {
332
                continue;
333
            }
334
            if ($portHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($hostData['port']) && $this->checkPort($hostData['port'])) {
335
                $uri = $uri->withPort($hostData['port']);
336
                break;
337
            }
338
            $port = $request->getHeaderLine($portHeader);
339
            if ($this->checkPort($port)) {
340
                $uri = $uri->withPort($port);
0 ignored issues
show
Bug introduced by
$port of type string is incompatible with the type integer|null expected by parameter $port of Psr\Http\Message\UriInterface::withPort(). ( Ignorable by Annotation )

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

340
                $uri = $uri->withPort(/** @scrutinizer ignore-type */ $port);
Loading history...
341
                break;
342
            }
343
        }
344
345
        return $handler->handle($request->withUri($uri)->withAttribute('requestClientIp', $hostData['ip']));
346
    }
347
348
    /**
349
     * Validate host by range
350
     *
351
     * This method can be extendable by overwriting eg. with reverse DNS verification.
352
     */
353
    protected function isValidHost(string $host, array $ranges, Ip $validator): bool
354
    {
355
        return $validator->ranges($ranges)->validate($host)->isValid();
356
    }
357
358
    /**
359
     * Reverse obfuscating host data
360
     *
361
     * RFC7239 allow the ability to use obfuscated host data. In this case, either specifying the
362
     * IP address or dropping the proxy endpoint is required to determine validated route.
363
     *
364
     * The base operation does not perform any transformation on the data.
365
     * This method can be extendable by overwriting.
366
     *
367
     * @return array|null reverse obfuscated host data or NULL.
368
     *                    If returned NULL, it is discarded and the process continues with the next.
369
     *                    If the return value is an array, it must contain at least the `ip` key.
370
     *
371
     * @see getElementsByRfc7239
372
     * @link https://tools.ietf.org/html/rfc7239#section-6.2
373
     * @link https://tools.ietf.org/html/rfc7239#section-6.3
374
     */
375
    protected function reverseObfuscate(
376
        array $hostData,
377
        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

377
        /** @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...
378
        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

378
        /** @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...
379
        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

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