Passed
Pull Request — master (#157)
by
unknown
01:59
created

TrustedHostsNetworkResolver::process()   F

Complexity

Conditions 20
Paths 4544

Size

Total Lines 87
Code Lines 61

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 20
eloc 61
nc 4544
nop 2
dl 0
loc 87
rs 0
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
class TrustedHostsNetworkResolver implements MiddlewareInterface
16
{
17
    public const IP_HEADER_TYPE_RFC7239 = 'rfc7239';
18
19
    private const DEFAULT_IP_HEADERS = [
20
        [self::IP_HEADER_TYPE_RFC7239, 'forward'], // https://tools.ietf.org/html/rfc7239
21
        'x-forwarded-for', // common
22
    ];
23
24
    private const DEFAULT_HOST_HEADERS = [
25
        'x-forwarded-host', // common
26
    ];
27
28
    private const DEFAULT_URL_HEADERS = [
29
        'x-rewrite-url', // Microsoft
30
    ];
31
32
    private const DEFAULT_PROTOCOL_HEADERS = [
33
        'x-forwarded-proto' => ['http' => 'http', 'https' => 'https'], // common
34
        'front-end-https' => ['https' => 'on'], // Microsoft
35
    ];
36
37
    private const DEFAULT_TRUSTED_HEADERS = [
38
        // common:
39
        'x-forwarded-for',
40
        'x-forwarded-host',
41
        'x-forwarded-proto',
42
43
        // RFC:
44
        'forward',
45
46
        // Microsoft:
47
        'front-end-https',
48
        'x-rewrite-url',
49
    ];
50
51
    private const DATA_KEY_HOSTS = 'hosts';
52
    private const DATA_KEY_IP_HEADERS = 'ipHeaders';
53
    private const DATA_KEY_HOST_HEADERS = 'hostHeaders';
54
    private const DATA_KEY_URL_HEADERS = 'urlHeaders';
55
    private const DATA_KEY_PROTOCOL_HEADERS = 'protocolHeaders';
56
    private const DATA_KEY_TRUSTED_HEADERS = 'trustedHeaders';
57
58
    private $trustedHosts = [];
59
60
    /**
61
     * @var string|null
62
     */
63
    private $attributeIps;
64
65
    /**
66
     * @var ResponseFactoryInterface
67
     */
68
    private $responseFactory;
69
    /**
70
     * @var Chain|null
71
     */
72
    private $notTrustedBranch;
73
74
    /**
75
     * @var Ip|null
76
     */
77
    private $ipValidator;
78
79
    public function __construct(ResponseFactoryInterface $responseFactory)
80
    {
81
        $this->responseFactory = $responseFactory;
82
    }
83
84
    /**
85
     * @return static
86
     */
87
    public function withIpValidator(Ip $ipValidator)
88
    {
89
        $new = clone $this;
90
        $ipValidator = clone $ipValidator;
91
        // force disable unacceptable validation
92
        $new->ipValidator = $ipValidator->disallowSubnet()->disallowNegation();
93
        return $new;
94
    }
95
96
    /**
97
     * @return static
98
     */
99
    public function withNotTrustedBranch(?MiddlewareInterface $middleware)
100
    {
101
        $new = clone $this;
102
        $new->notTrustedBranch = $middleware;
103
        return $new;
104
    }
105
106
    /**
107
     * @return static
108
     */
109
    public function withAddedTrustedHosts(
110
        array $hosts,
111
        ?array $ipHeaders = null,
112
        ?array $protocolHeaders = null,
113
        ?array $hostHeaders = null,
114
        ?array $urlHeaders = null,
115
        ?array $trustedHeaders = null
116
    ) {
117
        $new = clone $this;
118
        $ipHeaders = $ipHeaders ?? self::DEFAULT_IP_HEADERS;
119
        foreach ($ipHeaders as $ipHeader) {
120
            if (\is_string($ipHeader)) {
121
                continue;
122
            }
123
            if (!\is_array($ipHeader)) {
124
                throw new \InvalidArgumentException('Type of ipHeader is not a string and not array');
125
            }
126
            if (count($ipHeader) !== 2) {
127
                throw new \InvalidArgumentException('The ipHeader array must have exactly 2 elements');
128
            }
129
            [$type, $header] = $ipHeader;
130
            if (!\is_string($type)) {
131
                throw new \InvalidArgumentException('The type is not a string');
132
            }
133
            if (!\is_string($header)) {
134
                throw new \InvalidArgumentException('The header is not a string');
135
            }
136
            if ($type === self::IP_HEADER_TYPE_RFC7239) {
137
                continue;
138
            }
139
140
            throw new \InvalidArgumentException("Not supported IP header type: $type");
141
        }
142
        $data = [
143
            self::DATA_KEY_HOSTS => $hosts,
144
            self::DATA_KEY_IP_HEADERS => $ipHeaders,
145
            self::DATA_KEY_PROTOCOL_HEADERS => $this->prepareProtocolHeaders($protocolHeaders ?? self::DEFAULT_PROTOCOL_HEADERS),
146
            self::DATA_KEY_TRUSTED_HEADERS => $trustedHeaders ?? self::DEFAULT_TRUSTED_HEADERS,
147
            self::DATA_KEY_HOST_HEADERS => $hostHeaders ?? self::DEFAULT_HOST_HEADERS,
148
            self::DATA_KEY_URL_HEADERS => $urlHeaders ?? self::DEFAULT_URL_HEADERS,
149
        ];
150
        foreach ([self::DATA_KEY_HOSTS, self::DATA_KEY_TRUSTED_HEADERS, self::DATA_KEY_HOST_HEADERS, self::DATA_KEY_URL_HEADERS] as $key) {
151
            $this->checkStringArrayType($data[$key], $key);
152
        }
153
        foreach ($data[self::DATA_KEY_HOSTS] as $host) {
154
            $host = str_replace('*', 'wildcard', $host);        // wildcard is allowed in host
155
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) === false) {
156
                throw new \InvalidArgumentException("'$host' host is not a domain and not an IP address");
157
            }
158
        }
159
        $new->trustedHosts[] = $data;
160
        return $new;
161
    }
