Passed
Pull Request — master (#42)
by Rustam
03:31 queued 32s
created

TrustedHostsNetworkResolver::getUrl()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 15
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 4

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 7
c 1
b 0
f 0
nc 4
nop 2
dl 0
loc 15
ccs 8
cts 8
cp 1
crap 4
rs 10
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 = non-empty-array<self::DATA_KEY_*, array<string>>
66
 */
67
class TrustedHostsNetworkResolver implements MiddlewareInterface
68
{
69
    public const REQUEST_CLIENT_IP = 'requestClientIp';
70
    public const IP_HEADER_TYPE_RFC7239 = 'rfc7239';
71
72
    public const DEFAULT_TRUSTED_HEADERS = [
73
        // common:
74
        'x-forwarded-for',
75
        'x-forwarded-host',
76
        'x-forwarded-proto',
77
        'x-forwarded-port',
78
79
        // RFC:
80
        'forward',
81
82
        // Microsoft:
83
        'front-end-https',
84
        'x-rewrite-url',
85
    ];
86
87
    private const DATA_KEY_HOSTS = 'hosts';
88
    private const DATA_KEY_IP_HEADERS = 'ipHeaders';
89
    private const DATA_KEY_HOST_HEADERS = 'hostHeaders';
90
    private const DATA_KEY_URL_HEADERS = 'urlHeaders';
91
    private const DATA_KEY_PROTOCOL_HEADERS = 'protocolHeaders';
92
    private const DATA_KEY_TRUSTED_HEADERS = 'trustedHeaders';
93
    private const DATA_KEY_PORT_HEADERS = 'portHeaders';
94
95
    /**
96
     * @var array<TrustedHostData>
97
     */
98
    private array $trustedHosts = [];
99
    private ?string $attributeIps = null;
100
101 41
    public function __construct(private ValidatorInterface $validator)
102
    {
103 41
    }
104
105
    /**
106
     * Returns a new instance with the added trusted hosts and related headers.
107
     *
108
     * The header lists are evaluated in the order they were specified.
109
     * If you specify multiple headers by type (e.g. IP headers), you must ensure that the irrelevant header is removed
110
     * e.g. web server application, otherwise spoof clients can be use this vulnerability.
111
     *
112
     * @param string[] $hosts List of trusted hosts IP addresses. If {@see isValidHost()} method is extended,
113
     * then can use domain names with reverse DNS resolving e.g. yiiframework.com, * .yiiframework.com.
114
     * @param array $ipHeaders List of headers containing IP lists.
115
     * @param array $protocolHeaders List of headers containing protocol. e.g.
116
     * ['x-forwarded-for' => ['http' => 'http', 'https' => ['on', 'https']]].
117
     * @param string[] $hostHeaders List of headers containing HTTP host.
118
     * @param string[] $urlHeaders List of headers containing HTTP URL.
119
     * @param string[] $portHeaders List of headers containing port number.
120
     * @param string[]|null $trustedHeaders List of trusted headers. Removed from the request, if in checking process
121
     * are classified as untrusted by hosts.
122
     */
123 38
    public function withAddedTrustedHosts(
124
        array $hosts,
125
        // Defining default headers is not secure!
126
        array $ipHeaders = [],
127
        array $protocolHeaders = [],
128
        array $hostHeaders = [],
129
        array $urlHeaders = [],
130
        array $portHeaders = [],
131
        ?array $trustedHeaders = null,
132
    ): self {
133 38
        $new = clone $this;
134
135 38
        foreach ($ipHeaders as $ipHeader) {
136 20
            if (is_string($ipHeader)) {
137 6
                continue;
138
            }
139
140 14
            if (!is_array($ipHeader)) {
141 1
                throw new InvalidArgumentException('Type of IP header is not a string and not array.');
142
            }
143
144 13
            if (count($ipHeader) !== 2) {
145 1
                throw new InvalidArgumentException('The IP header array must have exactly 2 elements.');
146
            }
147
148 12
            [$type, $header] = $ipHeader;
149
150 12
            if (!is_string($type)) {
151 1
                throw new InvalidArgumentException('The IP header type is not a string.');
152
            }
153
154 11
            if (!is_string($header)) {
155 1
                throw new InvalidArgumentException('The IP header value is not a string.');
156
            }
157
158 10
            if ($type === self::IP_HEADER_TYPE_RFC7239) {
159 9
                continue;
160
            }
161
162 1
            throw new InvalidArgumentException("Not supported IP header type: $type.");
163
        }
164
165 33
        if ($hosts === []) {
166 8
            throw new InvalidArgumentException('Empty hosts not allowed.');
167
        }
168
169 25
        $trustedHeaders ??= self::DEFAULT_TRUSTED_HEADERS;
170
        /** @var array|ProtocolHeadersData $protocolHeaders */
171 25
        $protocolHeaders = $this->prepareProtocolHeaders($protocolHeaders);
0 ignored issues
show
Bug introduced by
It seems like $protocolHeaders can also be of type Yiisoft\Yii\Middleware\ProtocolHeadersData; however, parameter $protocolHeaders of Yiisoft\Yii\Middleware\T...repareProtocolHeaders() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

171
        $protocolHeaders = $this->prepareProtocolHeaders(/** @scrutinizer ignore-type */ $protocolHeaders);
Loading history...
172
173 21
        $this->checkTypeStringOrArray($hosts, self::DATA_KEY_HOSTS);
174 18
        $this->checkTypeStringOrArray($trustedHeaders, self::DATA_KEY_TRUSTED_HEADERS);
175 18
        $this->checkTypeStringOrArray($hostHeaders, self::DATA_KEY_HOST_HEADERS);
176 18
        $this->checkTypeStringOrArray($urlHeaders, self::DATA_KEY_URL_HEADERS);
177 18
        $this->checkTypeStringOrArray($portHeaders, self::DATA_KEY_PORT_HEADERS);
178
179 18
        foreach ($hosts as $host) {
180 18
            $host = str_replace('*', 'wildcard', $host); // wildcard is allowed in host
181
182 18
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) === false) {
183 1
                throw new InvalidArgumentException("\"$host\" host is not a domain and not an IP address.");
184
            }
185
        }
186
187
        /** @var array<array-key, string> $ipHeaders */
188 17
        $new->trustedHosts[] = [
189 17
            self::DATA_KEY_HOSTS => $hosts,
190 17
            self::DATA_KEY_IP_HEADERS => $ipHeaders,
191 17
            self::DATA_KEY_PROTOCOL_HEADERS => $protocolHeaders,
192 17
            self::DATA_KEY_TRUSTED_HEADERS => $trustedHeaders,
193 17
            self::DATA_KEY_HOST_HEADERS => $hostHeaders,
194 17
            self::DATA_KEY_URL_HEADERS => $urlHeaders,
195 17
            self::DATA_KEY_PORT_HEADERS => $portHeaders,
196 17
        ];
197
198 17
        return $new;
199
    }
