Passed
Pull Request — master (#57)
by
unknown
02:27
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 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
 *     'urlHeaders': array<array-key, string>,
68
 *     'portHeaders': array<array-key, string>,
69
 *     'trustedHeaders': array<array-key, string>,
70
 *     'protocolHeaders': ProtocolHeadersData,
71
 *     'hostHeaders': 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 57
    public function __construct(private ValidatorInterface $validator)
109
    {
110 57
    }
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 54
    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 54
        if ($hosts === []) {
141 14
            throw new InvalidArgumentException('Empty hosts are not allowed.');
142
        }
143
144 40
        foreach ($ipHeaders as $ipHeader) {
145 27
            if (is_string($ipHeader)) {
146 9
                continue;
147
            }
148
149 19
            if (!is_array($ipHeader)) {
150 1
                throw new InvalidArgumentException('IP header must have either string or array type.');
151
            }
152
153 18
            if (count($ipHeader) !== 2) {
154 1
                throw new InvalidArgumentException('IP header array must have exactly 2 elements.');
155
            }
156
157 17
            [$type, $header] = $ipHeader;
158
159 17
            if (!is_string($type)) {
160 1
                throw new InvalidArgumentException('IP header type must be a string.');
161
            }
162
163 16
            if (!is_string($header)) {
164 1
                throw new InvalidArgumentException('IP header value must be a string.');
165
            }
166
167 15
            if ($type === self::IP_HEADER_TYPE_RFC7239) {
168 15
                continue;
169
            }
170
171 1
            throw new InvalidArgumentException("Not supported IP header type: \"$type\".");
172
        }
173
174 35
        $trustedHeaders ??= self::DEFAULT_TRUSTED_HEADERS;
175
        /** @psalm-var ProtocolHeadersData $protocolHeaders */
176 35
        $protocolHeaders = $this->prepareProtocolHeaders($protocolHeaders);
177
178 30
        $this->requireListOfNonEmptyStrings($hosts, self::DATA_KEY_HOSTS);
179 27
        $this->requireListOfNonEmptyStrings($trustedHeaders, self::DATA_KEY_TRUSTED_HEADERS);
180 27
        $this->requireListOfNonEmptyStrings($hostHeaders, self::DATA_KEY_HOST_HEADERS);
181 27
        $this->requireListOfNonEmptyStrings($urlHeaders, self::DATA_KEY_URL_HEADERS);
182 27
        $this->requireListOfNonEmptyStrings($portHeaders, self::DATA_KEY_PORT_HEADERS);
183
184 27
        foreach ($hosts as $host) {
185 27
            $host = str_replace('*', 'wildcard', $host); // wildcard is allowed in host
186
187 27
            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 26
        $new = clone $this;
193
        /** @psalm-var array<array-key, string> $ipHeaders */
194 26
        $new->trustedHosts[] = [
195 26
            self::DATA_KEY_HOSTS => $hosts,
196 26
            self::DATA_KEY_IP_HEADERS => $ipHeaders,
197 26
            self::DATA_KEY_PROTOCOL_HEADERS => $protocolHeaders,
198 26
            self::DATA_KEY_TRUSTED_HEADERS => $trustedHeaders,
199 26
            self::DATA_KEY_HOST_HEADERS => $hostHeaders,
200 26
            self::DATA_KEY_URL_HEADERS => $urlHeaders,
201 26
            self::DATA_KEY_PORT_HEADERS => $portHeaders,
202 26
        ];
203
204 26
        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 56
    public function withAttributeIps(?string $attribute): self
225
    {
226 56
        if ($attribute === '') {
227 1
            throw new RuntimeException('Attribute should not be empty string.');
228
        }
229
230 56
        $new = clone $this;
231 56
        $new->attributeIps = $attribute;
232 56
        return $new;
233
    }
234
235 27
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
236
    {
237
        /** @var string|null $actualHost */
238 27
        $actualHost = $request->getServerParams()['REMOTE_ADDR'] ?? null;
239
240 27
        if ($actualHost === null) {
241
            // Validation is not possible.
242 1
            return $this->handleNotTrusted($request, $handler);
243
        }
244
245 26
        $trustedHostData = null;
246 26
        $trustedHeaders = [];
247
248 26
        foreach ($this->trustedHosts as $data) {
249
            // collect all trusted headers
250 25
            $trustedHeaders[] = $data[self::DATA_KEY_TRUSTED_HEADERS];
251
252 25
            if ($trustedHostData === null && $this->isValidHost($actualHost, $data[self::DATA_KEY_HOSTS])) {
253 23
                $trustedHostData = $data;
254
            }
255
        }
256
257 26
        if ($trustedHostData === null) {
258
            // No trusted host at all.
259 3
            return $this->handleNotTrusted($request, $handler);
260
        }
261
262 23
        $trustedHeaders = array_merge(...$trustedHeaders);
263
        /** @psalm-var array<string, array<array-key,string>> $requestHeaders */
264 23
        $requestHeaders = $request->getHeaders();
265 23
        $untrustedHeaders = array_diff(array_keys($requestHeaders), $trustedHeaders);
266 23
        $request = $this->removeHeaders($request, $untrustedHeaders);
267
268 23
        [$ipListType, $ipHeader, $hostList] = $this->getIpList($request, $trustedHostData[self::DATA_KEY_IP_HEADERS]);
269 23
        $hostList = array_reverse($hostList); // the first item should be the closest to the server
270
271 23
        if ($ipListType === self::IP_HEADER_TYPE_RFC7239) {
272 12
            $hostList = $this->getElementsByRfc7239($hostList);
273
        } else {
274 11
            $hostList = $this->getFormattedIpList($hostList);
275
        }
276
277 23
        array_unshift($hostList, ['ip' => $actualHost]); // server's ip to first position
278 23
        $hostDataList = [];
279
280
        do {
281 23
            $hostData = array_shift($hostList);
282 23
            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 23
            $ip = $hostData['ip'];
295
296 23
            if (!$this->isValidHost($ip, ['any'])) {
297
                // invalid IP
298
                break;
299
            }
300
301 23
            $hostDataList[] = $hostData;
302
303 23
            if (!$this->isValidHost($ip, $trustedHostData[self::DATA_KEY_HOSTS])) {
304
                // not trusted host
305 16
                break;
306
            }
307 23
        } while (count($hostList) > 0);
308
309 23
        if ($this->attributeIps !== null) {
310 22
            $request = $request->withAttribute($this->attributeIps, $hostDataList);
311
        }
312
313 23
        $uri = $request->getUri();
314
        // find HTTP host
315 23
        foreach ($trustedHostData[self::DATA_KEY_HOST_HEADERS] as $hostHeader) {
316 8
            if (!$request->hasHeader($hostHeader)) {
317 1
                continue;
318
            }
319
320
            if (
321 8
                $hostHeader === $ipHeader
322 8
                && $ipListType === self::IP_HEADER_TYPE_RFC7239
323 8
                && isset($hostData['httpHost'])
324
            ) {
325 4
                $uri = $uri->withHost($hostData['httpHost']);
326 4
                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 23
        $protocolHeadersData = $trustedHostData[self::DATA_KEY_PROTOCOL_HEADERS];
340 23
        foreach ($protocolHeadersData as $protocolHeader => $protocols) {
341 9
            if (!$request->hasHeader($protocolHeader)) {
342 1
                continue;
343
            }
344
345
            if (
346 9
                $protocolHeader === $ipHeader
347 9
                && $ipListType === self::IP_HEADER_TYPE_RFC7239
348 9
                && isset($hostData['protocol'])
349
            ) {
350 6
                $uri = $uri->withScheme($hostData['protocol']);
351 6
                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 23
        $urlParts = $this->getUrl($request, $trustedHostData[self::DATA_KEY_URL_HEADERS]);
365
366 23
        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 23
        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 23
        return $handler->handle(
402 23
            $request->withUri($uri)->withAttribute(self::REQUEST_CLIENT_IP, $hostData['ip'] ?? null)
403 23
        );
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 25
    protected function isValidHost(string $host, array $ranges): bool
415
    {
416 25
        $result = $this->validator->validate(
417 25
            $host,
418 25
            [new Ip(allowSubnet: false, allowNegation: false, ranges: $ranges)]
419 25
        );
420 25
        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 35
    private function prepareProtocolHeaders(array $protocolHeaders): array
467
    {
468 35
        $output = [];
469
470 35
        foreach ($protocolHeaders as $header => $protocolAndAcceptedValues) {
471 14
            if (!is_string($header)) {
472 1
                throw new InvalidArgumentException('The protocol header must be a string.');
473
            }
474 13
            $header = strtolower($header);
475
476 13
            if (is_callable($protocolAndAcceptedValues)) {
477 3
                $output[$header] = $protocolAndAcceptedValues;
478 3
                continue;
479
            }
480
481 10
            if (!is_array($protocolAndAcceptedValues)) {
482 1
                throw new InvalidArgumentException('Accepted values is not an array nor callable.');
483
            }
484
485 9
            if ($protocolAndAcceptedValues === []) {
486 1
                throw new InvalidArgumentException('Accepted values cannot be an empty array.');
487
            }
488
489 8
            $output[$header] = [];
490
491
            /**
492
             * @var array<string|string[]> $protocolAndAcceptedValues
493
             */
494 8
            foreach ($protocolAndAcceptedValues as $protocol => $acceptedValues) {
495 8
                if (!is_string($protocol)) {
496 1
                    throw new InvalidArgumentException('The protocol must be a string.');
497
                }
498
499 7
                if ($protocol === '') {
500 1
                    throw new InvalidArgumentException('The protocol cannot be empty.');
501
                }
502
503 6
                $output[$header][$protocol] = array_map('\strtolower', (array) $acceptedValues);
504
            }
505
        }
506
507 30
        return $output;
508
    }
509
510
    /**
511
     * @param string[] $headers
512
     */
513 23
    private function removeHeaders(ServerRequestInterface $request, array $headers): ServerRequestInterface
514
    {
515 23
        foreach ($headers as $header) {
516 5
            $request = $request->withoutHeader($header);
517
        }
518
519 23
        return $request;
520
    }
521
522
    /**
523
     * @param array<string|string[]> $ipHeaders
524
     *
525
     * @return array{0: string|null, 1: string|null, 2: string[]}
526
     */
527 23
    private function getIpList(ServerRequestInterface $request, array $ipHeaders): array
528
    {
529 23
        foreach ($ipHeaders as $ipHeader) {
530 20
            $type = null;
531
532 20
            if (is_array($ipHeader)) {
533 14
                $type = array_shift($ipHeader);
534 14
                $ipHeader = array_shift($ipHeader);
535
            }
536
537 20
            if ($request->hasHeader($ipHeader)) {
538 18
                return [$type, $ipHeader, $request->getHeader($ipHeader)];
539
            }
540
        }
541
542 5
        return [null, null, []];
543
    }
544
545
    /**
546
     * @param string[] $forwards
547
     *
548
     * @psalm-return list<HostData>
549
     *
550
     * @see getElementsByRfc7239()
551
     */
552 11
    private function getFormattedIpList(array $forwards): array
553
    {
554 11
        $list = [];
555
556 11
        foreach ($forwards as $ip) {
557 6
            $list[] = ['ip' => $ip];
558
        }
559
560 11
        return $list;
561
    }
562
563
    /**
564
     * Forwarded elements by RFC7239.
565
     *
566
     * The structure of the elements:
567
     * - `host`: IP or obfuscated hostname or "unknown"
568
     * - `ip`: IP address (only if presented)
569
     * - `by`: used user-agent by proxy (only if presented)
570
     * - `port`: port number received by proxy (only if presented)
571
     * - `protocol`: protocol received by proxy (only if presented)
572
     * - `httpHost`: HTTP host received by proxy (only if presented)
573
     *
574
     * The list starts with the server and the last item is the client itself.
575
     *
576
     * @link https://tools.ietf.org/html/rfc7239
577
     *
578
     * @param string[] $forwards
579
     *
580
     * @psalm-return list<HostData> Proxy data elements.
581
     */
582 12
    private function getElementsByRfc7239(array $forwards): array
583
    {
584 12
        $list = [];
585
586 12
        foreach ($forwards as $forward) {
587
            try {
588
                /** @psalm-var array<string, string> $data */
589 12
                $data = HeaderValueHelper::getParameters($forward);
590
            } catch (InvalidArgumentException) {
591
                break;
592
            }
593
594 12
            if (!isset($data['for'])) {
595
                // Invalid item, the following items will be dropped
596 1
                break;
597
            }
598
599 12
            $pattern = '/^(?<host>' . IpHelper::IPV4_PATTERN . '|unknown|_[\w\.-]+|[[]'
600 12
                . IpHelper::IPV6_PATTERN . '[]])(?::(?<port>[\w\.-]+))?$/';
601
602 12
            if (preg_match($pattern, $data['for'], $matches) === 0) {
603
                // Invalid item, the following items will be dropped
604 1
                break;
605
            }
606
607 12
            $ipData = [];
608 12
            $host = $matches['host'];
609 12
            $obfuscatedHost = $host === 'unknown' || str_starts_with($host, '_');
610
611 12
            if (!$obfuscatedHost) {
612
                // IPv4 & IPv6
613 12
                $ipData['ip'] = str_starts_with($host, '[') ? trim($host /* IPv6 */, '[]') : $host;
614
            }
615
616 12
            $ipData['host'] = $host;
617
618 12
            if (isset($matches['port'])) {
619 3
                $port = $matches['port'];
620
621 3
                if (!$obfuscatedHost && !$this->checkPort($port)) {
622
                    // Invalid port, the following items will be dropped
623 3
                    break;
624
                }
625
626 3
                $ipData['port'] = $obfuscatedHost ? $port : (int) $port;
627
            }
628
629
            // copy other properties
630 12
            foreach (['proto' => 'protocol', 'host' => 'httpHost', 'by' => 'by'] as $source => $destination) {
631 12
                if (isset($data[$source])) {
632 6
                    $ipData[$destination] = $data[$source];
633
                }
634
            }
635
636 12
            if (isset($ipData['httpHost']) && filter_var($ipData['httpHost'], FILTER_VALIDATE_DOMAIN) === false) {
637
                // remove not valid HTTP host
638
                unset($ipData['httpHost']);
639
            }
640
641 12
            $list[] = $ipData;
642
        }
643
644 12
        return $list;
645
    }
646
647
    /**
648
     * @param string[] $urlHeaders
649
     *
650
     * @psalm-return non-empty-list<null|string>|null
651
     */
652 23
    private function getUrl(RequestInterface $request, array $urlHeaders): ?array
653
    {
654 23
        foreach ($urlHeaders as $header) {
655 7
            if (!$request->hasHeader($header)) {
656 3
                continue;
657
            }
658
659 7
            $url = $request->getHeaderLine($header);
660
661 7
            if (!str_starts_with($url, '/')) {
662
                continue;
663
            }
664
665 7
            $urlParts = explode('?', $url, 2);
666 7
            if (!isset($urlParts[1])) {
667 1
                $urlParts[] = null;
668
            }
669
670 7
            return $urlParts;
671
        }
672
673 16
        return null;
674
    }
675
676 4
    private function checkPort(string $port): bool
677
    {
678
        /**
679
         * @infection-ignore-all
680
         * - PregMatchRemoveCaret.
681
         * - PregMatchRemoveDollar.
682
         */
683 4
        if (preg_match('/^\d{1,5}$/', $port) !== 1) {
684 3
            return false;
685
        }
686
687
        /** @infection-ignore-all CastInt */
688 4
        $intPort = (int) $port;
689
690 4
        return $intPort >= 1 && $intPort <= 65535;
691
    }
692
693
    /**
694
     * @psalm-assert array<non-empty-string> $array
695
     */
696 30
    private function requireListOfNonEmptyStrings(array $array, string $arrayName): void
697
    {
698 30
        foreach ($array as $item) {
699 30
            if (!is_string($item)) {
700 1
                throw new InvalidArgumentException("Each host \"$arrayName\" item must be string.");
701
            }
702
703 29
            if (trim($item) === '') {
704 2
                throw new InvalidArgumentException("Each host \"$arrayName\" item must be non-empty string.");
705
            }
706
        }
707
    }
708
}
709