Passed
Push — master ( 0cc967...cdd4ec )
by Rustam
02:51
created

TrustedHostsNetworkResolver   F

Complexity

Total Complexity 98

Size/Duplication

Total Lines 614
Duplicated Lines 0 %

Test Coverage

Coverage 96.22%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 242
c 2
b 0
f 0
dl 0
loc 614
ccs 229
cts 238
cp 0.9622
rs 2
wmc 98

16 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 2 1
A withAttributeIps() 0 9 2
A withoutTrustedHosts() 0 5 1
A checkTypeStringOrArray() 0 9 4
A getFormattedIpList() 0 9 2
A getUrl() 0 15 4
A checkPort() 0 3 2
A isValidHost() 0 7 1
A reverseObfuscate() 0 7 1
A getIpList() 0 16 4
B prepareProtocolHeaders() 0 42 9
F process() 0 173 37
B withAddedTrustedHosts() 0 76 11
A removeHeaders() 0 7 2
C getElementsByRfc7239() 0 59 15
A handleNotTrusted() 0 9 2

How to fix   Complexity   

Complex Class

Complex classes like TrustedHostsNetworkResolver often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use TrustedHostsNetworkResolver, and based on these observations, apply Extract Interface, too.

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 44
    public function __construct(private ValidatorInterface $validator)
110
    {
111 44
    }
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 41
    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 41
        $new = clone $this;
142
143 41
        foreach ($ipHeaders as $ipHeader) {
144 21
            if (is_string($ipHeader)) {
145 7
                continue;
146
            }
147
148 14
            if (!is_array($ipHeader)) {
149 1
                throw new InvalidArgumentException('Type of IP header is not a string and not array.');
150
            }
151
152 13
            if (count($ipHeader) !== 2) {
153 1
                throw new InvalidArgumentException('The IP header array must have exactly 2 elements.');
154
            }
155
156 12
            [$type, $header] = $ipHeader;
157
158 12
            if (!is_string($type)) {
159 1
                throw new InvalidArgumentException('The IP header type is not a string.');
160
            }
161
162 11
            if (!is_string($header)) {
163 1
                throw new InvalidArgumentException('The IP header value is not a string.');
164
            }
165
166 10
            if ($type === self::IP_HEADER_TYPE_RFC7239) {
167 9
                continue;
168
            }
169
170 1
            throw new InvalidArgumentException("Not supported IP header type: $type.");
171
        }
172
173 36
        if ($hosts === []) {
174 8
            throw new InvalidArgumentException('Empty hosts not allowed.');
175
        }
176
177 28
        $trustedHeaders ??= self::DEFAULT_TRUSTED_HEADERS;
178
        /** @psalm-var ProtocolHeadersData $protocolHeaders */
179 28
        $protocolHeaders = $this->prepareProtocolHeaders($protocolHeaders);
180
181 23
        $this->checkTypeStringOrArray($hosts, self::DATA_KEY_HOSTS);
182 20
        $this->checkTypeStringOrArray($trustedHeaders, self::DATA_KEY_TRUSTED_HEADERS);
183 20
        $this->checkTypeStringOrArray($hostHeaders, self::DATA_KEY_HOST_HEADERS);
184 20
        $this->checkTypeStringOrArray($urlHeaders, self::DATA_KEY_URL_HEADERS);
185 20
        $this->checkTypeStringOrArray($portHeaders, self::DATA_KEY_PORT_HEADERS);
