Passed
Push — master ( 8cc129...e6cd77 )
by
unknown
02:34
created

TrustedHostsNetworkResolver   F

Complexity

Total Complexity 101

Size/Duplication

Total Lines 634
Duplicated Lines 0 %

Test Coverage

Coverage 95.14%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 251
c 2
b 0
f 0
dl 0
loc 634
ccs 235
cts 247
cp 0.9514
rs 2
wmc 101

16 Methods

Rating   Name   Duplication   Size   Complexity  
A getUrl() 0 22 5
A __construct() 0 2 1
B withAddedTrustedHosts() 0 75 11
A withAttributeIps() 0 9 2
A withoutTrustedHosts() 0 5 1
A isValidHost() 0 7 1
A handleNotTrusted() 0 9 2
A reverseObfuscate() 0 7 1
A getIpList() 0 16 4
A getFormattedIpList() 0 9 2
B prepareProtocolHeaders() 0 45 9
C getElementsByRfc7239() 0 63 16
F process() 0 168 37
A removeHeaders() 0 7 2
A checkPort() 0 15 3
A requireListOfNonEmptyStrings() 0 9 4

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

445
        /** @scrutinizer ignore-unused */ array $hostDataListValidated,

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
446
        array $hostDataListRemaining,
0 ignored issues
show
Unused Code introduced by
The parameter $hostDataListRemaining is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

446
        /** @scrutinizer ignore-unused */ array $hostDataListRemaining,

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

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