Passed
Pull Request — master (#157)
by
unknown
10:12 queued 03:56
created

prepareProtocolHeaders()   B

Complexity

Conditions 8
Paths 8

Size

Total Lines 27
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 8
eloc 18
nc 8
nop 1
dl 0
loc 27
rs 8.4444
c 3
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
     * @param string[] $hosts List of trusted hosts IP addresses. If `isValidHost` is extended, then can use
90
     *                        domain names with reverse DNS resolving eg. yiiframework.com, * .yiiframework.com.
91
     * @param array $ipHeaders List of headers containing IP lists.
92
     * @param array $protocolHeaders List of headers containing protocol. @TODO
93
     * @param string[] $hostHeaders List of headers containing HTTP host.
94
     * @param string[] $urlHeaders List of headers containing HTTP URL.
95
     * @param string[]|null $trustedHeaders List of trusted headers.
96
     * @return static
97
     */
98
    public function withAddedTrustedHosts(
99
        array $hosts,
100
        // Defining default headers is not secure!
101
        array $ipHeaders = [],
102
        array $protocolHeaders = [],
103
        array $hostHeaders = [],
104
        array $urlHeaders = [],
105
        ?array $trustedHeaders = null
106
    ) {
107
        $new = clone $this;
108
        foreach ($ipHeaders as $ipHeader) {
109
            if (\is_string($ipHeader)) {
110
                continue;
111
            }
112
            if (!\is_array($ipHeader)) {
113
                throw new \InvalidArgumentException('Type of ipHeader is not a string and not array');
114
            }
115
            if (count($ipHeader) !== 2) {
116
                throw new \InvalidArgumentException('The ipHeader array must have exactly 2 elements');
117
            }
118
            [$type, $header] = $ipHeader;
119
            if (!\is_string($type)) {
120
                throw new \InvalidArgumentException('The type is not a string');
121
            }
122
            if (!\is_string($header)) {
123
                throw new \InvalidArgumentException('The header is not a string');
124
            }
125
            if ($type === self::IP_HEADER_TYPE_RFC7239) {
126
                continue;
127
            }
128
129
            throw new \InvalidArgumentException("Not supported IP header type: $type");
130
        }
131
        if (count($hosts) === 0) {
132
            throw new \InvalidArgumentException("Empty hosts not allowed");
133
        }
134
        $data = [
135
            self::DATA_KEY_HOSTS => $hosts,
136
            self::DATA_KEY_IP_HEADERS => $ipHeaders,
137
            self::DATA_KEY_PROTOCOL_HEADERS => $this->prepareProtocolHeaders($protocolHeaders),
138
            self::DATA_KEY_TRUSTED_HEADERS => $trustedHeaders ?? self::DEFAULT_TRUSTED_HEADERS,
139
            self::DATA_KEY_HOST_HEADERS => $hostHeaders,
140
            self::DATA_KEY_URL_HEADERS => $urlHeaders,
141
        ];
142
        foreach ([
143
                     self::DATA_KEY_HOSTS,
144
                     self::DATA_KEY_TRUSTED_HEADERS,
145
                     self::DATA_KEY_HOST_HEADERS,
146
                     self::DATA_KEY_URL_HEADERS
147
                 ] as $key) {
148
            $this->checkStringArrayType($data[$key], $key);
149
        }
150
        foreach ($data[self::DATA_KEY_HOSTS] as $host) {
151
            $host = str_replace('*', 'wildcard', $host);        // wildcard is allowed in host
152
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) === false) {
153
                throw new \InvalidArgumentException("'$host' host is not a domain and not an IP address");
154
            }
155
        }
156
        $new->trustedHosts[] = $data;
157
        return $new;
158
    }
159
160
    private function checkStringArrayType(array $array, string $field): void
161
    {
162
        foreach ($array as $item) {
163
            if (!is_string($item)) {
164
                throw new \InvalidArgumentException("$field must be string type");
165
            }
166
            if (trim($item) === '') {
167
                throw new \InvalidArgumentException("$field cannot be empty strings");
168
            }
169
        }
170
    }
171
172
    /**
173
     * @return static
174
     */
175
    public function withoutTrustedHosts()
176
    {
177
        $new = clone $this;
178
        $new->trustedHosts = [];
179
        return $new;
180
    }
181
182
    /**
183
     * @return static
184
     */
185
    public function withAttributeIps(?string $attribute)
186
    {
187
        if ($attribute === '') {
188
            throw new \RuntimeException('Attribute should not be empty');
189
        }
190
        $new = clone $this;
191
        $new->attributeIps = $attribute;
192
        return $new;
193
    }
194
195
    /**
196
     * Process an incoming server request.
197
     *
198
     * Processes an incoming server request in order to produce a response.
199
     * If unable to produce the response itself, it may delegate to the provided
200
     * request handler to do so.
201
     */
