Completed
Pull Request β€” master (#157)
by Alexander
02:21
created

prepareProtocolHeaders()   B

Complexity

Conditions 8
Paths 8

Size

Total Lines 27
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

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

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

366
        /** @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...
367
        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

367
        /** @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...
368
    ): array {
369
        return $ipData;
370
    }
371
372
    private function prepareProtocolHeaders(array $protocolHeaders): array
373
    {
374
        $output = [];
375
        foreach ($protocolHeaders as $header => $protocolAndAcceptedValues) {
376
            $header = strtolower($header);
377
            if (\is_callable($protocolAndAcceptedValues)) {
378
                $output[$header] = $protocolAndAcceptedValues;
379
                continue;
380
            }
381
            if (!\is_array($protocolAndAcceptedValues)) {
382
                throw new \RuntimeException('Accepted values is not an array nor callable');
383
            }
384
            if (count($protocolAndAcceptedValues) === 0) {
385
                throw new \RuntimeException('Accepted values cannot be an empty array');
386
            }
387
            $output[$header] = [];
388
            foreach ($protocolAndAcceptedValues as $protocol => $acceptedValues) {
389
                if (!\is_string($protocol)) {
390
                    throw new \RuntimeException('The protocol must be a string');
391
                }
392
                if ($protocol === '') {
393
                    throw new \RuntimeException('The protocol cannot be empty');
394
                }
395
                $output[$header][$protocol] = array_map('strtolower', (array)$acceptedValues);
396
            }
397
        }
398
        return $output;
399
    }
400
401
    private function removeHeaders(ServerRequestInterface $request, array $headers): ServerRequestInterface
402
    {
403
        foreach ($headers as $header) {
404
            $request = $request->withoutAttribute($header);
405
        }
406
        return $request;
407
    }
408
409
    private function getIpList(RequestInterface $request, array $ipHeaders): array
410
    {
411
        foreach ($ipHeaders as $ipHeader) {
412
            $type = null;
413
            if (\is_array($ipHeader)) {
414
                $type = array_shift($ipHeader);
415
                $ipHeader = array_shift($ipHeader);
416
            }
417
            if ($request->hasHeader($ipHeader)) {
418
                return [$type, $ipHeader, $request->getHeader($ipHeader)];
419
            }
420
        }
421
        return [null, null, []];
422
    }
423
424
    /**
425
     * @see getElementsByRfc7239
426
     */
427
    private function getFormattedIpList(array $forwards): array
428
    {
429
        $list = [];
430
        foreach ($forwards as $ip) {
431
            $list[] = ['ip' => $ip];
432
        }
433
        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
    private function getElementsByRfc7239(array $forwards): array
451
    {
452
        $list = [];
453
        foreach ($forwards as $forward) {
454
            $data = HeaderHelper::getParameters($forward);
455
            if (!isset($data['for'])) {
456
                // Invalid item, the following items will be dropped
457
                break;
458
            }
459
            $pattern = '/^(?<host>' . IpHelper::IPV4_PATTERN . '|unknown|_[\w\.-]+|[[]' . IpHelper::IPV6_PATTERN . '[]])(?::(?<port>[\w\.-]+))?$/';
460
            if (preg_match($pattern, $data['for'], $matches) === 0) {
461
                // Invalid item, the following items will be dropped
462
                break;
463
            }
464
            $ipData = [];
465
            $host = $matches['host'];
466
            $obfuscatedHost = $host === 'unknown' || strpos($host, '_') === 0;
467
            if (!$obfuscatedHost) {
468
                // IPv4 & IPv6
469
                $ipData['ip'] = strpos($host, '[') === 0 ? trim($host /* IPv6 */, '[]') : $host;
470
            }
471
            $ipData['host'] = $host;
472
            if (isset($matches['port'])) {
473
                $port = $matches['port'];
474
                if (!$obfuscatedHost && (preg_match('/^\d{1,5}$/', $port) === 0 || (int)$port > 65535)) {
475
                    // Invalid port, the following items will be dropped
476
                    break;
477
                }
478
                $ipData['port'] = $obfuscatedHost ? $port : (int)$port;
479
            }
480
481
            // copy other properties
482
            foreach (['proto' => 'protocol', 'host' => 'httpHost', 'by' => 'by'] as $source => $destination) {
483
                if (isset($data[$source])) {
484
                    $ipData[$destination] = $data[$source];
485
                }
486
            }
487
            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
            $list[] = $ipData;
493
        }
494
        return $list;
495
    }
496
497
    private function getUrl(RequestInterface $request, array $urlHeaders): ?array
498
    {
499
        foreach ($urlHeaders as $header) {
500
            if (!$request->hasHeader($header)) {
501
                continue;
502
            }
503
            $url = $request->getHeaderLine($header);
504
            if (strpos($url, '/') === 0) {
505
                return array_pad(explode('?', $url, 2), 2, null);
506
            }
507
        }
508
        return null;
509
    }
510
}
511