Passed
Push — master ( 8cc129...e6cd77 )
by
unknown
02:34
created

getElementsByRfc7239()   C

Complexity

Conditions 16
Paths 10

Size

Total Lines 63
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 27
CRAP Score 16.256

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 16
eloc 30
c 1
b 0
f 0
nc 10
nop 1
dl 0
loc 63
ccs 27
cts 30
cp 0.9
crap 16.256
rs 5.5666

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
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Middleware;
6
7
use Closure;
8
use InvalidArgumentException;
9
use Psr\Http\Message\RequestInterface;
10
use Psr\Http\Message\ResponseInterface;
11
use Psr\Http\Message\ServerRequestInterface;
12
use Psr\Http\Server\MiddlewareInterface;
13
use Psr\Http\Server\RequestHandlerInterface;
14
use RuntimeException;
15
use Yiisoft\Http\HeaderValueHelper;
16
use Yiisoft\NetworkUtilities\IpHelper;
17
use Yiisoft\Validator\Result;
18
use Yiisoft\Validator\Rule\Ip;
19
use Yiisoft\Validator\ValidatorInterface;
20
21
use function array_diff;
22
use function array_reverse;
23
use function array_shift;
24
use function array_unshift;
25
use function count;
26
use function explode;
27
use function filter_var;
28
use function in_array;
29
use function is_array;
30
use function is_callable;
31
use function is_string;
32
use function preg_match;
33
use function str_replace;
34
use function str_starts_with;
35
use function strtolower;
36
use function trim;
37
38
/**
39
 * Trusted hosts network resolver.
40
 *
41
 * ```php
42
 * $trustedHostsNetworkResolver->withAddedTrustedHosts(
43
 *   // List of secure hosts including $_SERVER['REMOTE_ADDR'], can specify IPv4, IPv6, domains and aliases {@see Ip}.
44
 *   ['1.1.1.1', '2.2.2.1/3', '2001::/32', 'localhost'].
45
 *   // IP list headers. For advanced handling of headers {@see TrustedHostsNetworkResolver::IP_HEADER_TYPE_RFC7239}.
46
 *   // Headers containing multiple sub-elements (e.g. RFC 7239) must also be listed for other relevant types
47
 *   // (e.g. host headers), otherwise they will only be used as an IP list.
48
 *   ['x-forwarded-for', [TrustedHostsNetworkResolver::IP_HEADER_TYPE_RFC7239, 'forwarded']]
49
 *   // Protocol headers with accepted protocols and values. Matching of values is case-insensitive.
50
 *   ['front-end-https' => ['https' => 'on']],
51
 *   // Host headers
52
 *   ['forwarded', 'x-forwarded-for']
53
 *   // URL headers
54
 *   ['x-rewrite-url'],
55
 *   // Port headers
56
 *   ['x-rewrite-port'],
57
 *   // Trusted headers. It is a good idea to list all relevant headers.
58
 *   ['x-forwarded-for', 'forwarded', ...],
59
 * );
60
 * ```
61
 *
62
 * @psalm-type HostData = array{ip?:string, host?: string, by?: string, port?: string|int, protocol?: string, httpHost?: string}
63
 * @psalm-type ProtocolHeadersData = array<string, array<non-empty-string, array<array-key, string>>|callable>
64
 * @psalm-type TrustedHostData = array{
65
 *     'hosts': array<array-key, string>,
66
 *     'ipHeaders': array<array-key, string>,
67
 *     'protocolHeaders': ProtocolHeadersData,
68
 *     'hostHeaders': array<array-key, string>,
69
 *     'urlHeaders': array<array-key, string>,
70
 *     'portHeaders': array<array-key, string>,
71
 *     'trustedHeaders': array<array-key, string>
72
 * }
73
 */
