Passed
Pull Request — master (#157)
by
unknown
02:27
created

TrustedHostsNetworkResolver::getScheme()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 8
nc 5
nop 2
dl 0
loc 14
rs 9.6111
c 0
b 0
f 0
1
<?php
2
3
namespace Yiisoft\Yii\Web\Middleware;
4
5
use Psr\Http\Message\RequestInterface;
6
use Psr\Http\Message\ResponseFactoryInterface;
7
use Psr\Http\Message\ResponseInterface;
8
use Psr\Http\Message\ServerRequestInterface;
9
use Psr\Http\Server\MiddlewareInterface;
10
use Psr\Http\Server\RequestHandlerInterface;
11
use Yiisoft\NetworkUtilities\IpHelper;
12
use Yiisoft\Validator\Rule\Ip;
13
use Yiisoft\Yii\Web\Helper\HeaderHelper;
14
15
class TrustedHostsNetworkResolver implements MiddlewareInterface
16
{
17
    public const IP_HEADER_TYPE_RFC7239 = 'rfc7239';
18
    
19
    public const DEFAULT_TRUSTED_HEADERS = [
20
        // common:
21
        'x-forwarded-for',
22
        'x-forwarded-host',
23
        'x-forwarded-proto',
24
25
        // RFC:
26
        'forward',
27
28
        // Microsoft:
29
        'front-end-https',
30
        'x-rewrite-url',
31
    ];
32
33
    private const DATA_KEY_HOSTS = 'hosts';
34
    private const DATA_KEY_IP_HEADERS = 'ipHeaders';
35
    private const DATA_KEY_HOST_HEADERS = 'hostHeaders';
36
    private const DATA_KEY_URL_HEADERS = 'urlHeaders';
37
    private const DATA_KEY_PROTOCOL_HEADERS = 'protocolHeaders';
38
    private const DATA_KEY_TRUSTED_HEADERS = 'trustedHeaders';
39
40
    private $trustedHosts = [];
41
42
    /**
43
     * @var string|null
44
     */
45
    private $attributeIps;
46
47
    /**
48
     * @var ResponseFactoryInterface
49
     */
50
    private $responseFactory;
51
    /**
52
     * @var Chain|null
53
     */
54
    private $notTrustedBranch;
55
56
    /**
57
     * @var Ip|null
58
     */
59
    private $ipValidator;
60
61
    public function __construct(ResponseFactoryInterface $responseFactory)
62
    {
63
        $this->responseFactory = $responseFactory;
64
    }
65
66
    /**
67
     * @return static
68
     */
69
    public function withIpValidator(Ip $ipValidator)
70
    {
71
        $new = clone $this;
72
        $ipValidator = clone $ipValidator;
73
        // force disable unacceptable validation
74
        $new->ipValidator = $ipValidator->disallowSubnet()->disallowNegation();
75
        return $new;
76
    }
77
78
    /**
79
     * @return static
80
     */
81
    public function withNotTrustedBranch(?MiddlewareInterface $middleware)
82
    {
83
        $new = clone $this;
84
        $new->notTrustedBranch = $middleware;
85
        return $new;
86
    }
87
88
    /**
89
     * @return static
90
     */
91
    public function withAddedTrustedHosts(
92
        array $hosts,
93
        // Defining default headers is not secure!
94
        array $ipHeaders = [],
95
        array $protocolHeaders = [],
96
        array $hostHeaders = [],
97
        array $urlHeaders = [],
98
        ?array $trustedHeaders = null
99
    ) {
100
        $new = clone $this;
101
        foreach ($ipHeaders as $ipHeader) {
102
            if (\is_string($ipHeader)) {
103
                continue;
104
            }
105
            if (!\is_array($ipHeader)) {
106
                throw new \InvalidArgumentException('Type of ipHeader is not a string and not array');
107
            }
108
            if (count($ipHeader) !== 2) {
109
                throw new \InvalidArgumentException('The ipHeader array must have exactly 2 elements');
110
            }
111
            [$type, $header] = $ipHeader;
112
            if (!\is_string($type)) {
113
                throw new \InvalidArgumentException('The type is not a string');
114
            }
115
            if (!\is_string($header)) {
116
                throw new \InvalidArgumentException('The header is not a string');
117
            }
118
            if ($type === self::IP_HEADER_TYPE_RFC7239) {
119
                continue;
120
            }
121
122
            throw new \InvalidArgumentException("Not supported IP header type: $type");
123
        }
124
        if (count($hosts) === 0) {
125
            throw new \InvalidArgumentException("Empty hosts not allowed");
126
        }
127
        $data = [
128
            self::DATA_KEY_HOSTS => $hosts,
129
            self::DATA_KEY_IP_HEADERS => $ipHeaders,
130
            self::DATA_KEY_PROTOCOL_HEADERS => $this->prepareProtocolHeaders($protocolHeaders),
131
            self::DATA_KEY_TRUSTED_HEADERS => $trustedHeaders ?? self::DEFAULT_TRUSTED_HEADERS,
132
            self::DATA_KEY_HOST_HEADERS => $hostHeaders,
133
            self::DATA_KEY_URL_HEADERS => $urlHeaders,
134
        ];
135
        foreach ([
136
                     self::DATA_KEY_HOSTS,
137
                     self::DATA_KEY_TRUSTED_HEADERS,
138
                     self::DATA_KEY_HOST_HEADERS,
139
                     self::DATA_KEY_URL_HEADERS
140
                 ] as $key) {
141
            $this->checkStringArrayType($data[$key], $key);
142
        }
143
        foreach ($data[self::DATA_KEY_HOSTS] as $host) {
144
            $host = str_replace('*', 'wildcard', $host);        // wildcard is allowed in host
145
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) === false) {
146
                throw new \InvalidArgumentException("'$host' host is not a domain and not an IP address");
147
            }
148
        }
149
        $new->trustedHosts[] = $data;
150
        return $new;
151
    }
