Skip to content

Commit bac17ff

Browse files
Fix the getClientIps function (#147)
* fix get realIp * feat(Request): refactor ClientIp handling and add IpUtils class * up * up * up --------- Co-authored-by: zds <[email protected]>
1 parent bbedc62 commit bac17ff

File tree

3 files changed

+311
-14
lines changed

3 files changed

+311
-14
lines changed

IpUtils.php

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* This file is part of MineAdmin.
6+
*
7+
* @link https://www.mineadmin.com
8+
* @document https://doc.mineadmin.com
9+
* @contact [email protected]
10+
* @license https://github.com/mineadmin/MineAdmin/blob/master/LICENSE
11+
*/
12+
13+
namespace Mine\Support;
14+
15+
/**
16+
* Symfony IP Utils.
17+
* @see https://github.com/symfony/symfony/blob/7.4/src/Symfony/Component/HttpFoundation/IpUtils.php
18+
*/
19+
class IpUtils
20+
{
21+
public const PRIVATE_SUBNETS = [
22+
'127.0.0.0/8', // RFC1700 (Loopback)
23+
'10.0.0.0/8', // RFC1918
24+
'192.168.0.0/16', // RFC1918
25+
'172.16.0.0/12', // RFC1918
26+
'169.254.0.0/16', // RFC3927
27+
'0.0.0.0/8', // RFC5735
28+
'240.0.0.0/4', // RFC1112
29+
'::1/128', // Loopback
30+
'fc00::/7', // Unique Local Address
31+
'fe80::/10', // Link Local Address
32+
'::ffff:0:0/96', // IPv4 translations
33+
'::/128', // Unspecified address
34+
];
35+
36+
private static array $checkedIps = [];
37+
38+
/**
39+
* This class should not be instantiated.
40+
*/
41+
private function __construct() {}
42+
43+
/**
44+
* Checks if an IPv4 or IPv6 address is contained in the list of given IPs or subnets.
45+
*
46+
* @param array|string $ips List of IPs or subnets (can be a string if only a single one)
47+
*/
48+
public static function checkIp(string $requestIp, array|string $ips): bool
49+
{
50+
if (! \is_array($ips)) {
51+
$ips = [$ips];
52+
}
53+
54+
$method = mb_substr_count($requestIp, ':') > 1 ? 'checkIp6' : 'checkIp4';
55+
56+
foreach ($ips as $ip) {
57+
if (self::$method($requestIp, $ip)) {
58+
return true;
59+
}
60+
}
61+
62+
return false;
63+
}
64+
65+
/**
66+
* Compares two IPv4 addresses.
67+
* In case a subnet is given, it checks if it contains the request IP.
68+
*
69+
* @param string $ip IPv4 address or subnet in CIDR notation
70+
*
71+
* @return bool Whether the request IP matches the IP, or whether the request IP is within the CIDR subnet
72+
*/
73+
public static function checkIp4(string $requestIp, string $ip): bool
74+
{
75+
$cacheKey = $requestIp . '-' . $ip . '-v4';
76+
if (null !== $cacheValue = self::getCacheResult($cacheKey)) {
77+
return $cacheValue;
78+
}
79+
80+
if (! filter_var($requestIp, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4)) {
81+
return self::setCacheResult($cacheKey, false);
82+
}
83+
84+
if (str_contains($ip, '/')) {
85+
[$address, $netmask] = explode('/', $ip, 2);
86+
87+
if ($netmask === '0') {
88+
return self::setCacheResult($cacheKey, filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4) !== false);
89+
}
90+
91+
if ($netmask < 0 || $netmask > 32) {
92+
return self::setCacheResult($cacheKey, false);
93+
}
94+
} else {
95+
$address = $ip;
96+
$netmask = 32;
97+
}
98+
99+
if (ip2long($address) === false) {
100+
return self::setCacheResult($cacheKey, false);
101+
}
102+
103+
return self::setCacheResult($cacheKey, substr_compare(\sprintf('%032b', ip2long($requestIp)), \sprintf('%032b', ip2long($address)), 0, $netmask) === 0);
104+
}
105+
106+
/**
107+
* Compares two IPv6 addresses.
108+
* In case a subnet is given, it checks if it contains the request IP.
109+
*
110+
* @see https://github.com/dsp/v6tools
111+
*
112+
* @param string $ip IPv6 address or subnet in CIDR notation
113+
*
114+
* @throws \RuntimeException When IPV6 support is not enabled
115+
*/
116+
public static function checkIp6(string $requestIp, string $ip): bool
117+
{
118+
$cacheKey = $requestIp . '-' . $ip . '-v6';
119+
if (null !== $cacheValue = self::getCacheResult($cacheKey)) {
120+
return $cacheValue;
121+
}
122+
123+
if (! ((\extension_loaded('sockets') && \defined('AF_INET6')) || @inet_pton('::1'))) {
124+
throw new \RuntimeException('Unable to check Ipv6. Check that PHP was not compiled with option "disable-ipv6".');
125+
}
126+
127+
// Check to see if we were given a IP4 $requestIp or $ip by mistake
128+
if (! filter_var($requestIp, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
129+
return self::setCacheResult($cacheKey, false);
130+
}
131+
132+
if (str_contains($ip, '/')) {
133+
[$address, $netmask] = explode('/', $ip, 2);
134+
135+
if (! filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
136+
return self::setCacheResult($cacheKey, false);
137+
}
138+
139+
if ($netmask === '0') {
140+
return (bool) unpack('n*', @inet_pton($address));
141+
}
142+
143+
if ($netmask < 1 || $netmask > 128) {
144+
return self::setCacheResult($cacheKey, false);
145+
}
146+
} else {
147+
if (! filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
148+
return self::setCacheResult($cacheKey, false);
149+
}
150+
151+
$address = $ip;
152+
$netmask = 128;
153+
}
154+
155+
$bytesAddr = unpack('n*', @inet_pton($address));
156+
$bytesTest = unpack('n*', @inet_pton($requestIp));
157+
158+
if (! $bytesAddr || ! $bytesTest) {
159+
return self::setCacheResult($cacheKey, false);
160+
}
161+
162+
for ($i = 1, $ceil = ceil($netmask / 16); $i <= $ceil; ++$i) {
163+
$left = $netmask - 16 * ($i - 1);
164+
$left = ($left <= 16) ? $left : 16;
165+
$mask = ~(0xFFFF >> $left) & 0xFFFF;
166+
if (($bytesAddr[$i] & $mask) !== ($bytesTest[$i] & $mask)) {
167+
return self::setCacheResult($cacheKey, false);
168+
}
169+
}
170+
171+
return self::setCacheResult($cacheKey, true);
172+
}
173+
174+
/**
175+
* Anonymizes an IP/IPv6.
176+
*
177+
* Removes the last bytes of IPv4 and IPv6 addresses (1 byte for IPv4 and 8 bytes for IPv6 by default).
178+
*/
179+
public static function anonymize(string $ip/* , int $v4Bytes = 1, int $v6Bytes = 8 */): string
180+
{
181+
$v4Bytes = 1 < \func_num_args() ? func_get_arg(1) : 1;
182+
$v6Bytes = 2 < \func_num_args() ? func_get_arg(2) : 8;
183+
184+
if ($v4Bytes < 0 || $v6Bytes < 0) {
185+
throw new \InvalidArgumentException('Cannot anonymize less than 0 bytes.');
186+
}
187+
188+
if ($v4Bytes > 4 || $v6Bytes > 16) {
189+
throw new \InvalidArgumentException('Cannot anonymize more than 4 bytes for IPv4 and 16 bytes for IPv6.');
190+
}
191+
192+
/*
193+
* If the IP contains a % symbol, then it is a local-link address with scoping according to RFC 4007
194+
* In that case, we only care about the part before the % symbol, as the following functions, can only work with
195+
* the IP address itself. As the scope can leak information (containing interface name), we do not want to
196+
* include it in our anonymized IP data.
197+
*/
198+
if (str_contains($ip, '%')) {
199+
$ip = mb_substr($ip, 0, mb_strpos($ip, '%'));
200+
}
201+
202+
$wrappedIPv6 = false;
203+
if (str_starts_with($ip, '[') && str_ends_with($ip, ']')) {
204+
$wrappedIPv6 = true;
205+
$ip = mb_substr($ip, 1, -1);
206+
}
207+
208+
$mappedIpV4MaskGenerator = static function (string $mask, int $bytesToAnonymize) {
209+
$mask .= str_repeat('ff', 4 - $bytesToAnonymize);
210+
$mask .= str_repeat('00', $bytesToAnonymize);
211+
212+
return '::' . implode(':', mb_str_split($mask, 4));
213+
};
214+
215+
$packedAddress = inet_pton($ip);
216+
if (mb_strlen($packedAddress) === 4) {
217+
$mask = rtrim(str_repeat('255.', 4 - $v4Bytes) . str_repeat('0.', $v4Bytes), '.');
218+
} elseif ($ip === inet_ntop($packedAddress & inet_pton('::ffff:ffff:ffff'))) {
219+
$mask = $mappedIpV4MaskGenerator('ffff', $v4Bytes);
220+
} elseif ($ip === inet_ntop($packedAddress & inet_pton('::ffff:ffff'))) {
221+
$mask = $mappedIpV4MaskGenerator('', $v4Bytes);
222+
} else {
223+
$mask = str_repeat('ff', 16 - $v6Bytes) . str_repeat('00', $v6Bytes);
224+
$mask = implode(':', mb_str_split($mask, 4));
225+
}
226+
$ip = inet_ntop($packedAddress & inet_pton($mask));
227+
228+
if ($wrappedIPv6) {
229+
$ip = '[' . $ip . ']';
230+
}
231+
232+
return $ip;
233+
}
234+
235+
/**
236+
* Checks if an IPv4 or IPv6 address is contained in the list of private IP subnets.
237+
*/
238+
public static function isPrivateIp(string $requestIp): bool
239+
{
240+
return self::checkIp($requestIp, self::PRIVATE_SUBNETS);
241+
}
242+
243+
private static function getCacheResult(string $cacheKey): ?bool
244+
{
245+
if (isset(self::$checkedIps[$cacheKey])) {
246+
// Move the item last in cache (LRU)
247+
$value = self::$checkedIps[$cacheKey];
248+
unset(self::$checkedIps[$cacheKey]);
249+
self::$checkedIps[$cacheKey] = $value;
250+
251+
return self::$checkedIps[$cacheKey];
252+
}
253+
254+
return null;
255+
}
256+
257+
private static function setCacheResult(string $cacheKey, bool $result): bool
258+
{
259+
if (1000 < \count(self::$checkedIps)) {
260+
// stop memory leak if there are many keys
261+
self::$checkedIps = \array_slice(self::$checkedIps, 500, null, true);
262+
}
263+
264+
return self::$checkedIps[$cacheKey] = $result;
265+
}
266+
}

Request/ClientIpRequestConstant.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,11 @@ final class ClientIpRequestConstant
3434
];
3535