74
class TrustedHostsNetworkResolver implements MiddlewareInterface
75
{
76
    public const REQUEST_CLIENT_IP = 'requestClientIp';
77
    public const IP_HEADER_TYPE_RFC7239 = 'rfc7239';
78
79
    public const DEFAULT_TRUSTED_HEADERS = [
80
        // common:
81
        'x-forwarded-for',
82
        'x-forwarded-host',
83
        'x-forwarded-proto',
84
        'x-forwarded-port',
85
86
        // RFC:
87
        'forward',
88
89
        // Microsoft:
90
        'front-end-https',
91
        'x-rewrite-url',
92
    ];
93
94
    private const DATA_KEY_HOSTS = 'hosts';
95
    private const DATA_KEY_IP_HEADERS = 'ipHeaders';
96
    private const DATA_KEY_HOST_HEADERS = 'hostHeaders';
97
    private const DATA_KEY_URL_HEADERS = 'urlHeaders';
98
    private const DATA_KEY_PROTOCOL_HEADERS = 'protocolHeaders';
99
    private const DATA_KEY_TRUSTED_HEADERS = 'trustedHeaders';
100
    private const DATA_KEY_PORT_HEADERS = 'portHeaders';
101
102
    /**
103
     * @var array<TrustedHostData>
104
     */
105
    private array $trustedHosts = [];
106
    private ?string $attributeIps = null;
107
108 58
    public function __construct(private ValidatorInterface $validator)
109
    {
110 58
    }
111
112
    /**
113
     * Returns a new instance with the added trusted hosts and related headers.
114
     *
115
     * The header lists are evaluated in the order they were specified.
116
     * If you specify multiple headers by type (e.g. IP headers), you must ensure that the irrelevant header is removed
117
     * e.g. web server application, otherwise spoof clients can be use this vulnerability.
118
     *
119
     * @param string[] $hosts List of trusted hosts IP addresses. If {@see isValidHost()} method is extended,
120
     * then can use domain names with reverse DNS resolving e.g. yiiframework.com, * .yiiframework.com.
121
     * @param array $ipHeaders List of headers containing IP lists.
122
     * @param array $protocolHeaders List of headers containing protocol. e.g.
123
     * ['x-forwarded-for' => ['http' => 'http', 'https' => ['on', 'https']]].
124
     * @param string[] $hostHeaders List of headers containing HTTP host.
125
     * @param string[] $urlHeaders List of headers containing HTTP URL.
126
     * @param string[] $portHeaders List of headers containing port number.
127
     * @param string[]|null $trustedHeaders List of trusted headers. Removed from the request, if in checking process
128
     * are classified as untrusted by hosts.
129
     */
130 55
    public function withAddedTrustedHosts(
131
        array $hosts,
132
        // Defining default headers is not secure!
133
        array $ipHeaders = [],
134
        array $protocolHeaders = [],
135
        array $hostHeaders = [],
136
        array $urlHeaders = [],
137
        array $portHeaders = [],
138
        ?array $trustedHeaders = null,
139
    ): self {
140 55
        if ($hosts === []) {
141 1
            throw new InvalidArgumentException('Empty hosts are not allowed.');
142
        }
143
144 54
        foreach ($ipHeaders as $ipHeader) {
145 29
            if (is_string($ipHeader)) {
146 9
                continue;
147
            }
148
149 21
            if (!is_array($ipHeader)) {
150 1
                throw new InvalidArgumentException('IP header must have either string or array type.');
151
            }
152
153 21
            if (count($ipHeader) !== 2) {
154 2
                throw new InvalidArgumentException('IP header array must have exactly 2 elements.');
155
            }
156
157 21
            [$type, $header] = $ipHeader;
158
159 21
            if (!is_string($type)) {
160 1
                throw new InvalidArgumentException('IP header type must be a string.');
161
            }
162
163 21
            if (!is_string($header)) {
164 1
                throw new InvalidArgumentException('IP header value must be a string.');
165
            }
166
167 21
            if ($type === self::IP_HEADER_TYPE_RFC7239) {
168 21
                continue;
169
            }
170
171 1
            throw new InvalidArgumentException("Not supported IP header type: \"$type\".");
172
        }
173
174 48
        $trustedHeaders ??= self::DEFAULT_TRUSTED_HEADERS;
175
        /** @psalm-var ProtocolHeadersData $protocolHeaders */
176 48
        $protocolHeaders = $this->prepareProtocolHeaders($protocolHeaders);
177
178 43
        $this->requireListOfNonEmptyStrings($hosts, self::DATA_KEY_HOSTS);
179 40
        $this->requireListOfNonEmptyStrings($trustedHeaders, self::DATA_KEY_TRUSTED_HEADERS);
180 37
        $this->requireListOfNonEmptyStrings($hostHeaders, self::DATA_KEY_HOST_HEADERS);
181 34
        $this->requireListOfNonEmptyStrings($urlHeaders, self::DATA_KEY_URL_HEADERS);
182 31
        $this->requireListOfNonEmptyStrings($portHeaders, self::DATA_KEY_PORT_HEADERS);
183
184 28
        foreach ($hosts as $host) {
185 28
            $host = str_replace('*', 'wildcard', $host); // wildcard is allowed in host
186
187 28
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) === false) {
188 1
                throw new InvalidArgumentException("\"$host\" host must be either a domain or an IP address.");
189
            }
190
        }