186
187 20
        foreach ($hosts as $host) {
188 20
            $host = str_replace('*', 'wildcard', $host); // wildcard is allowed in host
189
190 20
            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 19
        $new->trustedHosts[] = [
197 19
            self::DATA_KEY_HOSTS => $hosts,
198 19
            self::DATA_KEY_IP_HEADERS => $ipHeaders,
199 19
            self::DATA_KEY_PROTOCOL_HEADERS => $protocolHeaders,
200 19
            self::DATA_KEY_TRUSTED_HEADERS => $trustedHeaders,
201 19
            self::DATA_KEY_HOST_HEADERS => $hostHeaders,
202 19
            self::DATA_KEY_URL_HEADERS => $urlHeaders,
203 19
            self::DATA_KEY_PORT_HEADERS => $portHeaders,
204 19
        ];
205
206 19
        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 43
    public function withAttributeIps(?string $attribute): self
227
    {
228 43
        if ($attribute === '') {
229 1
            throw new RuntimeException('Attribute should not be empty string.');
230
        }
231
232 43
        $new = clone $this;
233 43
        $new->attributeIps = $attribute;
234 43
        return $new;
235
    }
236
237 20
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
238
    {
239
        /** @var string|null $actualHost */
240 20
        $actualHost = $request->getServerParams()['REMOTE_ADDR'] ?? null;
241
242 20
        if ($actualHost === null) {
243
            // Validation is not possible.
244 1
            return $this->handleNotTrusted($request, $handler);
245
        }
246
247 19
        $trustedHostData = null;
248 19
        $trustedHeaders = [];
249
250 19
        foreach ($this->trustedHosts as $data) {
251
            // collect all trusted headers
252 18
            $trustedHeaders[] = $data[self::DATA_KEY_TRUSTED_HEADERS];
253
254 18
            if ($trustedHostData !== null) {
255
                // trusted hosts already found
256 1
                continue;
257
            }
258
259 18
            if ($this->isValidHost($actualHost, $data[self::DATA_KEY_HOSTS])) {
260 16
                $trustedHostData = $data;
261
            }
262
        }
263
264 19
        if ($trustedHostData === null) {
265
            // No trusted host at all.
266 3
            return $this->handleNotTrusted($request, $handler);
267
        }
268
269 16
        $trustedHeaders = array_merge(...$trustedHeaders);
270
        /** @psalm-var array<string, array<array-key,string>> $requestHeaders */
271 16
        $requestHeaders = $request->getHeaders();
272 16
        $untrustedHeaders = array_diff(array_keys($requestHeaders), $trustedHeaders);
273 16
        $request = $this->removeHeaders($request, $untrustedHeaders);
274
275 16
        [$ipListType, $ipHeader, $hostList] = $this->getIpList($request, $trustedHostData[self::DATA_KEY_IP_HEADERS]);
276 16
        $hostList = array_reverse($hostList); // the first item should be the closest to the server
277
278 16
        if ($ipListType === self::IP_HEADER_TYPE_RFC7239) {
279 9
            $hostList = $this->getElementsByRfc7239($hostList);
280
        } else {
281 7
            $hostList = $this->getFormattedIpList($hostList);
282
        }
283
284 16
        array_unshift($hostList, ['ip' => $actualHost]); // server's ip to first position
285 16
        $hostDataList = [];
286
287
        do {
288 16
            $hostData = array_shift($hostList);
289 16
            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 16
            $ip = $hostData['ip'];
302
303 16
            if (!$this->isValidHost($ip, ['any'])) {
304
                // invalid IP
305
                break;
306
            }
307
308 16
            $hostDataList[] = $hostData;
309
310 16
            if (!$this->isValidHost($ip, $trustedHostData[self::DATA_KEY_HOSTS])) {
311
                // not trusted host
312 12
                break;
313
            }
314 16
        } while (count($hostList) > 0);
315
316 16
        if ($this->attributeIps !== null) {
317 15
            $request = $request->withAttribute($this->attributeIps, $hostDataList);
318
        }
319
320 16
        $uri = $request->getUri();
321
        // find HTTP host
322 16
        foreach ($trustedHostData[self::DATA_KEY_HOST_HEADERS] as $hostHeader) {
323 4
            if (!$request->hasHeader($hostHeader)) {
324 1
                continue;
325
            }
326
327
            if (
328 4
                $hostHeader === $ipHeader
329 4
                && $ipListType === self::IP_HEADER_TYPE_RFC7239
330 4
                && isset($hostData['httpHost'])
331
            ) {
332 2
                $uri = $uri->withHost($hostData['httpHost']);
333 2
                break;
334
            }
335
336 2
            $host = $request->getHeaderLine($hostHeader);
337
338 2
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) !== false) {
339 2
                $uri = $uri->withHost($host);
340 2
                break;
341
            }
342
        }
343
344
        // find protocol
345
        /** @psalm-var ProtocolHeadersData $protocolHeadersData */
