Completed
Pull Request — master (#157)
by Alexander
01:52
created

prepareProtocolHeaders()   B

Complexity

Conditions 8
Paths 8

Size

Total Lines 27
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 10.0155

Importance

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

288
    protected function reverseObfuscate(array $ipData, /** @scrutinizer ignore-unused */ array $ipDataList): array

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