152
153
    private function checkStringArrayType(array $array, string $field): void
154
    {
155
        foreach ($array as $item) {
156
            if (!is_string($item)) {
157
                throw new \InvalidArgumentException("$field must be string type");
158
            }
159
            if (trim($item) === '') {
160
                throw new \InvalidArgumentException("$field cannot be empty strings");
161
            }
162
        }
163
    }
164
165
    /**
166
     * @return static
167
     */
168
    public function withoutTrustedHosts()
169
    {
170
        $new = clone $this;
171
        $new->trustedHosts = [];
172
        return $new;
173
    }
174
175
    /**
176
     * @return static
177
     */
178
    public function withAttributeIps(?string $attribute)
179
    {
180
        if ($attribute === '') {
181
            throw new \RuntimeException('Attribute should not be empty');
182
        }
183
        $new = clone $this;
184
        $new->attributeIps = $attribute;
185
        return $new;
186
    }
187
188
    /**
189
     * Process an incoming server request.
190
     *
191
     * Processes an incoming server request in order to produce a response.
192
     * If unable to produce the response itself, it may delegate to the provided
193
     * request handler to do so.
194
     */
195
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
196
    {
197
        $actualHost = $request->getServerParams()['REMOTE_ADDR'];
198
        $trustedHostData = null;
199
        $trustedHeaders = [];
200
        $ipValidator = $this->ipValidator ?? new Ip();
201
        foreach ($this->trustedHosts as $data) {
202
            // collect all trusted headers
203
            $trustedHeaders = array_merge($trustedHeaders, $data[self::DATA_KEY_TRUSTED_HEADERS]);
204
            if ($trustedHostData !== null) {
205
                // trusted hosts already found
206
                continue;
207
            }
208
            if ($this->isValidHost($actualHost, $data[self::DATA_KEY_HOSTS], $ipValidator)) {
209
                $trustedHostData = $data;
210
            }
211
        }
212
        $untrustedHeaders = array_diff($trustedHeaders, $trustedHostData[self::DATA_KEY_TRUSTED_HEADERS] ?? []);
213
        $request = $this->removeHeaders($request, $untrustedHeaders);
214
        if ($trustedHostData === null) {
215
            // No trusted host at all.
216
            if ($this->notTrustedBranch !== null) {
217
                return $this->notTrustedBranch->process($request, $handler);
218
            }
219
            $response = $this->responseFactory->createResponse(412);
220
            $response->getBody()->write('Unable to verify your network.');
221
            return $response;
222
        }
223
        [$type, $ipList] = $this->getIpList($request, $trustedHostData[self::DATA_KEY_IP_HEADERS]);
224
        $ipList = array_reverse($ipList);       // the first item should be the closest to the server
225
        if ($type === null) {
226
            $ipList = $this->getFormattedIpList($ipList);
227
        } elseif ($type === self::IP_HEADER_TYPE_RFC7239) {
228
            $ipList = $this->getForwardedElements($ipList);
229
        }
230
        array_unshift($ipList, ['ip' => $actualHost]);  // server's ip to first position
231
        $ipDataList = [];
232
        do {
233
            $ipData = array_shift($ipList);
234
            if (!isset($ipData['ip'])) {
235
                $ipData = $this->reverseObfuscate($ipData, $ipDataList, $ipList, $request);
236
                if (!isset($ipData['ip'])) {
237
                    break;
238
                }
239
            }
240
            $ip = $ipData['ip'];
241
            if (!$this->isValidHost($ip, ['any'], $ipValidator)) {
242
                break;
243
            }
244
            $ipDataList[] = $ipData;
245
            if (!$this->isValidHost($ip, $trustedHostData[self::DATA_KEY_HOSTS], $ipValidator)) {
246
                break;
247
            }
248
        } while (count($ipList) > 0);
249
250
        if ($this->attributeIps !== null) {
251
            $request = $request->withAttribute($this->attributeIps, $ipDataList);
252
        }
253
254
        $uri = $request->getUri();
255
        if (isset($ipData['httpHost'])) {
256
            $uri = $uri->withHost($ipData['httpHost']);
257
        } else {
258
            // find host from headers
259
            $host = $this->getHttpHost($request, $trustedHostData[self::DATA_KEY_HOST_HEADERS]);
260
            if ($host !== null) {
261
                $uri = $uri->withHost($host);
262
            }
263
        }
264
        if (isset($ipData['protocol'])) {
265
            $uri = $uri->withScheme($ipData['protocol']);
266
        } else {
267
            // find scheme from headers
268
            $scheme = $this->getScheme($request, $trustedHostData[self::DATA_KEY_PROTOCOL_HEADERS]);
269
            if ($scheme !== null) {
270
                $uri = $uri->withScheme($scheme);
271
            }
272
        }
273
        $urlParts = $this->getUrl($request, $trustedHostData[self::DATA_KEY_URL_HEADERS]);
274
        if ($urlParts !== null) {
275
            [$path, $query] = $urlParts;
276
            $uri = $uri->withPath($path);
277
            if ($query !== null) {
278
                $uri = $uri->withQuery($query);
279
            }
280
        }
281
        return $handler->handle($request->withUri($uri)->withAttribute('requestClientIp', $ipData['ip']));
282
    }
