Completed
Pull Request — master (#157)
by Alexander
02:06 queued 16s
created

TrustedHostsNetworkResolver::withAttributeIps()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

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

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