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

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