$server usually $_SERVER * @param list $trustedProxies addresses or CIDR ranges of proxies the site operator controls */ public static function resolve(array $server, array $trustedProxies = []): string { $remote = self::valid($server['REMOTE_ADDR'] ?? null); if ($remote === '') { return ''; } if ($trustedProxies === [] || !self::trusted($remote, $trustedProxies)) { return $remote; } $forwarded = is_string($server['HTTP_X_FORWARDED_FOR'] ?? null) ? explode(',', (string) $server['HTTP_X_FORWARDED_FOR']) : []; for ($i = count($forwarded) - 1; $i >= 0; $i--) { $hop = self::valid(trim($forwarded[$i])); if ($hop === '') { return $remote; // a malformed entry: do not guess } if (!self::trusted($hop, $trustedProxies)) { return $hop; } } return $remote; } /** * The unit the budgets and throttles count as one client: an IPv4 address as is, an IPv6 address as its /64 network (one * subscriber normally holds a whole /64, so rotating addresses inside it must not mean a fresh allowance), an IPv4-mapped IPv6 * address as the IPv4 address. '' stays ''. */ public static function network(string $address): string { if (filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) { return $address; // IPv4, empty or not an address: unchanged } $bin = inet_pton($address); if ($bin === false || strlen($bin) !== 16) { return $address; } if (str_starts_with($bin, str_repeat("\0", 10) . "\xff\xff")) { return (string) inet_ntop(substr($bin, 12)); } return 'v6/' . bin2hex(substr($bin, 0, 8)); } /** * Which kind of client a request is, for the daily budget (Phase 89). * * unknown no usable connecting address at all. * client the address the server observed, or the visitor a DECLARED trusted proxy reports. A forwarding header from an * undeclared source never replaces or invalidates a public connecting address: a visitor cannot choose a different * allowance, or the shared one, by sending headers. * shared visitors cannot be told apart: the connecting address is a declared proxy that reported no visitor address, or a * private/loopback/reserved address (only a proxy or CDN on the operator's own network connects from one) that * carries forwarding headers although it is not declared. Nobody on the public internet can reach this by forging a header. * * @param array $server * @param list $trustedProxies * @return array{0:string, 1:string} [kind, the address to count] (the address is '' for unknown) */ public static function identify(array $server, array $trustedProxies = []): array { $remote = self::valid($server['REMOTE_ADDR'] ?? null); if ($remote === '') { return ['unknown', '']; } if ($trustedProxies !== [] && self::trusted($remote, $trustedProxies)) { $address = self::resolve($server, $trustedProxies); return $address !== $remote ? ['client', $address] : ['shared', $remote]; } if (self::forwardedWithoutTrust($server, $trustedProxies) && !self::publicAddress($remote)) { return ['shared', $remote]; } return ['client', $remote]; } private static function publicAddress(string $ip): bool { return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false; } /** * True when the request carries forwarding headers although the connecting address is not a proxy the operator declared: * a reverse proxy or CDN that is not listed (every visitor then shares its address), or a visitor forging the headers. * Either way the address cannot be trusted to tell visitors apart. * * @param array $server * @param list $trustedProxies */ public static function forwardedWithoutTrust(array $server, array $trustedProxies = []): bool { $remote = self::valid($server['REMOTE_ADDR'] ?? null); if ($remote !== '' && $trustedProxies !== [] && self::trusted($remote, $trustedProxies)) { return false; } foreach (['HTTP_X_FORWARDED_FOR', 'HTTP_FORWARDED', 'HTTP_X_REAL_IP', 'HTTP_CF_CONNECTING_IP', 'HTTP_TRUE_CLIENT_IP', 'HTTP_X_FORWARDED'] as $header) { if (is_string($server[$header] ?? null) && trim((string) $server[$header]) !== '') { return true; } } return false; } private static function valid(mixed $value): string { return is_string($value) && filter_var($value, FILTER_VALIDATE_IP) !== false ? strtolower($value) : ''; } /** @param list $list */ private static function trusted(string $ip, array $list): bool { foreach ($list as $spec) { if (is_string($spec) && self::inRange($ip, trim($spec))) { return true; } } return false; } private static function inRange(string $ip, string $spec): bool { [$base, $bits] = array_pad(explode('/', $spec, 2), 2, null); // Validate first (an operator-written range can be anything), so inet_pton never needs its warnings silenced. if (filter_var($ip, FILTER_VALIDATE_IP) === false || !is_string($base) || filter_var($base, FILTER_VALIDATE_IP) === false) { return false; } $a = inet_pton($ip); $b = inet_pton($base); if ($a === false || $b === false || strlen($a) !== strlen($b)) { return false; } $max = strlen($a) * 8; $bits = $bits === null ? $max : (ctype_digit((string) $bits) ? (int) $bits : -1); if ($bits < 0 || $bits > $max) { return false; } $full = intdiv($bits, 8); if (substr($a, 0, $full) !== substr($b, 0, $full)) { return false; } $rest = $bits % 8; if ($rest === 0) { return true; } $mask = (0xFF << (8 - $rest)) & 0xFF; return (ord($a[$full]) & $mask) === (ord($b[$full]) & $mask); } }