191
192 27
        $new = clone $this;
193
        /** @psalm-var array<array-key, string> $ipHeaders */
194 27
        $new->trustedHosts[] = [
195 27
            self::DATA_KEY_HOSTS => $hosts,
196 27
            self::DATA_KEY_IP_HEADERS => $ipHeaders,
197 27
            self::DATA_KEY_PROTOCOL_HEADERS => $protocolHeaders,
198 27
            self::DATA_KEY_TRUSTED_HEADERS => $trustedHeaders,
199 27
            self::DATA_KEY_HOST_HEADERS => $hostHeaders,
200 27
            self::DATA_KEY_URL_HEADERS => $urlHeaders,
201 27
            self::DATA_KEY_PORT_HEADERS => $portHeaders,
202 27
        ];
203
204 27
        return $new;
205
    }
206
207
    /**
208
     * Returns a new instance without the trusted hosts and related headers.
209
     */
210 1
    public function withoutTrustedHosts(): self
211
    {
212 1
        $new = clone $this;
213 1
        $new->trustedHosts = [];
214 1
        return $new;
215
    }
216
217
    /**
218
     * Returns a new instance with the specified request's attribute name to which trusted path data is added.
219
     *
220
     * @param string|null $attribute The request attribute name.
221
     *
222
     * @see getElementsByRfc7239()
223
     */
224 57
    public function withAttributeIps(?string $attribute): self
225
    {
226 57
        if ($attribute === '') {
227 1
            throw new RuntimeException('Attribute should not be empty string.');
228
        }
229
230 57
        $new = clone $this;
231 57
        $new->attributeIps = $attribute;
232 57
        return $new;
233
    }