162
163
    private function checkStringArrayType(array $array, string $field): void
164
    {
165
        foreach ($array as $item) {
166
            if (!is_string($item)) {
167
                throw new \InvalidArgumentException("$field must be string type");
168
            }
169
            if (trim($item) === '') {
170
                throw new \InvalidArgumentException("$field cannot be empty strings");
171
            }
172
        }
173
    }
174
175
    /**
176
     * @return static
177
     */
178
    public function withoutTrustedHosts()
179
    {
180
        $new = clone $this;
181
        $new->trustedHosts = [];
182
        return $new;
183
    }
184
185
    /**
186
     * @return static
187
     */
188
    public function withAttributeIps(?string $attribute)
189
    {
190
        if ($attribute === '') {
191
            throw new \RuntimeException('Attribute should not be empty');
192
        }
193
        $new = clone $this;
194
        $new->attributeIps = $attribute;
195
        return $new;
196
    }
197
198
    /**
199
     * Process an incoming server request.
200
     *
201
     * Processes an incoming server request in order to produce a response.
202
     * If unable to produce the response itself, it may delegate to the provided
203
     * request handler to do so.
204
     */
205
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
206
    {
207
        $actualHost = $request->getServerParams()['REMOTE_ADDR'];
208
        $trustedHostData = null;
209
        $trustedHeaders = [];
210
        $ipValidator = $this->ipValidator ?? new Ip();
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
                // trusted hosts already found
216
                continue;
217
            }
218
            if ($this->isValidHost($actualHost, $data[self::DATA_KEY_HOSTS], $ipValidator)) {
219
                $trustedHostData = $data;
220
            }
221
        }
222
        $untrustedHeaders = array_diff($trustedHeaders, $trustedHostData[self::DATA_KEY_TRUSTED_HEADERS] ?? []);
223
        $request = $this->removeHeaders($request, $untrustedHeaders);
224
        if ($trustedHostData === null) {
225
            // No trusted host at all.
226
            if ($this->notTrustedBranch !== null) {
227
                return $this->notTrustedBranch->process($request, $handler);
228
            }
229
            $response = $this->responseFactory->createResponse(412);
230
            $response->getBody()->write('Unable to verify your network.');
231
            return $response;
232
        }
233
        [$type, $ipList] = $this->getIpList($request, $trustedHostData[self::DATA_KEY_IP_HEADERS]);
234
        $ipList = array_reverse($ipList);       // the first item should be the closest to the server
235
        if ($type === null) {
236
            $ipList = $this->getFormattedIpList($ipList);
237
        } elseif ($type === self::IP_HEADER_TYPE_RFC7239) {
238
            $ipList = $this->getForwardedElements($ipList);
239
        }