200
201
    /**
202
     * Returns a new instance without the trusted hosts and related headers.
203
     */
204 1
    public function withoutTrustedHosts(): self
205
    {
206 1
        $new = clone $this;
207 1
        $new->trustedHosts = [];
208 1
        return $new;
209
    }
210
211
    /**
212
     * Returns a new instance with the specified request's attribute name to which trusted path data is added.
213
     *
214
     * @param string|null $attribute The request attribute name.
215
     *
216
     * @see getElementsByRfc7239()
217
     */
218 3
    public function withAttributeIps(?string $attribute): self
219
    {
220 3
        if ($attribute === '') {
221 1
            throw new RuntimeException('Attribute should not be empty string.');
222
        }
223
224 2
        $new = clone $this;
225 2
        $new->attributeIps = $attribute;
226 2
        return $new;
227
    }
228
229 18
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
230
    {
231
        /** @var string|null $actualHost */
232 18
        $actualHost = $request->getServerParams()['REMOTE_ADDR'] ?? null;
233
234 18
        if ($actualHost === null) {
235
            // Validation is not possible.
236 1
            return $this->handleNotTrusted($request, $handler);
237
        }
238
239 17
        $trustedHostData = null;
240 17
        $trustedHeaders = [];
241
242 17
        foreach ($this->trustedHosts as $data) {
243
            // collect all trusted headers
244 16
            $trustedHeaders[] = $data[self::DATA_KEY_TRUSTED_HEADERS];
245
246 16
            if ($trustedHostData !== null) {
247
                // trusted hosts already found
248 1
                continue;
249
            }
250
251 16
            if ($this->isValidHost($actualHost, $data[self::DATA_KEY_HOSTS])) {
252 14
                $trustedHostData = $data;
253
            }
254
        }
255
256 17
        if ($trustedHostData === null) {
257
            // No trusted host at all.
258 3
            return $this->handleNotTrusted($request, $handler);
259
        }
260
261 14
        $trustedHeaders = array_merge(...$trustedHeaders);
262 14
        $untrustedHeaders = array_diff($trustedHeaders, $trustedHostData[self::DATA_KEY_TRUSTED_HEADERS] ?? []);
263 14
        $request = $this->removeHeaders($request, $untrustedHeaders);
264
265 14
        [$ipListType, $ipHeader, $hostList] = $this->getIpList($request, $trustedHostData[self::DATA_KEY_IP_HEADERS]);
266 14
        $hostList = array_reverse($hostList); // the first item should be the closest to the server
267
268 14
        if ($ipListType === self::IP_HEADER_TYPE_RFC7239) {
269 9
            $hostList = $this->getElementsByRfc7239($hostList);
270
        } else {
271 5
            $hostList = $this->getFormattedIpList($hostList);
272
        }
273
274 14
        array_unshift($hostList, ['ip' => $actualHost]); // server's ip to first position
275 14
        $hostDataList = [];
276
277
        do {
278 14
            $hostData = array_shift($hostList);
279 14
            if (!isset($hostData['ip'])) {
280
                $hostData = $this->reverseObfuscate($hostData, $hostDataList, $hostList, $request);
281
282
                if ($hostData === null) {
283
                    continue;
284
                }
285
286
                if (!isset($hostData['ip'])) {
287
                    break;
288
                }
289
            }
290
291 14
            $ip = $hostData['ip'];
292
293 14
            if (!$this->isValidHost($ip, ['any'])) {
294
                // invalid IP
295
                break;
296
            }
297
298 14
            $hostDataList[] = $hostData;
299
300 14
            if (!$this->isValidHost($ip, $trustedHostData[self::DATA_KEY_HOSTS])) {
301
                // not trusted host
302 11
                break;
303
            }
304 14
        } while (count($hostList) > 0);
305
306 14
        if ($this->attributeIps !== null) {
307
            $request = $request->withAttribute($this->attributeIps, $hostDataList);
308
        }
309
310 14
        $uri = $request->getUri();
311
        // find HTTP host
312 14
        foreach ($trustedHostData[self::DATA_KEY_HOST_HEADERS] as $hostHeader) {
313 4
            if (!$request->hasHeader($hostHeader)) {
314
                continue;
315
            }
316
317
            if (
318 4
                $hostHeader === $ipHeader
319 4
                && $ipListType === self::IP_HEADER_TYPE_RFC7239
320 4
                && isset($hostData['httpHost'])
321
            ) {
322 2
                $uri = $uri->withHost($hostData['httpHost']);
323 2
                break;
324
            }
325
326 2
            $host = $request->getHeaderLine($hostHeader);
327
328 2
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) !== false) {
329 2
                $uri = $uri->withHost($host);
330 2
                break;
331
            }