234
235 28
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
236
    {
237
        /** @var string|null $actualHost */
238 28
        $actualHost = $request->getServerParams()['REMOTE_ADDR'] ?? null;
239
240 28
        if ($actualHost === null) {
241
            // Validation is not possible.
242 1
            return $this->handleNotTrusted($request, $handler);
243
        }
244
245 27
        $trustedHostData = null;
246 27
        $trustedHeaders = [];
247
248 27
        foreach ($this->trustedHosts as $data) {
249
            // collect all trusted headers
250 26
            $trustedHeaders[] = $data[self::DATA_KEY_TRUSTED_HEADERS];
251
252 26
            if ($trustedHostData === null && $this->isValidHost($actualHost, $data[self::DATA_KEY_HOSTS])) {
253 24
                $trustedHostData = $data;
254
            }
255
        }
256
257 27
        if ($trustedHostData === null) {
258
            // No trusted host at all.
259 3
            return $this->handleNotTrusted($request, $handler);
260
        }
261
262 24
        $trustedHeaders = array_merge(...$trustedHeaders);
263
        /** @psalm-var array<string, array<array-key,string>> $requestHeaders */
264 24
        $requestHeaders = $request->getHeaders();
265 24
        $untrustedHeaders = array_diff(array_keys($requestHeaders), $trustedHeaders);
266 24
        $request = $this->removeHeaders($request, $untrustedHeaders);
267
268 24
        [$ipListType, $ipHeader, $hostList] = $this->getIpList($request, $trustedHostData[self::DATA_KEY_IP_HEADERS]);
269 24
        $hostList = array_reverse($hostList); // the first item should be the closest to the server
270
271 24
        if ($ipListType === self::IP_HEADER_TYPE_RFC7239) {
272 13
            $hostList = $this->getElementsByRfc7239($hostList);
273
        } else {
274 11
            $hostList = $this->getFormattedIpList($hostList);
275
        }
276
277 24
        array_unshift($hostList, ['ip' => $actualHost]); // Move server's IP to the first position
278 24
        $hostDataList = [];
279
280
        do {
281 24
            $hostData = array_shift($hostList);
282 24
            if (!isset($hostData['ip'])) {
283
                $hostData = $this->reverseObfuscate($hostData, $hostDataList, $hostList, $request);
284
285
                if ($hostData === null) {
286
                    continue;
287
                }
288
289
                if (!isset($hostData['ip'])) {
290
                    break;
291
                }
292
            }
293
294 24
            $ip = $hostData['ip'];
295
296 24
            if (!$this->isValidHost($ip, ['any'])) {
297
                // invalid IP
298
                break;
299
            }
300
301 24
            $hostDataList[] = $hostData;
302
303 24
            if (!$this->isValidHost($ip, $trustedHostData[self::DATA_KEY_HOSTS])) {
304
                // not trusted host
305 17
                break;
306
            }
307 24
        } while (count($hostList) > 0);
308
309 24
        if ($this->attributeIps !== null) {
310 23
            $request = $request->withAttribute($this->attributeIps, $hostDataList);
311
        }
312
313 24
        $uri = $request->getUri();
314
        // find HTTP host
315 24
        foreach ($trustedHostData[self::DATA_KEY_HOST_HEADERS] as $hostHeader) {
316 9
            if (!$request->hasHeader($hostHeader)) {
317 2
                continue;
318
            }
319
320
            if (
321 9
                $hostHeader === $ipHeader
322 9
                && $ipListType === self::IP_HEADER_TYPE_RFC7239
323 9
                && isset($hostData['httpHost'])
324
            ) {
325 5
                $uri = $uri->withHost($hostData['httpHost']);
326 5
                break;
327
            }
328
329 4
            $host = $request->getHeaderLine($hostHeader);
330
331 4
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) !== false) {
332 4
                $uri = $uri->withHost($host);
333 4
                break;
334
            }
335
        }
336
337
        // find protocol
338
        /** @psalm-var ProtocolHeadersData $protocolHeadersData */
339 24
        $protocolHeadersData = $trustedHostData[self::DATA_KEY_PROTOCOL_HEADERS];
340 24
        foreach ($protocolHeadersData as $protocolHeader => $protocols) {
341 10
            if (!$request->hasHeader($protocolHeader)) {
342 2
                continue;
343
            }
344
345
            if (
346 10
                $protocolHeader === $ipHeader
347 10
                && $ipListType === self::IP_HEADER_TYPE_RFC7239
348 10
                && isset($hostData['protocol'])
349
            ) {
350 7
                $uri = $uri->withScheme($hostData['protocol']);
351 7
                break;
352
            }
353
354 5
            $protocolHeaderValue = $request->getHeaderLine($protocolHeader);
355
356 5
            foreach ($protocols as $protocol => $acceptedValues) {
357 5
                if (in_array($protocolHeaderValue, $acceptedValues, true)) {
358 1
                    $uri = $uri->withScheme($protocol);
359 1
                    break 2;
360
                }
361
            }
362
        }
363
364 24
        $urlParts = $this->getUrl($request, $trustedHostData[self::DATA_KEY_URL_HEADERS]);
