Passed
Pull Request — master (#57)
by Alexander
05:24 queued 02:42
created

getElementsByRfc7239()   C

Complexity

Conditions 16
Paths 10

Size

Total Lines 63
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 29
CRAP Score 16.0094

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 29
cts 30
cp 0.9667
crap 16.0094
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 55
    public function __construct(private ValidatorInterface $validator)
109
    {
110 55
    }
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 52
    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 52
        if ($hosts === []) {
141 14
            throw new InvalidArgumentException('Empty hosts are not allowed.');
142
        }
143
144 38
        foreach ($ipHeaders as $ipHeader) {
145 25
            if (is_string($ipHeader)) {
146 7
                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 33
        $trustedHeaders ??= self::DEFAULT_TRUSTED_HEADERS;
175
        /** @psalm-var ProtocolHeadersData $protocolHeaders */
176 33
        $protocolHeaders = $this->prepareProtocolHeaders($protocolHeaders);
177
178 28
        $this->checkTypeStringOrArray($hosts, self::DATA_KEY_HOSTS);
179 25
        $this->checkTypeStringOrArray($trustedHeaders, self::DATA_KEY_TRUSTED_HEADERS);
180 25
        $this->checkTypeStringOrArray($hostHeaders, self::DATA_KEY_HOST_HEADERS);
181 25
        $this->checkTypeStringOrArray($urlHeaders, self::DATA_KEY_URL_HEADERS);
182 25
        $this->checkTypeStringOrArray($portHeaders, self::DATA_KEY_PORT_HEADERS);
183
184 25
        foreach ($hosts as $host) {
185 25
            $host = str_replace('*', 'wildcard', $host); // wildcard is allowed in host
186
187 25
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) === false) {
188 1
                throw new InvalidArgumentException("\"$host\" host is not a domain and not an IP address.");
189
            }
190
        }
191
192 24
        $new = clone $this;
193
        /** @psalm-var array<array-key, string> $ipHeaders */
194 24
        $new->trustedHosts[] = [
195 24
            self::DATA_KEY_HOSTS => $hosts,
196 24
            self::DATA_KEY_IP_HEADERS => $ipHeaders,
197 24
            self::DATA_KEY_PROTOCOL_HEADERS => $protocolHeaders,
198 24
            self::DATA_KEY_TRUSTED_HEADERS => $trustedHeaders,
199 24
            self::DATA_KEY_HOST_HEADERS => $hostHeaders,
200 24
            self::DATA_KEY_URL_HEADERS => $urlHeaders,
201 24
            self::DATA_KEY_PORT_HEADERS => $portHeaders,
202 24
        ];
203
204 24
        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 54
    public function withAttributeIps(?string $attribute): self
225
    {
226 54
        if ($attribute === '') {
227 1
            throw new RuntimeException('Attribute should not be empty string.');
228
        }
229
230 54
        $new = clone $this;
231 54
        $new->attributeIps = $attribute;
232 54
        return $new;
233
    }
234
235 25
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
236
    {
237
        /** @var string|null $actualHost */
238 25
        $actualHost = $request->getServerParams()['REMOTE_ADDR'] ?? null;
239
240 25
        if ($actualHost === null) {
241
            // Validation is not possible.
242 1
            return $this->handleNotTrusted($request, $handler);
243
        }
244
245 24
        $trustedHostData = null;
246 24
        $trustedHeaders = [];
247
248 24
        foreach ($this->trustedHosts as $data) {
249
            // collect all trusted headers
250 23
            $trustedHeaders[] = $data[self::DATA_KEY_TRUSTED_HEADERS];
251
252 23
            if ($trustedHostData !== null) {
253
                // trusted hosts already found
254 1
                continue;
255
            }
256
257 23
            if ($this->isValidHost($actualHost, $data[self::DATA_KEY_HOSTS])) {
258 21
                $trustedHostData = $data;
259
            }
260
        }
261
262 24
        if ($trustedHostData === null) {
263
            // No trusted host at all.
264 3
            return $this->handleNotTrusted($request, $handler);
265
        }
266
267 21
        $trustedHeaders = array_merge(...$trustedHeaders);
268
        /** @psalm-var array<string, array<array-key,string>> $requestHeaders */
269 21
        $requestHeaders = $request->getHeaders();
270 21
        $untrustedHeaders = array_diff(array_keys($requestHeaders), $trustedHeaders);
271 21
        $request = $this->removeHeaders($request, $untrustedHeaders);
272
273 21
        [$ipListType, $ipHeader, $hostList] = $this->getIpList($request, $trustedHostData[self::DATA_KEY_IP_HEADERS]);
274 21
        $hostList = array_reverse($hostList); // the first item should be the closest to the server
275
276 21
        if ($ipListType === self::IP_HEADER_TYPE_RFC7239) {
277 14
            $hostList = $this->getElementsByRfc7239($hostList);
278
        } else {
279 7
            $hostList = $this->getFormattedIpList($hostList);
280
        }
281
282 21
        array_unshift($hostList, ['ip' => $actualHost]); // server's ip to first position
283 21
        $hostDataList = [];
284
285
        do {
286 21
            $hostData = array_shift($hostList);
287 21
            if (!isset($hostData['ip'])) {
288
                $hostData = $this->reverseObfuscate($hostData, $hostDataList, $hostList, $request);
289
290
                if ($hostData === null) {
291
                    continue;
292
                }
293
294
                if (!isset($hostData['ip'])) {
295
                    break;
296
                }
297
            }
298
299 21
            $ip = $hostData['ip'];
300
301 21
            if (!$this->isValidHost($ip, ['any'])) {
302
                // invalid IP
303
                break;
304
            }
305
306 21
            $hostDataList[] = $hostData;
307
308 21
            if (!$this->isValidHost($ip, $trustedHostData[self::DATA_KEY_HOSTS])) {
309
                // not trusted host
310 14
                break;
311
            }
312 21
        } while (count($hostList) > 0);
313
314 21
        if ($this->attributeIps !== null) {
315 20
            $request = $request->withAttribute($this->attributeIps, $hostDataList);
316
        }
317
318 21
        $uri = $request->getUri();
319
        // find HTTP host
320 21
        foreach ($trustedHostData[self::DATA_KEY_HOST_HEADERS] as $hostHeader) {
321 8
            if (!$request->hasHeader($hostHeader)) {
322 1
                continue;
323
            }
324
325
            if (
326 8
                $hostHeader === $ipHeader
327 8
                && $ipListType === self::IP_HEADER_TYPE_RFC7239
328 8
                && isset($hostData['httpHost'])
329
            ) {
330 4
                $uri = $uri->withHost($hostData['httpHost']);
331 4
                break;
332
            }
333
334 4
            $host = $request->getHeaderLine($hostHeader);
335
336 4
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) !== false) {
337 4
                $uri = $uri->withHost($host);
338 4
                break;
339
            }
340
        }