332
        }
333
334
        // find protocol
335
        /** @psalm-var ProtocolHeadersData $protocolHeadersData */
336 14
        $protocolHeadersData = $trustedHostData[self::DATA_KEY_PROTOCOL_HEADERS];
337 14
        foreach ($protocolHeadersData as $protocolHeader => $protocols) {
338 4
            if (!$request->hasHeader($protocolHeader)) {
339
                continue;
340
            }
341
342
            if (
343 4
                $protocolHeader === $ipHeader
344 4
                && $ipListType === self::IP_HEADER_TYPE_RFC7239
345 4
                && isset($hostData['protocol'])
346
            ) {
347 4
                $uri = $uri->withScheme($hostData['protocol']);
348 4
                break;
349
            }
350
351 2
            $protocolHeaderValue = $request->getHeaderLine($protocolHeader);
352
353 2
            foreach ($protocols as $protocol => $acceptedValues) {
354 2
                if (in_array($protocolHeaderValue, $acceptedValues, true)) {
355
                    $uri = $uri->withScheme($protocol);
356
                    break 2;
357
                }
358
            }
359
        }
360
361 14
        $urlParts = $this->getUrl($request, $trustedHostData[self::DATA_KEY_URL_HEADERS]);
362
363 14
        if ($urlParts !== null) {
364 3
            [$path, $query] = $urlParts;
365 3
            if ($path !== null) {
366 3
                $uri = $uri->withPath($path);
367
            }
368
369 3
            if ($query !== null) {
370 3
                $uri = $uri->withQuery($query);
371
            }
372
        }