365
366 24
        if ($urlParts !== null) {
367 7
            [$path, $query] = $urlParts;
368 7
            if ($path !== null) {
0 ignored issues
show
introduced by
The condition $path !== null is always true.
Loading history...
369 7
                $uri = $uri->withPath($path);
370
            }
371
372 7
            if ($query !== null) {
0 ignored issues
show
introduced by
The condition $query !== null is always true.
Loading history...
373 6
                $uri = $uri->withQuery($query);
374
            }
375
        }
376
377
        // find port
378 24
        foreach ($trustedHostData[self::DATA_KEY_PORT_HEADERS] as $portHeader) {
379 4
            if (!$request->hasHeader($portHeader)) {
380 3
                continue;
381
            }
382
383
            if (
384 4
                $portHeader === $ipHeader
385 4
                && $ipListType === self::IP_HEADER_TYPE_RFC7239
386 4
                && isset($hostData['port'])
387 4
                && $this->checkPort((string) $hostData['port'])
388
            ) {
389 3
                $uri = $uri->withPort((int) $hostData['port']);
390 3
                break;
391
            }
392
393 1
            $port = $request->getHeaderLine($portHeader);
394
395 1
            if ($this->checkPort($port)) {
396 1
                $uri = $uri->withPort((int) $port);
397 1
                break;
398
            }
399
        }
400
401 24
        return $handler->handle(
402 24
            $request->withUri($uri)->withAttribute(self::REQUEST_CLIENT_IP, $hostData['ip'] ?? null)
403 24
        );
404
    }
405
406
    /**
407
     * Validate host by range.
408
     *
409
     * This method can be extendable by overwriting e.g. with reverse DNS verification.
410
     *
411
     * @param string[] $ranges
412
     * @param Closure(string, string[]): Result $validator
413
     */
414 26
    protected function isValidHost(string $host, array $ranges): bool
415
    {
416 26
        $result = $this->validator->validate(
417 26
            $host,
418 26
            [new Ip(allowSubnet: false, allowNegation: false, ranges: $ranges)]
419 26
        );
420 26
        return $result->isValid();
421
    }
422
423
    /**
424
     * Reverse obfuscating host data
425
     *
426
     * RFC 7239 allows to use obfuscated host data. In this case, either specifying the
427
     * IP address or dropping the proxy endpoint is required to determine validated route.
428
     *
429
     * By default, it does not perform any transformation on the data. You can override this method.
430
     *
431
     * @return array|null reverse obfuscated host data or null.
432
     * In case of null data is discarded and the process continues with the next portion of host data.
433
     * If the return value is an array, it must contain at least the `ip` key.
434
     *
435
     * @psalm-param HostData|null $hostData
436
     *
437
     * @psalm-return HostData|null
438
     *
439
     * @see getElementsByRfc7239()
440
     * @link https://tools.ietf.org/html/rfc7239#section-6.2
441
     * @link https://tools.ietf.org/html/rfc7239#section-6.3
442
     */
443
    protected function reverseObfuscate(
444
        ?array $hostData,
445
        array $hostDataListValidated,
0 ignored issues
show
Unused Code introduced by
The parameter $hostDataListValidated 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

445
        /** @scrutinizer ignore-unused */ array $hostDataListValidated,

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

446
        /** @scrutinizer ignore-unused */ array $hostDataListRemaining,

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...
447
        RequestInterface $request
448
    ): ?array {
449
        return $hostData;
450
    }
451
452 4
    private function handleNotTrusted(
453
        ServerRequestInterface $request,
454
        RequestHandlerInterface $handler
455
    ): ResponseInterface {
456 4
        if ($this->attributeIps !== null) {
457 4
            $request = $request->withAttribute($this->attributeIps, null);
458
        }
459
460 4
        return $handler->handle($request->withAttribute(self::REQUEST_CLIENT_IP, null));
461
    }
462
463
    /**
464
     * @psalm-return ProtocolHeadersData
465
     */
466 48
    private function prepareProtocolHeaders(array $protocolHeaders): array
