Completed
Pull Request β€” master (#157)
by
unknown
02:03
created

TrustedHostsNetworkResolver::reverseObfuscate()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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

343
        /** @scrutinizer ignore-unused */ array $ipDataListValidated,

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...
344
        array $ipDataListRemaining,
0 ignored issues
show
Unused Code introduced by
The parameter $ipDataListRemaining 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

344
        /** @scrutinizer ignore-unused */ array $ipDataListRemaining,

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...
345
        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

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