373
374
        // find port
375 14
        foreach ($trustedHostData[self::DATA_KEY_PORT_HEADERS] as $portHeader) {
376 2
            if (!$request->hasHeader($portHeader)) {
377
                continue;
378
            }
379
380
            if (
381 2
                $portHeader === $ipHeader
382 2
                && $ipListType === self::IP_HEADER_TYPE_RFC7239
383 2
                && isset($hostData['port'])
384 2
                && $this->checkPort((string) $hostData['port'])
385
            ) {
386 1
                $uri = $uri->withPort((int) $hostData['port']);
387 1
                break;
388
            }
389
390 1
            $port = $request->getHeaderLine($portHeader);
391
392 1
            if ($this->checkPort($port)) {
393 1
                $uri = $uri->withPort((int) $port);
394 1
                break;
395
            }
396
        }
397
398 14
        return $handler->handle(
399 14
            $request->withUri($uri)->withAttribute(self::REQUEST_CLIENT_IP, $hostData['ip'] ?? null)
400 14
        );
401
    }
402
403
    /**
404
     * Validate host by range.
405
     *
406
     * This method can be extendable by overwriting e.g. with reverse DNS verification.
407
     *
408
     * @param string[] $ranges
409
     * @param Closure(string, string[]): Result $validator
410
     */
411 16
    protected function isValidHost(string $host, array $ranges): bool
412
    {
413 16
        $result = $this->validator->validate(
414 16
            $host,
415 16
            [new Ip(allowSubnet: false, allowNegation: false, ranges: $ranges)]
416 16
        );
417 16
        return $result->isValid();
418
    }
419
420
    /**
421
     * Reverse obfuscating host data
422
     *
423
     * RFC 7239 allows to use obfuscated host data. In this case, either specifying the
424
     * IP address or dropping the proxy endpoint is required to determine validated route.
425
     *
426
     * By default, it does not perform any transformation on the data. You can override this method.
427
     *
428
     * @param HostData|null $hostData
0 ignored issues
show
Bug introduced by
The type Yiisoft\Yii\Middleware\HostData was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
429
     * @param array $hostDataListValidated
430
     * @param array $hostDataListRemaining
431
     * @param RequestInterface $request
432
     *
433
     * @return HostData|null reverse obfuscated host data or null.
434
     * In case of null data is discarded and the process continues with the next portion of host data.
435
     * If the return value is an array, it must contain at least the `ip` key.
436
     *
437
     * @see getElementsByRfc7239()
438
     * @link https://tools.ietf.org/html/rfc7239#section-6.2
439
     * @link https://tools.ietf.org/html/rfc7239#section-6.3
440
     */