341
342
        // find protocol
343
        /** @psalm-var ProtocolHeadersData $protocolHeadersData */
344 21
        $protocolHeadersData = $trustedHostData[self::DATA_KEY_PROTOCOL_HEADERS];
345 21
        foreach ($protocolHeadersData as $protocolHeader => $protocols) {
346 9
            if (!$request->hasHeader($protocolHeader)) {
347 1
                continue;
348
            }
349
350
            if (
351 9
                $protocolHeader === $ipHeader
352 9
                && $ipListType === self::IP_HEADER_TYPE_RFC7239
353 9
                && isset($hostData['protocol'])
354
            ) {
355 6
                $uri = $uri->withScheme($hostData['protocol']);
356 6
                break;
357
            }
358
359 5
            $protocolHeaderValue = $request->getHeaderLine($protocolHeader);
360
361 5
            foreach ($protocols as $protocol => $acceptedValues) {
362 5
                if (in_array($protocolHeaderValue, $acceptedValues, true)) {
363 1
                    $uri = $uri->withScheme($protocol);
364 1
                    break 2;
365
                }
366
            }
367
        }
368
369 21
        $urlParts = $this->getUrl($request, $trustedHostData[self::DATA_KEY_URL_HEADERS]);
370
371 21
        if ($urlParts !== null) {
372 7
            [$path, $query] = $urlParts;
373 7
            if ($path !== null) {
0 ignored issues
show
introduced by
The condition $path !== null is always true.
Loading history...
374 7
                $uri = $uri->withPath($path);
375
            }
376
377 7
            if ($query !== null) {
0 ignored issues
show
introduced by
The condition $query !== null is always true.
Loading history...
378 6
                $uri = $uri->withQuery($query);
379
            }
380
        }
381
382
        // find port
383 21
        foreach ($trustedHostData[self::DATA_KEY_PORT_HEADERS] as $portHeader) {
384 4
            if (!$request->hasHeader($portHeader)) {
385 3
                continue;
386
            }
387
388
            if (
389 4
                $portHeader === $ipHeader
390 4
                && $ipListType === self::IP_HEADER_TYPE_RFC7239
391 4
                && isset($hostData['port'])
392 4
                && $this->checkPort((string) $hostData['port'])
393
            ) {
394 3
                $uri = $uri->withPort((int) $hostData['port']);
395 3
                break;
396
            }
397
398 1
            $port = $request->getHeaderLine($portHeader);
399
400 1
            if ($this->checkPort($port)) {
401 1
                $uri = $uri->withPort((int) $port);
402 1
                break;
403
            }
404
        }
405
406 21
        return $handler->handle(
407 21
            $request->withUri($uri)->withAttribute(self::REQUEST_CLIENT_IP, $hostData['ip'] ?? null)
408 21
        );
409
    }
410
411
    /**
412
     * Validate host by range.
413
     *
414
     * This method can be extendable by overwriting e.g. with reverse DNS verification.
415
     *
416
     * @param string[] $ranges
417
     * @param Closure(string, string[]): Result $validator
418
     */
419 23
    protected function isValidHost(string $host, array $ranges): bool
420
    {
421 23
        $result = $this->validator->validate(
422 23
            $host,
423 23
            [new Ip(allowSubnet: false, allowNegation: false, ranges: $ranges)]
424 23
        );
425 23
        return $result->isValid();
426
    }
427
428
    /**
429
     * Reverse obfuscating host data
430
     *
431
     * RFC 7239 allows to use obfuscated host data. In this case, either specifying the
432
     * IP address or dropping the proxy endpoint is required to determine validated route.
433
     *
434
     * By default, it does not perform any transformation on the data. You can override this method.
435
     *
436
     * @return array|null reverse obfuscated host data or null.
437
     * In case of null data is discarded and the process continues with the next portion of host data.
438
     * If the return value is an array, it must contain at least the `ip` key.
439
     *
440
     * @psalm-param HostData|null $hostData
441
     *
442
     * @psalm-return HostData|null
443
     *
444
     * @see getElementsByRfc7239()
445
     * @link https://tools.ietf.org/html/rfc7239#section-6.2
446
     * @link https://tools.ietf.org/html/rfc7239#section-6.3
447
     */
448
    protected function reverseObfuscate(
449
        ?array $hostData,
450
        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

450
        /** @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...
451
        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

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