Passed
Pull Request — master (#57)
by
unknown
02:38
created

getElementsByRfc7239()   C

Complexity

Conditions 15
Paths 9

Size

Total Lines 59
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 27
CRAP Score 15.0102

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 15
eloc 27
c 1
b 0
f 0
nc 9
nop 1
dl 0
loc 59
ccs 27
cts 28
cp 0.9643
crap 15.0102
rs 5.9166

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

452
        /** @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...
453
        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

453
        /** @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...
454
        RequestInterface $request
455
    ): ?array {
456
        return $hostData;
457
    }
458
459 4
    private function handleNotTrusted(
460
        ServerRequestInterface $request,
461
        RequestHandlerInterface $handler
462
    ): ResponseInterface {
463 4
        if ($this->attributeIps !== null) {
464 4
            $request = $request->withAttribute($this->attributeIps, null);
465
        }
466
467 4
        return $handler->handle($request->withAttribute(self::REQUEST_CLIENT_IP, null));
468
    }
469
470
    /**
471
     * @psalm-return ProtocolHeadersData
472
     */
473 32
    private function prepareProtocolHeaders(array $protocolHeaders): array
474
    {
475 32
        $output = [];
476
477 32
        foreach ($protocolHeaders as $header => $protocolAndAcceptedValues) {
478 14
            if (!is_string($header)) {
479 1
                throw new InvalidArgumentException('The protocol header must be a string.');
480
            }
481 13
            $header = strtolower($header);
482
483 13
            if (is_callable($protocolAndAcceptedValues)) {
484 3
                $output[$header] = $protocolAndAcceptedValues;
485 3
                continue;
486
            }
487
488 10
            if (!is_array($protocolAndAcceptedValues)) {
489 1
                throw new InvalidArgumentException('Accepted values is not an array nor callable.');
490
            }
491
492 9
            if ($protocolAndAcceptedValues === []) {
493 1
                throw new InvalidArgumentException('Accepted values cannot be an empty array.');
494
            }
495
496 8
            $output[$header] = [];
497
498
            /**
499
             * @var array<string|string[]> $protocolAndAcceptedValues
500
             */
501 8
            foreach ($protocolAndAcceptedValues as $protocol => $acceptedValues) {
502 8
                if (!is_string($protocol)) {
503 1
                    throw new InvalidArgumentException('The protocol must be a string.');
504
                }
505
506 7
                if ($protocol === '') {
507 1
                    throw new InvalidArgumentException('The protocol cannot be empty.');
508
                }
509
510 6
                $output[$header][$protocol] = array_map('\strtolower', (array) $acceptedValues);
511
            }
512
        }
513
514 27
        return $output;
515
    }
516
517
    /**
518
     * @param string[] $headers
519
     */
520 20
    private function removeHeaders(ServerRequestInterface $request, array $headers): ServerRequestInterface
521
    {
522 20
        foreach ($headers as $header) {
523 14
            $request = $request->withoutAttribute($header);
524
        }
525
526 20
        return $request;
527
    }
528
529
    /**
530
     * @param array<string|string[]> $ipHeaders
531
     *
532
     * @return array{0: string|null, 1: string|null, 2: string[]}
533
     */
534 20
    private function getIpList(ServerRequestInterface $request, array $ipHeaders): array
535
    {
536 20
        foreach ($ipHeaders as $ipHeader) {
537 17
            $type = null;
538
539 17
            if (is_array($ipHeader)) {
540 13
                $type = array_shift($ipHeader);
541 13
                $ipHeader = array_shift($ipHeader);
542
            }
543
544 17
            if ($request->hasHeader($ipHeader)) {
545 17
                return [$type, $ipHeader, $request->getHeader($ipHeader)];
546
            }
547
        }
548
549 3
        return [null, null, []];
550
    }
551
552
    /**
553
     * @param string[] $forwards
554
     *
555
     * @psalm-return list<HostData>
556
     *
557
     * @see getElementsByRfc7239()
558
     */
559 7
    private function getFormattedIpList(array $forwards): array
560
    {
561 7
        $list = [];
562
563 7
        foreach ($forwards as $ip) {
564 4
            $list[] = ['ip' => $ip];
565
        }
566
567 7
        return $list;
568
    }
569
570
    /**
571
     * Forwarded elements by RFC7239.
572
     *
573
     * The structure of the elements:
574
     * - `host`: IP or obfuscated hostname or "unknown"
575
     * - `ip`: IP address (only if presented)
576
     * - `by`: used user-agent by proxy (only if presented)
577
     * - `port`: port number received by proxy (only if presented)
578
     * - `protocol`: protocol received by proxy (only if presented)
579
     * - `httpHost`: HTTP host received by proxy (only if presented)
580
     *
581
     * The list starts with the server and the last item is the client itself.
582
     *
583
     * @link https://tools.ietf.org/html/rfc7239
584
     *
585
     * @param string[] $forwards
586
     *
587
     * @psalm-return list<HostData> Proxy data elements.
588
     */
589 13
    private function getElementsByRfc7239(array $forwards): array
590
    {
591 13
        $list = [];
592
593 13
        foreach ($forwards as $forward) {
594
            /** @var array<string, string> $data */
595 13
            $data = HeaderValueHelper::getParameters($forward);
596
597 13
            if (!isset($data['for'])) {
598
                // Invalid item, the following items will be dropped
599 2
                break;
600
            }
601
602 12
            $pattern = '/^(?<host>' . IpHelper::IPV4_PATTERN . '|unknown|_[\w\.-]+|[[]'
603 12
                . IpHelper::IPV6_PATTERN . '[]])(?::(?<port>[\w\.-]+))?$/';
604
605 12
            if (preg_match($pattern, $data['for'], $matches) === 0) {
606
                // Invalid item, the following items will be dropped
607 1
                break;
608
            }
609
610 12
            $ipData = [];
611 12
            $host = $matches['host'];
612 12
            $obfuscatedHost = $host === 'unknown' || str_starts_with($host, '_');
613
614 12
            if (!$obfuscatedHost) {
615
                // IPv4 & IPv6
616 12
                $ipData['ip'] = str_starts_with($host, '[') ? trim($host /* IPv6 */, '[]') : $host;
617
            }
618
619 12
            $ipData['host'] = $host;
620
621 12
            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 12
            foreach (['proto' => 'protocol', 'host' => 'httpHost', 'by' => 'by'] as $source => $destination) {
634 12
                if (isset($data[$source])) {
635 6
                    $ipData[$destination] = $data[$source];
636
                }
637
            }
638
639 12
            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 12
            $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 20
    private function getUrl(RequestInterface $request, array $urlHeaders): ?array
656
    {
657 20
        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 13
        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 27
    private function checkTypeStringOrArray(array $array, string $field): void
700
    {
701 27
        foreach ($array as $item) {
702 27
            if (!is_string($item)) {
703 1
                throw new InvalidArgumentException("$field must be string type");
704
            }
705
706 26
            if (trim($item) === '') {
707 2
                throw new InvalidArgumentException("$field cannot be empty strings");
708
            }
709
        }
710
    }
711
}
712