202
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
203
    {
204
        $actualHost = $request->getServerParams()['REMOTE_ADDR'];
205
        $trustedHostData = null;
206
        $trustedHeaders = [];
207
        $ipValidator = $this->ipValidator ?? new Ip();
208
        foreach ($this->trustedHosts as $data) {
209
            // collect all trusted headers
210
            $trustedHeaders = array_merge($trustedHeaders, $data[self::DATA_KEY_TRUSTED_HEADERS]);
211
            if ($trustedHostData !== null) {
212
                // trusted hosts already found
213
                continue;
214
            }
215
            if ($this->isValidHost($actualHost, $data[self::DATA_KEY_HOSTS], $ipValidator)) {
216
                $trustedHostData = $data;
217
            }
218
        }
219
        $untrustedHeaders = array_diff($trustedHeaders, $trustedHostData[self::DATA_KEY_TRUSTED_HEADERS] ?? []);
220
        $request = $this->removeHeaders($request, $untrustedHeaders);
221
        if ($trustedHostData === null) {
222
            // No trusted host at all.
223
            if ($this->notTrustedBranch !== null) {
224
                return $this->notTrustedBranch->process($request, $handler);
225
            }
226
            $response = $this->responseFactory->createResponse(412);
227
            $response->getBody()->write('Unable to verify your network.');
228
            return $response;
229
        }
230
        [$ipListType, $ipHeader, $ipList] = $this->getIpList($request, $trustedHostData[self::DATA_KEY_IP_HEADERS]);
231
        $ipList = array_reverse($ipList);       // the first item should be the closest to the server
232
        if ($ipListType === null) {
233
            $ipList = $this->getFormattedIpList($ipList);
234
        } elseif ($ipListType === self::IP_HEADER_TYPE_RFC7239) {
235
            $ipList = $this->getElementsByRfc7239($ipList);
236
        }
237
        array_unshift($ipList, ['ip' => $actualHost]);  // server's ip to first position
238
        $ipDataList = [];
239
        do {
240
            $ipData = array_shift($ipList);
241
            if (!isset($ipData['ip'])) {
242
                $ipData = $this->reverseObfuscate($ipData, $ipDataList, $ipList, $request);
243
                if (!isset($ipData['ip'])) {
244
                    break;
245
                }
246
            }
247
            $ip = $ipData['ip'];
248
            if (!$this->isValidHost($ip, ['any'], $ipValidator)) {
249
                break;
250
            }
251
            $ipDataList[] = $ipData;
252
            if (!$this->isValidHost($ip, $trustedHostData[self::DATA_KEY_HOSTS], $ipValidator)) {
253
                break;
254
            }
255
        } while (count($ipList) > 0);
256
257
        if ($this->attributeIps !== null) {
258
            $request = $request->withAttribute($this->attributeIps, $ipDataList);
259
        }
260
261
        $uri = $request->getUri();
262
        // find HTTP host
263
        foreach ($trustedHostData[self::DATA_KEY_HOST_HEADERS] as $hostHeader) {
264
            if (!$request->hasHeader($hostHeader)) {
265
                continue;
266
            }
267
            if ($hostHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($ipData['httpHost'])) {
268
                $uri = $uri->withHost($ipData['httpHost']);
269
                break;
270
            }
271
            $host = $request->getHeaderLine($hostHeader);
272
            if (filter_var($host, FILTER_VALIDATE_DOMAIN) !== false) {
273
                $uri = $uri->withHost($host);
274
                break;
275
            }
276
        }
277
278
        // find protocol
279
        foreach ($trustedHostData[self::DATA_KEY_PROTOCOL_HEADERS] as $protocolHeader => $protocols) {
280
            if (!$request->hasHeader($protocolHeader)) {
281
                continue;
282
            }
283
            if ($protocolHeader === $ipHeader && $ipListType === self::IP_HEADER_TYPE_RFC7239 && isset($ipData['protocol'])) {
284
                $uri = $uri->withScheme($ipData['protocol']);
285
                break;
286
            }
287
            $protocolHeaderValue = $request->getHeaderLine($protocolHeader);
288
            foreach ($protocols as $protocol => $acceptedValues) {
289
                if (\in_array($protocolHeaderValue, $acceptedValues)) {
290
                    $uri = $uri->withScheme($protocol);
291
                    break 2;
292
                }
293
            }
294
        }
295
        $urlParts = $this->getUrl($request, $trustedHostData[self::DATA_KEY_URL_HEADERS]);
296
        if ($urlParts !== null) {
297
            [$path, $query] = $urlParts;
298
            $uri = $uri->withPath($path);
299
            if ($query !== null) {
300
                $uri = $uri->withQuery($query);
301
            }
302
        }
303
        return $handler->handle($request->withUri($uri)->withAttribute('requestClientIp', $ipData['ip']));
304
    }
305
306
    /**
307
     * Validate host by range
308
     *
309
     * This method can be extendable by overwriting eg. with reverse DNS verification.
310
     */
311
    protected function isValidHost(string $host, array $ranges, Ip $validator): bool
312
    {
313
        return $validator->ranges($ranges)->validate($host)->isValid();
314
    }
315
316
    /**
317
     * Reverse obfuscating host data
318
     *
319
     * The base operation does not perform any transformation on the data.
320
     * This method can be extendable by overwriting eg.
321
     *
322
     * @see getElementsByRfc7239
323
     */
324
    protected function reverseObfuscate(
325
        array $ipData,
326
        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

326
        /** @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...
327
        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

327
        /** @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...
328
        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

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