*/ private const BLOCKED_IPV4 = [ '0.0.0.0/8', // "this" network / unspecified '10.0.0.0/8', // RFC1918 private '100.64.0.0/10', // RFC6598 carrier-grade NAT '127.0.0.0/8', // loopback '169.254.0.0/16', // link-local (incl. cloud metadata 169.254.169.254) '172.16.0.0/12', // RFC1918 private '192.0.0.0/24', // IETF protocol assignments '192.0.2.0/24', // TEST-NET-1 '192.168.0.0/16', // RFC1918 private '198.18.0.0/15', // benchmarking '198.51.100.0/24', // TEST-NET-2 '203.0.113.0/24', // TEST-NET-3 '240.0.0.0/4', // reserved (incl. 255.255.255.255 broadcast) ]; /** * IPv6 CIDR blocks that must never be the target of a server-side request. * * IPv4-mapped addresses (::ffff:0:0/96) are handled explicitly by unwrapping * the embedded IPv4 in {@see self::ipIsBlocked()}. * * @var array */ private const BLOCKED_IPV6 = [ '::1/128', // loopback '::/128', // unspecified 'fc00::/7', // unique local address 'fe80::/10', // link-local '64:ff9b::/96', // NAT64 (embeds IPv4) '2001:db8::/32', // documentation ]; /** * Return a human-readable reason when the URL must NOT be requested, or null * when it is allowed (including the fail-open unresolvable-host case). */ public static function blockedReason(string $url): ?string { $url = trim($url); if ($url === '') { return null; } $parts = parse_url($url); if ($parts === false || ! isset($parts['scheme']) || ! isset($parts['host'])) { return 'URL must include a scheme and host'; } $scheme = strtolower($parts['scheme']); if (! in_array($scheme, ['http', 'https'], true)) { return 'URL scheme must be http or https'; } // parse_url keeps IPv6 literals wrapped in brackets; strip them. $host = trim($parts['host'], '[]'); if ($host === '') { return 'URL must include a host'; } // IP literal — check directly without DNS. if (filter_var($host, FILTER_VALIDATE_IP) !== false) { return self::ipIsBlocked($host) ? "URL host {$host} is a private or reserved address" : null; } // Hostname — resolve and check every address it points at. $ips = self::resolveHost($host); if ($ips === []) { // Unresolvable: not provably unsafe. The actual request will fail. return null; } foreach ($ips as $ip) { if (self::ipIsBlocked($ip)) { return "URL host {$host} resolves to a private or reserved address ({$ip})"; } } return null; } /** * Throw when the URL is not safe to request. Used by runtime driver guards. * * @throws BlockedUrlException */ public static function assertAllowed(string $url): void { $reason = self::blockedReason($url); if ($reason !== null) { throw new BlockedUrlException($reason); } } /** * Whether a single IP address falls inside any blocked range. */ public static function ipIsBlocked(string $ip): bool { // Normalise IPv4-mapped IPv6 (e.g. ::ffff:10.0.0.1) to its embedded IPv4. $mapped = self::extractMappedIpv4($ip); if ($mapped !== null) { return self::ipIsBlocked($mapped); } $isIpv4 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false; $blocks = $isIpv4 ? self::BLOCKED_IPV4 : self::BLOCKED_IPV6; foreach ($blocks as $cidr) { if (self::ipInCidr($ip, $cidr)) { return true; } } return false; } /** * Whether an IP (v4 or v6) is contained in the given CIDR block. */ public static function ipInCidr(string $ip, string $cidr): bool { [$subnet, $bits] = array_pad(explode('/', $cidr, 2), 2, null); $ipBin = @inet_pton($ip); $subnetBin = @inet_pton((string) $subnet); if ($ipBin === false || $subnetBin === false) { return false; } // Different address families (v4 vs v6) can never match. if (strlen($ipBin) !== strlen($subnetBin)) { return false; } $bits = (int) $bits; $wholeBytes = intdiv($bits, 8); $remainderBits = $bits % 8; if ($wholeBytes > 0 && strncmp($ipBin, $subnetBin, $wholeBytes) !== 0) { return false; } if ($remainderBits > 0) { $mask = (~((1 << (8 - $remainderBits)) - 1)) & 0xFF; if ((ord($ipBin[$wholeBytes]) & $mask) !== (ord($subnetBin[$wholeBytes]) & $mask)) { return false; } } return true; } /** * Resolve a hostname to all of its A and AAAA addresses. * * @return array */ private static function resolveHost(string $host): array { $ips = []; if (function_exists('gethostbynamel')) { $v4 = @gethostbynamel($host); if (is_array($v4)) { $ips = $v4; } } if (function_exists('dns_get_record')) { $records = @dns_get_record($host, DNS_AAAA); if (is_array($records)) { foreach ($records as $record) { if (isset($record['ipv6'])) { $ips[] = $record['ipv6']; } } } } return array_values(array_unique($ips)); } /** * Extract the embedded IPv4 from an IPv4-mapped IPv6 address (::ffff:0:0/96), * or null if the value is not such an address. */ private static function extractMappedIpv4(string $ip): ?string { $bin = @inet_pton($ip); if ($bin === false || strlen($bin) !== 16) { return null; } // ::ffff:0:0/96 = 10 zero bytes followed by 0xff 0xff. $prefix = str_repeat("\x00", 10)."\xff\xff"; if (strncmp($bin, $prefix, 12) !== 0) { return null; } $ipv4 = inet_ntop(substr($bin, 12, 4)); return $ipv4 === false ? null : $ipv4; } }