240
        array_unshift($ipList, ['ip' => $actualHost]);  // server's ip to first position
241
        $ipDataList = [];
242
        do {
243
            $ipData = array_shift($ipList);
244
            if (!isset($ipData['ip'])) {
245
                $ipData = $this->reverseObfuscate($ipData, $ipDataList, $ipList, $request);
246
                if (!isset($ipData['ip'])) {
247
                    break;
248
                }
249
            }
250
            $ip = $ipData['ip'];
251
            if (!$this->isValidHost($ip, ['any'], $ipValidator)) {
252
                break;
253
            }
254
            $ipDataList[] = $ipData;
255
            if (!$this->isValidHost($ip, $trustedHostData[self::DATA_KEY_HOSTS], $ipValidator)) {
256
                break;
257
            }
258
        } while (count($ipList) > 0);
259
260
        if ($this->attributeIps !== null) {
261
            $request = $request->withAttribute($this->attributeIps, $ipDataList);
262
        }
263
264
        $uri = $request->getUri();
265
        if (isset($ipData['httpHost'])) {
266
            $uri = $uri->withHost($ipData['httpHost']);
267
        } else {
268
            // find host from headers
269
            $host = $this->getHttpHost($request, $trustedHostData[self::DATA_KEY_HOST_HEADERS]);
270
            if ($host !== null) {
271
                $uri = $uri->withHost($host);
272
            }
273
        }
274
        if (isset($ipData['protocol'])) {
275
            $uri = $uri->withScheme($ipData['protocol']);
276
        } else {
277
            // find scheme from headers
278
            $scheme = $this->getScheme($request, $trustedHostData[self::DATA_KEY_PROTOCOL_HEADERS]);
279
            if ($scheme !== null) {
280
                $uri = $uri->withScheme($scheme);
281
            }
282
        }
283
        $urlParts = $this->getUrl($request, $trustedHostData[self::DATA_KEY_URL_HEADERS]);
284
        if ($urlParts !== null) {
285
            [$path, $query] = $urlParts;
286
            $uri = $uri->withPath($path);
287
            if ($query !== null) {
288
                $uri = $uri->withQuery($query);
289
            }
290
        }
291
        return $handler->handle($request->withUri($uri)->withAttribute('requestClientIp', $ipData['ip']));
292
    }
293
294
    /**
295
     * Validate host by range
296
     *
297
     * This method can be extendable by overwriting eg. with reverse DNS verification.
298
     */
299
    protected function isValidHost(string $host, array $ranges, Ip $validator): bool
300
    {
301
        return $validator->ranges($ranges)->validate($host)->isValid();
302
    }
303
304
    /**
305
     * Reverse obfuscating host data
306
     *
307
     * The base operation does not perform any transformation on the data.
308
     * This method can be extendable by overwriting eg.
309
     */
