Completed
Pull Request — master (#157)
by Alexander
02:02
created

getForwardedElements()   C

Complexity

Conditions 13
Paths 6

Size

Total Lines 41
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 15.3362

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 13
eloc 24
c 1
b 0
f 0
nc 6
nop 1
dl 0
loc 41
ccs 19
cts 25
cp 0.76
crap 15.3362
rs 6.6166

How to fix   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 10
    public function __construct(ResponseFactoryInterface $responseFactory)
80
    {
81 10
        $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 1
    public function withNotTrustedBranch(?MiddlewareInterface $middleware)
100
    {
101 1
        $new = clone $this;
102 1
        $new->notTrustedBranch = $middleware;
103 1
        return $new;
104
    }
105
106
    /**
107
     * @return static
108
     */
109 8
    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 8
        $new = clone $this;
118 8
        $ipHeaders = $ipHeaders ?? self::DEFAULT_IP_HEADERS;
119 8
        foreach ($ipHeaders as $ipHeader) {
120 8
            if (is_string($ipHeader)) {
121 8
                continue;
122
            }
123 8
            if (!is_array($ipHeader)) {
124
                throw new \InvalidArgumentException('Type of ipHeader is not a string and not array');
125
            }
126 8
            if (count($ipHeader) !== 2) {
127
                throw new \InvalidArgumentException('The ipHeader array must have exactly 2 elements');
128
            }
129 8
            [$type, $header] = $ipHeader;
130 8
            if (!is_string($type)) {
131
                throw new \InvalidArgumentException('The type is not a string');
132
            }
133 8
            if (!is_string($header)) {
134
                throw new \InvalidArgumentException('The header is not a string');
135
            }
136 8
            if ($type === self::IP_HEADER_TYPE_RFC7239) {
137 8
                continue;
138
            }
139
140
            throw new \InvalidArgumentException("Not supported IP header type: $type");
141
        }
142 8
        $new->trustedHosts[] = [
143 8
            self::DATA_KEY_HOSTS => $hosts,
144 8
            self::DATA_KEY_IP_HEADERS => $ipHeaders,
145 8
            self::DATA_KEY_PROTOCOL_HEADERS => $this->prepareProtocolHeaders($protocolHeaders ?? self::DEFAULT_PROTOCOL_HEADERS),
146 8
            self::DATA_KEY_TRUSTED_HEADERS => $trustedHeaders ?? self::DEFAULT_TRUSTED_HEADERS,
147 8
            self::DATA_KEY_HOST_HEADERS => $hostHeaders ?? self::DEFAULT_HOST_HEADERS,
148 8
            self::DATA_KEY_URL_HEADERS => $urlHeaders ?? self::DEFAULT_URL_HEADERS,
149
        ];
150 8
        return $new;
151
    }
152
153
    /**
154
     * @return static
155
     */
156
    public function withoutTrustedHosts()
157
    {
158
        $new = clone $this;
159
        $new->trustedHosts = [];
160
        return $new;
161
    }
162
163
    /**
164
     * @return static
165
     */
166
    public function withAttributeIps(?string $attribute)
167
    {
168
        if ($attribute === '') {
169
            throw new \RuntimeException('Attribute should not be empty');
170
        }
171
        $new = clone $this;
172
        $new->attributeIps = $attribute;
173
        return $new;
174
    }
175
176
    /**
177
     * Process an incoming server request.
178
     *
179
     * Processes an incoming server request in order to produce a response.
180
     * If unable to produce the response itself, it may delegate to the provided
181
     * request handler to do so.
182
     */
183 10
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
184
    {
185 10
        $actualHost = $request->getServerParams()['REMOTE_ADDR'];
186 10
        $trustedHostData = null;
187 10
        $trustedHeaders = [];
188 10
        $ipValidator = $this->ipValidator ?? new Ip();
189 10
        foreach ($this->trustedHosts as $data) {
190
            // collect all trusted headers
191 8
            $trustedHeaders = array_merge($trustedHeaders, $data[self::DATA_KEY_TRUSTED_HEADERS]);
192 8
            if ($trustedHostData !== null) {
193
                // trusted hosts already found
194
                continue;
195
            }
196 8
            if ($this->isValidHost($actualHost, $data[self::DATA_KEY_HOSTS], $ipValidator)) {
197 6
                $trustedHostData = $data;
198
            }
199
        }
200 10
        $untrustedHeaders = array_diff($trustedHeaders, $trustedHostData[self::DATA_KEY_TRUSTED_HEADERS] ?? []);
201 10
        $request = $this->removeHeaders($request, $untrustedHeaders);
202 10
        if ($trustedHostData === null) {
203
            // No trusted host at all.
204 4
            if ($this->notTrustedBranch !== null) {
205 1
                return $this->notTrustedBranch->process($request, $handler);
206
            }
207 3
            $response = $this->responseFactory->createResponse(412);
208 3
            $response->getBody()->write('Unable to verify your network.');
209 3
            return $response;
210
        }
211 6
        [$type, $ipList] = $this->getIpList($request, $trustedHostData[self::DATA_KEY_IP_HEADERS]);
212 6
        $ipList = array_reverse($ipList);       // the first item should be the closest to the server
213 6
        if ($type === null) {
214 2
            $ipList = $this->getFormattedIpList($ipList);
215 4
        } elseif ($type === self::IP_HEADER_TYPE_RFC7239) {
216 4
            $ipList = $this->getForwardedElements($ipList);
217
        }
218 6
        array_unshift($ipList, ['ip' => $actualHost]);  // server's ip to first position
219 6
        $ipDataList = [];
220
        do {
221 6
            $ipData = array_shift($ipList);
222 6
            if (!isset($ipData['ip'])) {
223
                $ipData = $this->reverseObfuscate($ipData, $ipDataList);
224
                if (!isset($ipData['ip'])) {
225
                    break;
226
                }
227
            }
228 6
            $ip = $ipData['ip'];
229 6
            if (!$this->isValidHost($ip, ['any'], $ipValidator)) {
230
                break;
231
            }
232 6
            $ipDataList[] = $ipData;
233 6
            if (!$this->isValidHost($ip, $trustedHostData[self::DATA_KEY_HOSTS], $ipValidator)) {
234 6
                break;
235
            }
236 6
        } while (count($ipList) > 0);
237
238 6
        if ($this->attributeIps !== null) {
239
            $request = $request->withAttribute($this->attributeIps, $ipDataList);
240
        }
241
242 6
        $uri = $request->getUri();
243 6
        if (isset($ipData['httpHost'])) {
244 2
            $uri = $uri->withHost($ipData['httpHost']);
245
        } else {
246
            // find host from headers
247 4
            $host = $this->getHttpHost($request, $trustedHostData[self::DATA_KEY_HOST_HEADERS]);
248 4
            if ($host !== null) {
249
                $uri = $uri->withHost($host);
250
            }
251
        }
252 6
        if (isset($ipData['protocol'])) {
253 2
            $uri = $uri->withScheme($ipData['protocol']);
254
        } else {
255
            // find scheme from headers
256 4
            $scheme = $this->getScheme($request, $trustedHostData[self::DATA_KEY_PROTOCOL_HEADERS]);
257 4
            if ($scheme !== null) {
258
                $uri = $uri->withScheme($scheme);
259
            }
260
        }
261 6
        $urlParts = $this->getUrl($request, $trustedHostData[self::DATA_KEY_URL_HEADERS]);
262 6
        if ($urlParts !== null) {
263 1
            [$path, $query] = $urlParts;
264 1
            $uri = $uri->withPath($path);
265 1
            if ($query !== null) {
266 1
                $uri = $uri->withQuery($query);
267
            }
268
        }
269 6
        return $handler->handle($request->withUri($uri)->withAttribute('requestClientIp', $ipData['ip']));
270
    }
271
272
    /**
273
     * Validate host by range
274
     *
275
     * This method can be extendable by overwriting eg. with reverse DNS verification.
276
     */
277 8
    protected function isValidHost(string $host, array $ranges, Ip $validator): bool
278
    {
279 8
        return $validator->ranges($ranges)->validate($host)->isValid();
280
    }
281
282
    /**
283
     * Reverse obfuscating host data
284
     *
285
     * The base operation does not perform any transformation on the data.
286
     * This method can be extendable by overwriting eg.
287
     */
288
    protected function reverseObfuscate(array $ipData, array $ipDataList): array
0 ignored issues
show
Unused Code introduced by
The parameter $ipDataList 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

288
    protected function reverseObfuscate(array $ipData, /** @scrutinizer ignore-unused */ array $ipDataList): array

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