467
    {
468 48
        $output = [];
469
470 48
        foreach ($protocolHeaders as $header => $protocolAndAcceptedValues) {
471 15
            if (!is_string($header)) {
472 1
                throw new InvalidArgumentException('The protocol header array key must be a string.');
473
            }
474
475 15
            $header = strtolower($header);
476
477 15
            if (is_callable($protocolAndAcceptedValues)) {
478 3
                $output[$header] = $protocolAndAcceptedValues;
479 3
                continue;
480
            }
481
482 12
            if (!is_array($protocolAndAcceptedValues)) {
483 1
                throw new InvalidArgumentException(
484 1
                    'Accepted values for protocol headers must be either an array or a callable.',
485 1
                );
486
            }
487
488 12
            if ($protocolAndAcceptedValues === []) {
489 1
                throw new InvalidArgumentException('Accepted values for protocol headers cannot be an empty array.');
490
            }
491
492 12
            $output[$header] = [];
493
494
            /**
495
             * @psalm-var array<string|string[]> $protocolAndAcceptedValues
496
             */
497 12
            foreach ($protocolAndAcceptedValues as $protocol => $acceptedValues) {
498 12
                if (!is_string($protocol)) {
499 1
                    throw new InvalidArgumentException('The protocol must be a string.');
500
                }
501
502 12
                if ($protocol === '') {
503 1
                    throw new InvalidArgumentException('The protocol must be non-empty string.');
504
                }
505
506 12
                $output[$header][$protocol] = array_map('\strtolower', (array) $acceptedValues);
507
            }
508
        }
509
510 43
        return $output;
511
    }
512
513
    /**
514
     * @param string[] $headers
515
     */
516 24
    private function removeHeaders(ServerRequestInterface $request, array $headers): ServerRequestInterface
517
    {
518 24
        foreach ($headers as $header) {
519 5
            $request = $request->withoutHeader($header);
520
        }
521
522 24
        return $request;
523
    }
524
525
    /**
526
     * @param array<string|string[]> $ipHeaders
527
     *
528
     * @return array{0: string|null, 1: string|null, 2: string[]}
529
     */
530 24
    private function getIpList(ServerRequestInterface $request, array $ipHeaders): array
531
    {
532 24
        foreach ($ipHeaders as $ipHeader) {
533 21
            $type = null;
534
535 21
            if (is_array($ipHeader)) {
536 15
                $type = array_shift($ipHeader);
537 15
                $ipHeader = array_shift($ipHeader);
538
            }
539
540 21
            if ($request->hasHeader($ipHeader)) {
541 19
                return [$type, $ipHeader, $request->getHeader($ipHeader)];
542
            }
543
        }
544
545 5
        return [null, null, []];
546
    }
547
548
    /**
549
     * @param string[] $forwards
550
     *
551
     * @psalm-return list<HostData>
552
     *
553
     * @see getElementsByRfc7239()
554
     */
555 11
    private function getFormattedIpList(array $forwards): array
556
    {
557 11
        $list = [];
558
559 11
        foreach ($forwards as $ip) {
560 6
            $list[] = ['ip' => $ip];
561
        }
562
563 11
        return $list;
564
    }
565
566
    /**
567
     * Forwarded elements by RFC7239.
568
     *
569
     * The structure of the elements:
570
     * - `host`: IP or obfuscated hostname or "unknown"
571
     * - `ip`: IP address (only if presented)
572
     * - `by`: used user-agent by proxy (only if presented)
573
     * - `port`: port number received by proxy (only if presented)
574
     * - `protocol`: protocol received by proxy (only if presented)
575
     * - `httpHost`: HTTP host received by proxy (only if presented)
576
     *
577
     * The list starts with the server and the last item is the client itself.
578
     *
579
     * @link https://tools.ietf.org/html/rfc7239
580
     *
581
     * @param string[] $forwards
582
     *
583
     * @psalm-return list<HostData> Proxy data elements.
584
     */
585 13
    private function getElementsByRfc7239(array $forwards): array