3636
public const TRUSTED_HEADERS = [
37-
self::HEADER_FORWARDED => 'FORWARDED',
38-
self::HEADER_X_FORWARDED_FOR => 'X_FORWARDED_FOR',
39-
self::HEADER_X_FORWARDED_HOST => 'X_FORWARDED_HOST',
40-
self::HEADER_X_FORWARDED_PROTO => 'X_FORWARDED_PROTO',
41-
self::HEADER_X_FORWARDED_PORT => 'X_FORWARDED_PORT',
42-
self::HEADER_X_FORWARDED_PREFIX => 'X_FORWARDED_PREFIX',
37+
self::HEADER_FORWARDED => 'HTTP_FORWARDED',
38+
self::HEADER_X_FORWARDED_FOR => 'HTTP_X_FORWARDED_FOR',
39+
self::HEADER_X_FORWARDED_HOST => 'HTTP_X_FORWARDED_HOST',
40+
self::HEADER_X_FORWARDED_PROTO => 'HTTP_X_FORWARDED_PROTO',
41+
self::HEADER_X_FORWARDED_PORT => 'HTTP_X_FORWARDED_PORT',
42+
self::HEADER_X_FORWARDED_PREFIX => 'HTTP_X_FORWARDED_PREFIX',
4343
];
4444
}

