1<?php 2 3/** 4 * DokuWiki IP address and reverse proxy functions. 5 * 6 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 7 * @author Zebra North <mrzebra@mrzebra.co.uk> 8 */ 9 10namespace dokuwiki; 11 12use dokuwiki\Input\Input; 13use dokuwiki\Ip32; 14use Exception; 15 16class Ip 17{ 18 /** 19 * Determine whether an IP address is within a given CIDR range. 20 * The needle and haystack may be either IPv4 or IPv6. 21 * 22 * Example: 23 * 24 * ipInRange('192.168.11.123', '192.168.0.0/16') === true 25 * ipInRange('192.168.11.123', '::192.168.0.0/80') === true 26 * ipInRange('::192.168.11.123', '192.168.0.0/16') === true 27 * ipInRange('::192.168.11.123', '::192.168.0.0/80') === true 28 * 29 * @param string $needle The IP to test, either IPv4 in dotted decimal 30 * notation or IPv6 in colon notation. 31 * @param string $haystack The CIDR range as an IP followed by a forward 32 * slash and the number of significant bits. 33 * 34 * @return bool Returns true if $needle is within the range specified 35 * by $haystack, false if it is outside the range. 36 * 37 * @throws Exception Thrown if $needle is not a valid IP address. 38 * @throws Exception Thrown if $haystack is not a valid IP range. 39 */ 40 public static function ipInRange(string $needle, string $haystack): bool 41 { 42 $range = explode('/', $haystack); 43 $networkIp = Ip::ipToNumber($range[0]); 44 45 // The mask length must be a non-negative integer. 46 if (!isset($range[1]) || !ctype_digit($range[1])) { 47 throw new Exception('Invalid IP range mask: ' . $haystack); 48 } 49 $maskLength = (int) $range[1]; 50 51 // For an IPv4 address the top 96 bits must be zero. 52 if ($networkIp['version'] === 4) { 53 $maskLength += 96; 54 } 55 56 if ($maskLength > 128) { 57 throw new Exception('Invalid IP range mask: ' . $haystack); 58 } 59 60 61 $needle = Ip::ipToNumber($needle); 62 63 $maskLengthUpper = min($maskLength, 64); 64 $maskLengthLower = max(0, $maskLength - 64); 65 66 if (PHP_INT_SIZE == 4) { 67 $needle_up = Ip32::bitmask64On32($needle['upper'], $maskLengthUpper); 68 $net_up = Ip32::bitmask64On32($networkIp['upper'], $maskLengthUpper); 69 $needle_lo = Ip32::bitmask64On32($needle['lower'], $maskLengthLower); 70 $net_lo = Ip32::bitmask64On32($networkIp['lower'], $maskLengthLower); 71 } else { 72 $maskUpper = ~0 << intval(64 - $maskLengthUpper); 73 $maskLower = ~0 << intval(64 - $maskLengthLower); 74 75 $needle_up = $needle['upper'] & $maskUpper; 76 $net_up = $networkIp['upper'] & $maskUpper; 77 $needle_lo = $needle['lower'] & $maskLower; 78 $net_lo = $networkIp['lower'] & $maskLower; 79 } 80 81 return $needle_up === $net_up && $needle_lo === $net_lo; 82 } 83 84 /** 85 * Convert an IP address from a string to a number. 86 * 87 * This splits 128 bit IP addresses into the upper and lower 64 bits, and 88 * also returns whether the IP given was IPv4 or IPv6. 89 * 90 * The returned array contains: 91 * 92 * - version: Either '4' or '6'. 93 * - upper: The upper 64 bits of the IP. 94 * - lower: The lower 64 bits of the IP. 95 * 96 * For an IPv4 address, 'upper' will always be zero. 97 * 98 * @param string $ip The IPv4 or IPv6 address. 99 * 100 * @return int[] Returns an array of 'version', 'upper', 'lower'. 101 * 102 * @throws Exception Thrown if the IP is not valid. 103 */ 104 public static function ipToNumber(string $ip): array 105 { 106 $binary = inet_pton($ip); 107 108 if ($binary === false) { 109 throw new Exception('Invalid IP: ' . $ip); 110 } 111 112 if (strlen($binary) === 4) { 113 // IPv4. 114 $ipNum = unpack('Nip', $binary)['ip']; 115 if (PHP_INT_SIZE == 4) { 116 // integer overlfow on 32bit: negative even though 'N'=unsigned 117 $ipNum = ($ipNum < 0) ? bcadd($ipNum, Ip32::$b32) : (string)$ipNum; 118 } 119 return [ 120 'version' => 4, 121 'upper' => 0, 122 'lower' => $ipNum, 123 ]; 124 } else { 125 // IPv6. strlen==16 126 if (PHP_INT_SIZE == 4) { // 32-bit 127 $result = Ip32::ipv6UpperLowerOn32($binary); 128 } else { // 64-bit arch 129 $result = unpack('Jupper/Jlower', $binary); 130 } 131 $result['version'] = 6; 132 return $result; 133 } 134 } 135 136 /** 137 * Determine if an IP address is equal to another IP or within an IP range. 138 * IPv4 and IPv6 are supported. 139 * 140 * @param string $ip The address to test. 141 * @param string $ipOrRange An IP address or CIDR range. 142 * 143 * @return bool Returns true if the IP matches, false if not. 144 */ 145 public static function ipMatches(string $ip, string $ipOrRange): bool 146 { 147 try { 148 // If it's not a range, compare the addresses directly. 149 // Addresses are converted to numbers because the same address may be 150 // represented by different strings, e.g. "::1" and "::0001". 151 if (!str_contains($ipOrRange, '/')) { 152 return Ip::ipToNumber($ip) === Ip::ipToNumber($ipOrRange); 153 } 154 155 return Ip::ipInRange($ip, $ipOrRange); 156 } catch (\Throwable) { 157 // The IP address or range was invalid. 158 return false; 159 } 160 } 161 162 /** 163 * Given the IP address of a proxy server, determine whether it is 164 * a known and trusted server. 165 * 166 * This test is performed using the config value `trustedproxies`. 167 * 168 * @param string $ip The IP address of the proxy. 169 * 170 * @return bool Returns true if the IP is trusted as a proxy. 171 */ 172 public static function proxyIsTrusted(string $ip): bool 173 { 174 global $conf; 175 176 // If the configuration is empty then no proxies are trusted. 177 if (empty($conf['trustedproxies'])) { 178 return false; 179 } 180 181 foreach ((array)$conf['trustedproxies'] as $trusted) { 182 if (Ip::ipMatches($ip, $trusted)) { 183 return true; // The given IP matches one of the trusted proxies. 184 } 185 } 186 187 return false; // none of the proxies matched 188 } 189 190 /** 191 * Get the originating IP address and the address of every proxy that the 192 * request has passed through, according to the X-Forwarded-For header. 193 * 194 * To prevent spoofing of the client IP, every proxy listed in the 195 * X-Forwarded-For header must be trusted, as well as the TCP/IP endpoint 196 * from which the connection was received (i.e. the final proxy). 197 * 198 * If the header is not present or contains an untrusted proxy then 199 * an empty array is returned. 200 * 201 * The client IP is the first entry in the returned list, followed by the 202 * proxies. 203 * 204 * @return string[] Returns an array of IP addresses. 205 */ 206 public static function forwardedFor(): array 207 { 208 /* @var Input $INPUT */ 209 global $INPUT, $conf; 210 211 $forwardedFor = $INPUT->server->str('HTTP_X_FORWARDED_FOR'); 212 213 if (empty($conf['trustedproxies']) || !$forwardedFor) { 214 return []; 215 } 216 217 // This is the address from which the header was received. 218 $remoteAddr = $INPUT->server->str('REMOTE_ADDR'); 219 220 // Get the client address from the X-Forwarded-For header. 221 // X-Forwarded-For: <client> [, <proxy>]... 222 $forwardedFor = explode(',', str_replace(' ', '', $forwardedFor)); 223 224 // The client address is the first item, remove it from the list. 225 $clientAddress = array_shift($forwardedFor); 226 227 // The remaining items are the proxies through which the X-Forwarded-For 228 // header has passed. The final proxy is the connection's remote address. 229 $proxies = $forwardedFor; 230 $proxies[] = $remoteAddr; 231 232 // Ensure that every proxy is trusted. 233 foreach ($proxies as $proxy) { 234 if (!Ip::proxyIsTrusted($proxy)) { 235 return []; 236 } 237 } 238 239 // Add the client address before the list of proxies. 240 return array_merge([$clientAddress], $proxies); 241 } 242 243 /** 244 * Return the IP of the client. 245 * 246 * The IP is sourced from, in order of preference: 247 * 248 * - The custom IP header if $conf[client_ip_header] is set. 249 * - The X-Forwarded-For header if all the proxies are trusted by $conf[trustedproxy]. 250 * - The TCP/IP connection remote address. 251 * - 0.0.0.0 if all else fails. 252 * 253 * The 'client_ip_header' config value should only be set if the header 254 * is being added by the web server, otherwise it may be spoofed by the client. 255 * 256 * The 'trustedproxy' setting must not allow any IP, otherwise the X-Forwarded-For 257 * may be spoofed by the client. 258 * 259 * @return string Returns an IPv4 or IPv6 address. 260 */ 261 public static function clientIp(): string 262 { 263 return Ip::clientIps()[0]; 264 } 265 266 /** 267 * Return the IP of the client and the proxies through which the connection has passed. 268 * 269 * The IPs are sourced from, in order of preference: 270 * 271 * - The custom IP header if $conf[client_ip_header] is set. 272 * - The X-Forwarded-For header if all the proxies are trusted by $conf[trustedproxies]. 273 * - The TCP/IP connection remote address. 274 * - 0.0.0.0 if all else fails. 275 * 276 * @return string[] Returns an array of IPv4 or IPv6 addresses. 277 */ 278 public static function clientIps(): array 279 { 280 /* @var Input $INPUT */ 281 global $INPUT, $conf; 282 283 // IPs in order of most to least preferred. 284 $ips = []; 285 286 // Use a custom IP header (e.g. CDN) if it is set by the configuration. 287 if (!empty($conf['client_ip_header']) && $INPUT->server->str('HTTP_' . $conf['client_ip_header'])) { 288 $ips[] = $INPUT->server->str('HTTP_' . $conf['client_ip_header']); 289 } 290 291 // Add the X-Forwarded-For addresses if all proxies are trusted. 292 $ips = array_merge($ips, Ip::forwardedFor()); 293 294 // Add the TCP/IP connection endpoint. 295 $ips[] = $INPUT->server->str('REMOTE_ADDR'); 296 297 // Remove invalid IPs. 298 $ips = array_filter($ips, static fn($ip) => filter_var($ip, FILTER_VALIDATE_IP)); 299 300 // Remove duplicated IPs. 301 $ips = array_values(array_unique($ips)); 302 303 // Add a fallback if for some reason there were no valid IPs. 304 if (!$ips) { 305 $ips[] = '0.0.0.0'; 306 } 307 308 return $ips; 309 } 310 311 /** 312 * Get the host name of the server. 313 * 314 * The host name is sourced from, in order of preference: 315 * 316 * - The X-Forwarded-Host header if it exists and the proxies are trusted. 317 * - The HTTP_HOST header. 318 * - The SERVER_NAME header. 319 * - The system's host name. 320 * 321 * @return string Returns the host name of the server. 322 */ 323 public static function hostName(): string 324 { 325 /* @var Input $INPUT */ 326 global $INPUT; 327 328 $remoteAddr = $INPUT->server->str('REMOTE_ADDR'); 329 if ($INPUT->server->str('HTTP_X_FORWARDED_HOST') && self::proxyIsTrusted($remoteAddr)) { 330 return $INPUT->server->str('HTTP_X_FORWARDED_HOST'); 331 } elseif ($INPUT->server->str('HTTP_HOST')) { 332 return $INPUT->server->str('HTTP_HOST'); 333 } elseif ($INPUT->server->str('SERVER_NAME')) { 334 return $INPUT->server->str('SERVER_NAME'); 335 } else { 336 return php_uname('n'); 337 } 338 } 339 340 /** 341 * Is the connection using the HTTPS protocol? 342 * 343 * Will use the X-Forwarded-Proto header if it exists and the proxies are trusted, otherwise 344 * the HTTPS environment is used. 345 * 346 * @return bool 347 */ 348 public static function isSsl(): bool 349 { 350 /* @var Input $INPUT */ 351 global $INPUT; 352 353 $remoteAddr = $INPUT->server->str('REMOTE_ADDR'); 354 if ($INPUT->server->has('HTTP_X_FORWARDED_PROTO') && self::proxyIsTrusted($remoteAddr)) { 355 return $INPUT->server->str('HTTP_X_FORWARDED_PROTO') === 'https'; 356 } 357 return !preg_match('/^(|off|false|disabled)$/i', $INPUT->server->str('HTTPS', 'off')); 358 } 359} 360