586
    {
587 13
        $list = [];
588
589 13
        foreach ($forwards as $forward) {
590
            try {
591
                /** @psalm-var array<string, string> $data */
592 13
                $data = HeaderValueHelper::getParameters($forward);
593
            } catch (InvalidArgumentException) {
594
                break;
595
            }
596
597 13
            if (!isset($data['for'])) {
598
                // Invalid item, the following items will be dropped
599 1
                break;
600
            }
601
602 13
            $pattern = '/^(?<host>' . IpHelper::IPV4_PATTERN . '|unknown|_[\w\.-]+|[[]'
603 13
                . IpHelper::IPV6_PATTERN . '[]])(?::(?<port>[\w\.-]+))?$/';
604
605 13
            if (preg_match($pattern, $data['for'], $matches) === 0) {
606
                // Invalid item, the following items will be dropped
607 1
                break;
608
            }
609
610 13
            $ipData = [];
611 13
            $host = $matches['host'];
612 13
            $obfuscatedHost = $host === 'unknown' || str_starts_with($host, '_');
613
614 13
            if (!$obfuscatedHost) {
615
                // IPv4 & IPv6
616 13
                $ipData['ip'] = str_starts_with($host, '[') ? trim($host /* IPv6 */, '[]') : $host;
617
            }
618
619 13
            $ipData['host'] = $host;
620
621 13
            if (isset($matches['port'])) {
622 3
                $port = $matches['port'];
623
624 3
                if (!$obfuscatedHost && !$this->checkPort($port)) {
625
                    // Invalid port, the following items will be dropped
626 3
                    break;
627
                }
628
629 3
                $ipData['port'] = $obfuscatedHost ? $port : (int) $port;
630
            }
631
632
            // copy other properties
633 13
            foreach (['proto' => 'protocol', 'host' => 'httpHost', 'by' => 'by'] as $source => $destination) {
634 13
                if (isset($data[$source])) {
635 7
                    $ipData[$destination] = $data[$source];
636
                }
637
            }
638
639 13
            if (isset($ipData['httpHost']) && filter_var($ipData['httpHost'], FILTER_VALIDATE_DOMAIN) === false) {
640
                // remove not valid HTTP host
641
                unset($ipData['httpHost']);
642
            }
643
644 13
            $list[] = $ipData;
645
        }
646
647 13
        return $list;
648
    }
649
650
    /**
651
     * @param string[] $urlHeaders
652
     *
653
     * @psalm-return non-empty-list<null|string>|null
654
     */
655 24
    private function getUrl(RequestInterface $request, array $urlHeaders): ?array
656
    {
657 24
        foreach ($urlHeaders as $header) {
658 7
            if (!$request->hasHeader($header)) {
659 3
                continue;
660
            }
661
662 7
            $url = $request->getHeaderLine($header);
663
664 7
            if (!str_starts_with($url, '/')) {
665
                continue;
666
            }
667
668 7
            $urlParts = explode('?', $url, 2);
669 7
            if (!isset($urlParts[1])) {
670 1
                $urlParts[] = null;
671
            }
672
673 7
            return $urlParts;
674
        }
675
676 17
        return null;
677
    }
678
679 4
    private function checkPort(string $port): bool
680
    {
681
        /**
682
         * @infection-ignore-all
683
         * - PregMatchRemoveCaret.
684
         * - PregMatchRemoveDollar.
685
         */
686 4
        if (preg_match('/^\d{1,5}$/', $port) !== 1) {
687 3
            return false;
688
        }
689
690
        /** @infection-ignore-all CastInt */
691 4
        $intPort = (int) $port;
692
693 4
        return $intPort >= 1 && $intPort <= 65535;
694
    }
695
696
    /**
697
     * @psalm-assert array<non-empty-string> $array
698
     */
699 43
    private function requireListOfNonEmptyStrings(array $array, string $arrayName): void
700
    {
701 43
        foreach ($array as $item) {
702 43
            if (!is_string($item)) {
703 5
                throw new InvalidArgumentException("Each \"$arrayName\" item must be string.");
704
            }
705
706 43
            if (trim($item) === '') {
707 10
                throw new InvalidArgumentException("Each \"$arrayName\" item must be non-empty string.");
708
            }
709
        }
710
    }
711
}
712