283
284
    /**
285
     * Validate host by range
286
     *
287
     * This method can be extendable by overwriting eg. with reverse DNS verification.
288
     */
289
    protected function isValidHost(string $host, array $ranges, Ip $validator): bool
290
    {
291
        return $validator->ranges($ranges)->validate($host)->isValid();
292
    }
293
294
    /**
295
     * Reverse obfuscating host data
296
     *
297
     * The base operation does not perform any transformation on the data.
298
     * This method can be extendable by overwriting eg.
299
     */
300
    protected function reverseObfuscate(
301
        array $ipData,
302
        array $ipDataListValidated,
0 ignored issues
show
Unused Code introduced by
The parameter $ipDataListValidated 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

302
        /** @scrutinizer ignore-unused */ array $ipDataListValidated,

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...
303
        array $ipDataListRemaining,
0 ignored issues
show
Unused Code introduced by
The parameter $ipDataListRemaining 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

303
        /** @scrutinizer ignore-unused */ array $ipDataListRemaining,

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...
304
        RequestInterface $request
0 ignored issues
show
Unused Code introduced by
The parameter $request 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

304
        /** @scrutinizer ignore-unused */ RequestInterface $request

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...
305
    ): array {
306
        return $ipData;
307
    }
308
309
    private function prepareProtocolHeaders(array $protocolHeaders): array
310
    {
311
        $output = [];
312
        foreach ($protocolHeaders as $header => $protocolAndAcceptedValues) {
313
            $header = strtolower($header);
314
            if (\is_callable($protocolAndAcceptedValues)) {
315
                $output[$header] = $protocolAndAcceptedValues;
316
                continue;
317
            }
318
            if (!\is_array($protocolAndAcceptedValues)) {
319
                throw new \RuntimeException('Accepted values is not an array nor callable');
320
            }
321
            if (count($protocolAndAcceptedValues) === 0) {
322
                throw new \RuntimeException('Accepted values cannot be an empty array');
323
            }
324
            $output[$header] = [];
325
            foreach ($protocolAndAcceptedValues as $protocol => $acceptedValues) {
326
                if (!\is_string($protocol)) {
327
                    throw new \RuntimeException('The protocol must be a string');
328
                }
329
                if ($protocol === '') {
330
                    throw new \RuntimeException('The protocol cannot be empty');
331
                }
332
                $output[$header][$protocol] = array_map('strtolower', (array)$acceptedValues);
333
            }
334
        }
335
        return $output;
336
    }
