Passed
Pull Request — master (#57)
by Alexander
04:56 queued 02:26
created

withAddedTrustedHosts()   B

Complexity

Conditions 11
Paths 17

Size

Total Lines 76
Code Lines 38

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 40
CRAP Score 11

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 11
eloc 38
c 1
b 0
f 0
nc 17
nop 7
dl 0
loc 76
ccs 40
cts 40
cp 1
crap 11
rs 7.3166

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

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

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