346 16
        $protocolHeadersData = $trustedHostData[self::DATA_KEY_PROTOCOL_HEADERS];
347 16
        foreach ($protocolHeadersData as $protocolHeader => $protocols) {
348 5
            if (!$request->hasHeader($protocolHeader)) {
349 1
                continue;
350
            }
351
352
            if (
353 5
                $protocolHeader === $ipHeader
354 5
                && $ipListType === self::IP_HEADER_TYPE_RFC7239
355 5
                && isset($hostData['protocol'])
356
            ) {
357 4
                $uri = $uri->withScheme($hostData['protocol']);
358 4
                break;
359
            }
360
361 3
            $protocolHeaderValue = $request->getHeaderLine($protocolHeader);
362
363 3
            foreach ($protocols as $protocol => $acceptedValues) {
364 3
                if (in_array($protocolHeaderValue, $acceptedValues, true)) {
365 1
                    $uri = $uri->withScheme($protocol);
366 1
                    break 2;
367
                }
368
            }
369
        }
370
371 16
        $urlParts = $this->getUrl($request, $trustedHostData[self::DATA_KEY_URL_HEADERS]);
372
373 16
        if ($urlParts !== null) {
374 3
            [$path, $query] = $urlParts;
375 3
            if ($path !== null) {
376 3
                $uri = $uri->withPath($path);
377
            }
378
379 3
            if ($query !== null) {
380 3
                $uri = $uri->withQuery($query);
381
            }
382
        }
383
384
        // find port
