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 |
|
|
|
|
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
|
|
|
|
This check looks for parameters that have been defined for a function or method, but which are not used in the method body.