310
    protected function reverseObfuscate(
311
        array $ipData,
312
        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

312
        /** @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...
313
        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

313
        /** @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...
314
        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

314
        /** @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...
315
    ): array {
316
        return $ipData;
317
    }
318
319
    private function prepareProtocolHeaders(array $protocolHeaders): array
320
    {
321
        $output = [];
322
        foreach ($protocolHeaders as $header => $protocolAndAcceptedValues) {
323
            $header = strtolower($header);
324
            if (\is_callable($protocolAndAcceptedValues)) {
325
                $output[$header] = $protocolAndAcceptedValues;
326
                continue;
327
            }
328
            if (!\is_array($protocolAndAcceptedValues)) {
329
                throw new \RuntimeException('Accepted values is not an array nor callable');
330
            }
331
            if (count($protocolAndAcceptedValues) === 0) {
332
                throw new \RuntimeException('Accepted values cannot be an empty array');
333
            }
334
            $output[$header] = [];
335
            foreach ($protocolAndAcceptedValues as $protocol => $acceptedValues) {
336
                if (!\is_string($protocol)) {
337
                    throw new \RuntimeException('The protocol must be a string');
338
                }
339
                if ($protocol === '') {
340
                    throw new \RuntimeException('The protocol cannot be empty');
341
                }
342
                $output[$header][$protocol] = array_map('strtolower', (array)$acceptedValues);
343
            }
344
        }
345
        return $output;
346
    }
347
348
    private function removeHeaders(ServerRequestInterface $request, array $headers): ServerRequestInterface
349
    {
350
        foreach ($headers as $header) {
351
            $request = $request->withoutAttribute($header);
352
        }
353
        return $request;
354
    }
355
356
    private function getIpList(RequestInterface $request, array $ipHeaders): array
357
    {
358
        foreach ($ipHeaders as $ipHeader) {
359
            $type = null;
360
            if (\is_array($ipHeader)) {
361
                $type = array_shift($ipHeader);
362
                $ipHeader = array_shift($ipHeader);
363
            }
364
            if ($request->hasHeader($ipHeader)) {
365
                return [$type, $request->getHeader($ipHeader)];
366
            }
367
        }
368
        return [null, []];
369
    }
370
371
    private function getFormattedIpList(array $forwards): array
372
    {
373
        $list = [];
374
        foreach ($forwards as $ip) {
375
            $list[] = ['ip' => $ip];
376
        }
377
        return $list;
378
    }
379
380
    /**
381
     * Forwarded elements by RFC7239
382
     *
383
     * @link https://tools.ietf.org/html/rfc7239
384
     */
385
    private function getForwardedElements(array $forwards): array
386
    {
387
        $list = [];
388
        foreach ($forwards as $forward) {
389
            $data = HeaderHelper::getParameters($forward);
390
            if (!isset($data['for'])) {
391
                // Invalid item, the following items will be dropped
392
                break;
393
            }
394
            $pattern = '/^(?<host>' . IpHelper::IPV4_PATTERN . '|unknown|_[\w\.-]+|[[]' . IpHelper::IPV6_PATTERN . '[]])(?::(?<port>[\w\.-]+))?$/';
395
            if (preg_match($pattern, $data['for'], $matches) === 0) {
396
                // Invalid item, the following items will be dropped
397
                break;
398
            }
399
            $ipData = [];
400
            $host = $matches['host'];
401
            $obfuscatedHost = $host === 'unknown' || strpos($host, '_') === 0;
402
            if (!$obfuscatedHost) {
403
                // IPv4 & IPv6
404
                $ipData['ip'] = strpos($host, '[') === 0 ? trim($host /* IPv6 */, '[]') : $host;
405
            }
406
            $ipData['host'] = $host;
407
            if (isset($matches['port'])) {
408
                $port = $matches['port'];
409
                if (!$obfuscatedHost && (preg_match('/^\d{1,5}$/', $port) === 0 || (int)$port > 65535)) {
410
                    // Invalid port, the following items will be dropped
411
                    break;
412
                }
413
                $ipData['port'] = $obfuscatedHost ? $port : (int)$port;
414
            }
415
416
            // copy other properties
417
            foreach (['proto' => 'protocol', 'host' => 'httpHost', 'by' => 'by'] as $source => $destination) {
418
                if (isset($data[$source])) {
419
                    $ipData[$destination] = $data[$source];
420
                }
421
            }
422
423
            $list[] = $ipData;
424
        }
425
        return $list;
426
    }
427
428
    private function getHttpHost(RequestInterface $request, array $hostHeaders): ?string
429
    {
430
        foreach ($hostHeaders as $header) {
431
            if (!$request->hasHeader($header)) {
432
                continue;
433
            }
434
            $host = $request->getHeaderLine($header);
435
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) !== false) {
436
                return $host;
437
            }
438
        }
439
        return null;
440
    }
441
442
    private function getScheme(RequestInterface $request, array $protocolHeaders): ?string
443
    {
444
        foreach ($protocolHeaders as $header => $ref) {
445
            if (!$request->hasHeader($header)) {
446
                continue;
447
            }
448
            $value = strtolower($request->getHeaderLine($header));
449
            foreach ($ref as $protocol => $acceptedValues) {
450
                if (\in_array($value, $acceptedValues, true)) {
451
                    return $protocol;
452
                }
453
            }
454
        }
455
        return null;
456
    }
457
458
    private function getUrl(RequestInterface $request, array $urlHeaders): ?array
459
    {
460
        foreach ($urlHeaders as $header) {
461
            if (!$request->hasHeader($header)) {
462
                continue;
463
            }
464
            $url = $request->getHeaderLine($header);
465
            if (strpos($url, '/') === 0) {
466
                return array_pad(explode('?', $url, 2), 2, null);
467
            }
468
        }
469
        return null;
470
    }
471
}
472