337
338
    private function removeHeaders(ServerRequestInterface $request, array $headers): ServerRequestInterface
339
    {
340
        foreach ($headers as $header) {
341
            $request = $request->withoutAttribute($header);
342
        }
343
        return $request;
344
    }
345
346
    private function getIpList(RequestInterface $request, array $ipHeaders): array
347
    {
348
        foreach ($ipHeaders as $ipHeader) {
349
            $type = null;
350
            if (\is_array($ipHeader)) {
351
                $type = array_shift($ipHeader);
352
                $ipHeader = array_shift($ipHeader);
353
            }
354
            if ($request->hasHeader($ipHeader)) {
355
                return [$type, $request->getHeader($ipHeader)];
356
            }
357
        }
358
        return [null, []];
359
    }
360
361
    private function getFormattedIpList(array $forwards): array
362
    {
363
        $list = [];
364
        foreach ($forwards as $ip) {
365
            $list[] = ['ip' => $ip];
366
        }
367
        return $list;
368
    }
369
370
    /**
371
     * Forwarded elements by RFC7239
372
     *
373
     * @link https://tools.ietf.org/html/rfc7239
374
     */
375
    private function getForwardedElements(array $forwards): array
376
    {
377
        $list = [];
378
        foreach ($forwards as $forward) {
379
            $data = HeaderHelper::getParameters($forward);
380
            if (!isset($data['for'])) {
381
                // Invalid item, the following items will be dropped
382
                break;
383
            }
384
            $pattern = '/^(?<host>' . IpHelper::IPV4_PATTERN . '|unknown|_[\w\.-]+|[[]' . IpHelper::IPV6_PATTERN . '[]])(?::(?<port>[\w\.-]+))?$/';
385
            if (preg_match($pattern, $data['for'], $matches) === 0) {
386
                // Invalid item, the following items will be dropped
387
                break;
388
            }
389
            $ipData = [];
390
            $host = $matches['host'];
391
            $obfuscatedHost = $host === 'unknown' || strpos($host, '_') === 0;
392
            if (!$obfuscatedHost) {
393
                // IPv4 & IPv6
394
                $ipData['ip'] = strpos($host, '[') === 0 ? trim($host /* IPv6 */, '[]') : $host;
395
            }
396
            $ipData['host'] = $host;
397
            if (isset($matches['port'])) {
398
                $port = $matches['port'];
399
                if (!$obfuscatedHost && (preg_match('/^\d{1,5}$/', $port) === 0 || (int)$port > 65535)) {
400
                    // Invalid port, the following items will be dropped
401
                    break;
402
                }
403
                $ipData['port'] = $obfuscatedHost ? $port : (int)$port;
404
            }
405
406
            // copy other properties
407
            foreach (['proto' => 'protocol', 'host' => 'httpHost', 'by' => 'by'] as $source => $destination) {
408
                if (isset($data[$source])) {
409
                    $ipData[$destination] = $data[$source];
410
                }
411
            }
412
413
            $list[] = $ipData;
414
        }
415
        return $list;
416
    }
417
418
    private function getHttpHost(RequestInterface $request, array $hostHeaders): ?string
419
    {
420
        foreach ($hostHeaders as $header) {
421
            if (!$request->hasHeader($header)) {
422
                continue;
423
            }
424
            $host = $request->getHeaderLine($header);
425
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) !== false) {
426
                return $host;
427
            }
428
        }
429
        return null;
430
    }
431
432
    private function getScheme(RequestInterface $request, array $protocolHeaders): ?string
433
    {
434
        foreach ($protocolHeaders as $header => $ref) {
435
            if (!$request->hasHeader($header)) {
436
                continue;
437
            }
438
            $value = strtolower($request->getHeaderLine($header));
439
            foreach ($ref as $protocol => $acceptedValues) {
440
                if (\in_array($value, $acceptedValues, true)) {
441
                    return $protocol;
442
                }
443
            }
444
        }
445
        return null;
446
    }
447
448
    private function getUrl(RequestInterface $request, array $urlHeaders): ?array
449
    {
450
        foreach ($urlHeaders as $header) {
451
            if (!$request->hasHeader($header)) {
452
                continue;
453
            }
454
            $url = $request->getHeaderLine($header);
455
            if (strpos($url, '/') === 0) {
456
                return array_pad(explode('?', $url, 2), 2, null);
457
            }
458
        }
459
        return null;
460
    }
461
}
462