385 16
        foreach ($trustedHostData[self::DATA_KEY_PORT_HEADERS] as $portHeader) {
386 2
            if (!$request->hasHeader($portHeader)) {
387 1
                continue;
388
            }
389
390
            if (
391 2
                $portHeader === $ipHeader
392 2
                && $ipListType === self::IP_HEADER_TYPE_RFC7239
393 2
                && isset($hostData['port'])
394 2
                && $this->checkPort((string) $hostData['port'])
395
            ) {
396 1
                $uri = $uri->withPort((int) $hostData['port']);
397 1
                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 16
        return $handler->handle(
409 16
            $request->withUri($uri)->withAttribute(self::REQUEST_CLIENT_IP, $hostData['ip'] ?? null)
410 16
        );
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 18
    protected function isValidHost(string $host, array $ranges): bool
422
    {
423 18
        $result = $this->validator->validate(
424 18
            $host,
425 18
            [new Ip(allowSubnet: false, allowNegation: false, ranges: $ranges)]
426 18
        );
427 18
        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 28
    private function prepareProtocolHeaders(array $protocolHeaders): array
474
    {
475 28
        $output = [];
476
477 28
        foreach ($protocolHeaders as $header => $protocolAndAcceptedValues) {
478 10
            if (!is_string($header)) {
479 1
                throw new InvalidArgumentException('The protocol header must be a string.');
480
            }
481 9
            $header = strtolower($header);
482
483 9
            if (is_callable($protocolAndAcceptedValues)) {
484 1
                $output[$header] = $protocolAndAcceptedValues;
485 1
                continue;
486
            }
487
488 8
            if (!is_array($protocolAndAcceptedValues)) {
489 1
                throw new InvalidArgumentException('Accepted values is not an array nor callable.');
490
            }
491
492 7
            if ($protocolAndAcceptedValues === []) {
493 1
                throw new InvalidArgumentException('Accepted values cannot be an empty array.');
494
            }
495
496 6
            $output[$header] = [];
497
498
            /**
499
             * @var array<string|string[]> $protocolAndAcceptedValues
500
             */
501 6
            foreach ($protocolAndAcceptedValues as $protocol => $acceptedValues) {
502 6
                if (!is_string($protocol)) {
503 1
                    throw new InvalidArgumentException('The protocol must be a string.');
504
                }
505
506 5
                if ($protocol === '') {
507 1
                    throw new InvalidArgumentException('The protocol cannot be empty.');
508
                }
509
510 4
                $output[$header][$protocol] = array_map('\strtolower', (array) $acceptedValues);
511
            }
512
        }
513
514 23
        return $output;
515
    }
516
517
    /**
518
     * @param string[] $headers
519
     */
520 16
    private function removeHeaders(ServerRequestInterface $request, array $headers): ServerRequestInterface
521
    {
522 16
        foreach ($headers as $header) {
523 10
            $request = $request->withoutAttribute($header);
524
        }
525
526 16
        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 16
    private function getIpList(ServerRequestInterface $request, array $ipHeaders): array
535
    {
536 16
        foreach ($ipHeaders as $ipHeader) {
537 13
            $type = null;
538
539 13
            if (is_array($ipHeader)) {
540 9
                $type = array_shift($ipHeader);
541 9
                $ipHeader = array_shift($ipHeader);
542
            }
543
544 13
            if ($request->hasHeader($ipHeader)) {
545 13
                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 9
    private function getElementsByRfc7239(array $forwards): array
590
    {
591 9
        $list = [];
592
593 9
        foreach ($forwards as $forward) {
594
            /** @var array<string, string> $data */
595 9
            $data = HeaderValueHelper::getParameters($forward);
596
597 9
            if (!isset($data['for'])) {
598
                // Invalid item, the following items will be dropped
599 2
                break;
600
            }
601
602 8
            $pattern = '/^(?<host>' . IpHelper::IPV4_PATTERN . '|unknown|_[\w\.-]+|[[]'
603 8
                . IpHelper::IPV6_PATTERN . '[]])(?::(?<port>[\w\.-]+))?$/';
604
605 8
            if (preg_match($pattern, $data['for'], $matches) === 0) {
606
                // Invalid item, the following items will be dropped
607 1
                break;
608
            }
609
610 8
            $ipData = [];
611 8
            $host = $matches['host'];
612 8
            $obfuscatedHost = $host === 'unknown' || str_starts_with($host, '_');
613
614 8
            if (!$obfuscatedHost) {
615
                // IPv4 & IPv6
616 8
                $ipData['ip'] = str_starts_with($host, '[') ? trim($host /* IPv6 */, '[]') : $host;
617
            }
618
619 8
            $ipData['host'] = $host;
620
621 8
            if (isset($matches['port'])) {
622 1
                $port = $matches['port'];
623
624 1
                if (!$obfuscatedHost && !$this->checkPort($port)) {
625
                    // Invalid port, the following items will be dropped
626 1
                    break;
627
                }
628
629 1
                $ipData['port'] = $obfuscatedHost ? $port : (int) $port;
630
            }
631
632
            // copy other properties
633 8
            foreach (['proto' => 'protocol', 'host' => 'httpHost', 'by' => 'by'] as $source => $destination) {
634 8
                if (isset($data[$source])) {
635 4
                    $ipData[$destination] = $data[$source];
636
                }
637
            }
638
639 8
            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 8
            $list[] = $ipData;
645
        }
646
647 9
        return $list;
648
    }
649
650
    /**
651
     * @param string[] $urlHeaders
652
     *
653
     * @psalm-return non-empty-list<null|string>|null
654
     */
655 16
    private function getUrl(RequestInterface $request, array $urlHeaders): ?array
656
    {
657 16
        foreach ($urlHeaders as $header) {
658 3
            if (!$request->hasHeader($header)) {
659 1
                continue;
660
            }
661
662 3
            $url = $request->getHeaderLine($header);
663
664 3
            if (str_starts_with($url, '/')) {
665 3
                return array_pad(explode('?', $url, 2), 2, null);
666
            }
667
        }
668
669 13
        return null;
670
    }
671
672 2
    private function checkPort(string $port): bool
673
    {
674 2
        return preg_match('/^\d{1,5}$/', $port) === 1 && (int) $port <= 65535;
675
    }
676
677
    /**
678
     * @psalm-assert array<non-empty-string> $array
679
     */
680 23
    private function checkTypeStringOrArray(array $array, string $field): void
681
    {
682 23
        foreach ($array as $item) {
683 23
            if (!is_string($item)) {
684 1
                throw new InvalidArgumentException("$field must be string type");
685
            }
686
687 22
            if (trim($item) === '') {
688 2
                throw new InvalidArgumentException("$field cannot be empty strings");
689
            }
690
        }
691
    }
692
}
693