403] ); } $allowed = array_map('trim', explode(',', $whitelist)); foreach ($allowed as $range) { if (self::ipInRange($clientIp, $range)) { return true; } } return new WP_Error( 'SENTINELO_forbidden', __('IP address not allowed.', 'sentinelo'), ['status' => 403] ); } /** * Match an IP against a single allow-list entry. Supports both IPv4 and IPv6, * as an exact address or as CIDR notation. inet_pton() normalises each side to * its packed binary form so the comparison works identically for v4 and v6 * (the old ip2long() path silently failed for every IPv6 client). */ private static function ipInRange(string $ip, string $range): bool { $ipBin = @inet_pton($ip); if ($ipBin === false) { return false; } if (strpos($range, '/') === false) { $rangeBin = @inet_pton($range); return $rangeBin !== false && hash_equals($rangeBin, $ipBin); } [$subnet, $bits] = explode('/', $range, 2); $subnetBin = @inet_pton($subnet); if ($subnetBin === false || strlen($subnetBin) !== strlen($ipBin)) { // Malformed subnet, or mixing IPv4 and IPv6 between the client and rule. return false; } $bits = (int) $bits; $maxBits = strlen($ipBin) * 8; // 32 for IPv4, 128 for IPv6 if ($bits < 0 || $bits > $maxBits) { return false; } // Build a packed mask of $bits leading 1-bits. $mask = str_repeat("\xff", intdiv($bits, 8)); if ($bits % 8 !== 0) { $mask .= chr((0xff << (8 - ($bits % 8))) & 0xff); } $mask = str_pad($mask, strlen($ipBin), "\x00"); return ($ipBin & $mask) === ($subnetBin & $mask); } }