441
    protected function reverseObfuscate(
442
        ?array $hostData,
443
        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

443
        /** @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...
444
        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

444
        /** @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...
445
        RequestInterface $request
446
    ): ?array {
447
        return $hostData;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $hostData returns the type array which is incompatible with the documented return type Yiisoft\Yii\Middleware\HostData|null.
Loading history...
448
    }
449
450 4
    private function handleNotTrusted(
451
        ServerRequestInterface $request,
452
        RequestHandlerInterface $handler
453
    ): ResponseInterface {
454 4
        if ($this->attributeIps !== null) {
455 1
            $request = $request->withAttribute($this->attributeIps, null);
456
        }
457
458 4
        return $handler->handle($request->withAttribute(self::REQUEST_CLIENT_IP, null));
459
    }
460
461
    /**
462
     * @return ProtocolHeadersData
0 ignored issues
show
Bug introduced by
The type Yiisoft\Yii\Middleware\ProtocolHeadersData was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
463
     */
464 25
    private function prepareProtocolHeaders(array $protocolHeaders): array
465
    {
466 25
        $output = [];
467
468 25
        foreach ($protocolHeaders as $header => $protocolAndAcceptedValues) {
469 8
            if (!is_string($header)) {
470
                throw new RuntimeException('The protocol header must be a string.');
471
            }
472 8
            $header = strtolower($header);
473
474 8
            if (is_callable($protocolAndAcceptedValues)) {
475 1
                $output[$header] = $protocolAndAcceptedValues;
476 1
                continue;
477
            }
478
479 7
            if (!is_array($protocolAndAcceptedValues)) {
480 1
                throw new RuntimeException('Accepted values is not an array nor callable.');
481
            }
482
483 6
            if ($protocolAndAcceptedValues === []) {
484 1
                throw new RuntimeException('Accepted values cannot be an empty array.');
485
            }
486
487 5
            $output[$header] = [];
488
489
            /**
490
             * @var array<string|string[]> $protocolAndAcceptedValues
491
             */
492 5
            foreach ($protocolAndAcceptedValues as $protocol => $acceptedValues) {
493 5
                if (!is_string($protocol)) {
494 1
                    throw new RuntimeException('The protocol must be a string.');
495
                }
496
497 4
                if ($protocol === '') {
498 1
                    throw new RuntimeException('The protocol cannot be empty.');
499
                }
500
501 3
                $output[$header][$protocol] = array_map('\strtolower', (array) $acceptedValues);
502
            }
503
        }
504
505 21
        return $output;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $output returns the type array which is incompatible with the documented return type Yiisoft\Yii\Middleware\ProtocolHeadersData.
Loading history...
506
    }
507
508
    /**
509
     * @param string[] $headers
510
     */
511 14
    private function removeHeaders(ServerRequestInterface $request, array $headers): ServerRequestInterface
512
    {
513 14
        foreach ($headers as $header) {
514
            $request = $request->withoutAttribute($header);
515
        }
516
517 14
        return $request;
518
    }
519
520
    /**
521
     * @param array<string|string[]> $ipHeaders
522
     *
523
     * @return array{0: string|null, 1: string|null, 2: string[]}
524
     */
525 14
    private function getIpList(ServerRequestInterface $request, array $ipHeaders): array
526
    {
527 14
        foreach ($ipHeaders as $ipHeader) {
528 12
            $type = null;
529
530 12
            if (is_array($ipHeader)) {
531 9
                $type = array_shift($ipHeader);
532 9
                $ipHeader = array_shift($ipHeader);
533
            }
534
535 12
            if ($request->hasHeader($ipHeader)) {
536 12
                return [$type, $ipHeader, $request->getHeader($ipHeader)];
537
            }
538
        }
539
540 2
        return [null, null, []];
541
    }
542
543
    /**
544
     * @param string[] $forwards
545
     *
546
     * @return list<HostData>
0 ignored issues
show
Bug introduced by
The type Yiisoft\Yii\Middleware\list was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
547
     *
548
     * @see getElementsByRfc7239()
549
     */
550 5
    private function getFormattedIpList(array $forwards): array
551
    {
552 5
        $list = [];
553
554 5
        foreach ($forwards as $ip) {
555 3
            $list[] = ['ip' => $ip];
556
        }
557
558 5
        return $list;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $list returns the type array|array<mixed,array<string,string>> which is incompatible with the documented return type Yiisoft\Yii\Middleware\list.
Loading history...
559
    }
560
561
    /**
562
     * Forwarded elements by RFC7239.
563
     *
564
     * The structure of the elements:
565
     * - `host`: IP or obfuscated hostname or "unknown"
566
     * - `ip`: IP address (only if presented)
567
     * - `by`: used user-agent by proxy (only if presented)
568
     * - `port`: port number received by proxy (only if presented)
569
     * - `protocol`: protocol received by proxy (only if presented)
570
     * - `httpHost`: HTTP host received by proxy (only if presented)
571
     *
572
     * The list starts with the server and the last item is the client itself.
573
     *
574
     * @link https://tools.ietf.org/html/rfc7239
575
     *
576
     * @param string[] $forwards
577
     *
578
     * @return list<HostData> Proxy data elements.
579
     */
580 9
    private function getElementsByRfc7239(array $forwards): array
581
    {
582 9
        $list = [];
583
584 9
        foreach ($forwards as $forward) {
585
            /** @var array<string, string> $data */
586 9
            $data = HeaderValueHelper::getParameters($forward);
587
588 9
            if (!isset($data['for'])) {
589
                // Invalid item, the following items will be dropped
590 2
                break;
591
            }
592
593 8
            $pattern = '/^(?<host>' . IpHelper::IPV4_PATTERN . '|unknown|_[\w\.-]+|[[]'
594 8
                . IpHelper::IPV6_PATTERN . '[]])(?::(?<port>[\w\.-]+))?$/';
595
596 8
            if (preg_match($pattern, $data['for'], $matches) === 0) {
597
                // Invalid item, the following items will be dropped
598 1
                break;
599
            }
600
601 8
            $ipData = [];
602 8
            $host = $matches['host'];
603 8
            $obfuscatedHost = $host === 'unknown' || str_starts_with($host, '_');
604
605 8
            if (!$obfuscatedHost) {
606
                // IPv4 & IPv6
607 8
                $ipData['ip'] = str_starts_with($host, '[') ? trim($host /* IPv6 */, '[]') : $host;
608
            }
609
610 8
            $ipData['host'] = $host;
611
612 8
            if (isset($matches['port'])) {
613 1
                $port = $matches['port'];
614
615 1
                if (!$obfuscatedHost && !$this->checkPort($port)) {
616
                    // Invalid port, the following items will be dropped
617
                    break;
618
                }
619
620 1
                $ipData['port'] = $obfuscatedHost ? $port : (int) $port;
621
            }
622
623
            // copy other properties
624 8
            foreach (['proto' => 'protocol', 'host' => 'httpHost', 'by' => 'by'] as $source => $destination) {
625 8
                if (isset($data[$source])) {
626 4
                    $ipData[$destination] = $data[$source];
627
                }
628
            }
629
630 8
            if (isset($ipData['httpHost']) && filter_var($ipData['httpHost'], FILTER_VALIDATE_DOMAIN) === false) {
631
                // remove not valid HTTP host
632
                unset($ipData['httpHost']);
633
            }
634
635 8
            $list[] = $ipData;
636
        }
637
638 9
        return $list;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $list returns the type array|array<mixed,array> which is incompatible with the documented return type Yiisoft\Yii\Middleware\list.
Loading history...
639
    }
640
641
    /**
642
     * @param string[] $urlHeaders
643
     *
644
     * @psalm-return non-empty-list<null|string>|null
645
     */
646 14
    private function getUrl(RequestInterface $request, array $urlHeaders): ?array
647
    {
648 14
        foreach ($urlHeaders as $header) {
649 3
            if (!$request->hasHeader($header)) {
650 1
                continue;
651
            }
652
653 3
            $url = $request->getHeaderLine($header);
654
655 3
            if (str_starts_with($url, '/')) {
656 3
                return array_pad(explode('?', $url, 2), 2, null);
657
            }
658
        }
659
660 11
        return null;
661
    }
662
663 2
    private function checkPort(string $port): bool
664
    {
665 2
        return preg_match('/^\d{1,5}$/', $port) === 1 && (int) $port <= 65535;
666
    }
667
668
    /**
669
     * @psalm-assert array<non-empty-string> $array
670
     */
671 21
    private function checkTypeStringOrArray(array $array, string $field): void
672
    {
673 21
        foreach ($array as $item) {
674 21
            if (!is_string($item)) {
675 1
                throw new InvalidArgumentException("$field must be string type");
676
            }
677
678 20
            if (trim($item) === '') {
679 2
                throw new InvalidArgumentException("$field cannot be empty strings");
680
            }
681
        }
682
    }
683
}
684