Request/ClientIpRequestTrait.php

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,36 @@ trait ClientIpRequestTrait
3232

3333
private bool $isForwardedValid = true;
3434

35+
private static bool $isTrustedRemoteAddr = false;
36+
37+
public static function isTrustedRemoteAddr(): bool
38+
{
39+
return self::$isTrustedRemoteAddr;
40+
}
41+
42+
public static function resetTrustedRemoteAddr(): void
43+
{
44+
self::$isTrustedRemoteAddr = false;
45+
}
46+
47+
/**
48+
* Sets the trusted proxies.
49+
*/
50+
public static function setTrustedProxies(array $proxies, int $trustedHeaderSet): void
51+
{
52+
if (false !== $i = array_search('REMOTE_ADDR', $proxies, true)) {
53+
self::$isTrustedRemoteAddr = true;
54+
}
55+
56+
if (false !== ($i = array_search('PRIVATE_SUBNETS', $proxies, true)) || false !== ($i = array_search('private_ranges', $proxies, true))) {
57+
unset($proxies[$i]);
58+
$proxies = array_merge($proxies, IpUtils::PRIVATE_SUBNETS);
59+
}
60+
61+
self::$trustedProxies = $proxies;
62+
self::$trustedHeaderSet = $trustedHeaderSet;
63+
}
64+
3565
/**
3666
* Returns the client IP addresses.
3767
*
@@ -45,7 +75,7 @@ trait ClientIpRequestTrait
4575
*/
4676
public function getClientIps(): array
4777
{
48-
$ip = $this->server('remote_addr');
78+
$ip = $this->server('REMOTE_ADDR');
4979

5080
if (! $this->isFromTrustedProxy()) {
5181
return [$ip];
@@ -62,7 +92,9 @@ public function getClientIps(): array
6292
*/
6393
public function isFromTrustedProxy(): bool
6494
{
65-
return self::$trustedProxies && IpUtils::checkIp($this->server('REMOTE_ADDR', ''), self::$trustedProxies);
95+
return (self::$trustedProxies
96+
&& IpUtils::checkIp($this->server('REMOTE_ADDR', ''), self::$trustedProxies))
97+
|| self::isTrustedRemoteAddr();
6698
}
6799

68100
/**
@@ -72,8 +104,8 @@ public function isFromTrustedProxy(): bool
72104
*/
73105
private function getTrustedValues(int $type, ?string $ip = null): array
74106
{
75-
$cacheKey = $type . "\0" . ((self::$trustedHeaderSet & $type) ? $this->header(ClientIpRequestConstant::TRUSTED_HEADERS[$type]) : '');
76-
$cacheKey .= "\0" . $ip . "\0" . $this->header(ClientIpRequestConstant::TRUSTED_HEADERS[ClientIpRequestConstant::HEADER_FORWARDED]);
107+
$cacheKey = $type . "\0" . ((self::$trustedHeaderSet & $type) ? $this->getHeaderLine(ClientIpRequestConstant::TRUSTED_HEADERS[$type]) : '');
108+
$cacheKey .= "\0" . $ip . "\0" . $this->getHeaderLine(ClientIpRequestConstant::TRUSTED_HEADERS[ClientIpRequestConstant::HEADER_FORWARDED]);
77109

78110
if (isset($this->trustedValuesCache[$cacheKey])) {
79111
return $this->trustedValuesCache[$cacheKey];
@@ -83,13 +115,13 @@ private function getTrustedValues(int $type, ?string $ip = null): array
83115
$forwardedValues = [];
84116

85117
if ((self::$trustedHeaderSet & $type) && $this->hasHeader(ClientIpRequestConstant::TRUSTED_HEADERS[$type])) {
86-
foreach (explode(',', $this->header(ClientIpRequestConstant::TRUSTED_HEADERS[$type])) as $v) {
118+
foreach (explode(',', $this->getHeaderLine(ClientIpRequestConstant::TRUSTED_HEADERS[$type])) as $v) {
87119
$clientValues[] = ($type === ClientIpRequestConstant::HEADER_X_FORWARDED_PORT ? '0.0.0.0:' : '') . trim($v);
88120
}
89121
}
90122

91123
if ((self::$trustedHeaderSet & ClientIpRequestConstant::HEADER_FORWARDED) && (isset(ClientIpRequestConstant::FORWARDED_PARAMS[$type])) && $this->hasHeader(ClientIpRequestConstant::TRUSTED_HEADERS[ClientIpRequestConstant::HEADER_FORWARDED])) {
92-
$forwarded = $this->header(ClientIpRequestConstant::TRUSTED_HEADERS[ClientIpRequestConstant::HEADER_FORWARDED]);
124+
$forwarded = $this->getHeaderLine(ClientIpRequestConstant::TRUSTED_HEADERS[ClientIpRequestConstant::HEADER_FORWARDED]);
93125
$parts = HeaderUtils::split($forwarded, ',;=');
94126
$param = ClientIpRequestConstant::FORWARDED_PARAMS[$type];
95127
foreach ($parts as $subParts) {
@@ -123,8 +155,7 @@ private function getTrustedValues(int $type, ?string $ip = null): array
123155
return $this->trustedValuesCache[$cacheKey] = $ip !== null ? ['0.0.0.0', $ip] : [];
124156
}
125157
$this->isForwardedValid = false;
126-
127-
throw new \RuntimeException(\sprintf('The request has both a trusted "%s" header and a trusted "%s" header, conflicting with each other. You should either configure your proxy to remove one of them, or configure your project to distrust the offending one.', ClientIpRequestConstant::TRUSTED_HEADERS[ClientIpRequestConstant::HEADER_FORWARDED], ClientIpRequestConstant::TRUSTED_HEADERS[$type]));
158+
throw new \RuntimeException('The Forwarded header is invalid. Please check your server configuration.');
128159
}
129160

130161
private function normalizeAndFilterClientIps(array $clientIps, string $ip): array

0 commit comments

Comments
 (0)