2048) { return self::deny('invalid_length'); } $parts = wp_parse_url($url); if (!is_array($parts)) { return self::deny('invalid_url'); } $scheme = strtolower((string) ($parts['scheme'] ?? '')); if (!in_array($scheme, ['http', 'https'], true)) { return self::deny('unsupported_scheme'); } if (isset($parts['user']) || isset($parts['pass'])) { return self::deny('credentials_not_allowed'); } $host = strtolower(trim((string) ($parts['host'] ?? ''))); if ($host === '') { return self::deny('missing_host'); } $host = trim($host, "[] \t\n\r\0\x0B."); if ($host === '' || $host === 'localhost' || substr($host, -6) === '.local') { return self::deny('local_host_blocked'); } $port = isset($parts['port']) ? (int) $parts['port'] : 0; if ($port > 0) { $allowed = $scheme === 'https' ? [443] : [80]; if (!in_array($port, $allowed, true)) { return self::deny('port_blocked'); } } $ips = self::resolve_host($host); if (empty($ips)) { return self::deny('dns_failed'); } foreach ($ips as $ip) { if (!self::is_public_ip($ip)) { return self::deny('private_or_reserved_ip'); } } return ['ok' => true, 'url' => $url, 'reason' => '']; } public static function resolve_redirect(string $base_url, string $location): string { $location = trim($location); if ($location === '') { return ''; } if (preg_match('~^https?://~i', $location)) { return $location; } $base = wp_parse_url($base_url); if (!is_array($base) || empty($base['scheme']) || empty($base['host'])) { return ''; } $scheme = strtolower((string) $base['scheme']); $host = (string) $base['host']; $port = isset($base['port']) ? ':' . (int) $base['port'] : ''; if (strpos($location, '//') === 0) { return $scheme . ':' . $location; } if ($location[0] === '/') { return $scheme . '://' . $host . $port . $location; } $path = (string) ($base['path'] ?? '/'); $dir = preg_replace('~/[^/]*$~', '/', $path); if (!is_string($dir) || $dir === '') { $dir = '/'; } return $scheme . '://' . $host . $port . $dir . $location; } /** * Resolve a hostname to BOTH IPv4 and IPv6 records. * * `gethostbynamel()` returns A-records only; if the host has both A * (public) and AAAA (loopback/private) records, validation would pass on * IPv4 while the OS resolver may pick the AAAA at request time, defeating * the SSRF guard. We resolve both record sets explicitly. * * @return array */ private static function resolve_host(string $host): array { if (filter_var($host, FILTER_VALIDATE_IP)) { return [$host]; } $ips = []; if (function_exists('dns_get_record')) { $a = @dns_get_record($host, DNS_A); $aaaa = @dns_get_record($host, DNS_AAAA); foreach ((array) $a as $rec) { if (is_array($rec) && !empty($rec['ip']) && is_string($rec['ip'])) { $ips[] = $rec['ip']; } } foreach ((array) $aaaa as $rec) { if (is_array($rec) && !empty($rec['ipv6']) && is_string($rec['ipv6'])) { $ips[] = $rec['ipv6']; } } } // Fallback only if dns_get_record is unavailable (rare). if (empty($ips) && function_exists('gethostbynamel')) { $records = gethostbynamel($host); if (is_array($records)) { foreach ($records as $rec) { if (is_string($rec)) { $ips[] = $rec; } } } } return array_values(array_unique($ips)); } private static function is_public_ip(string $ip): bool { if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return (bool) filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); } if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { return (bool) filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); } return false; } /** * @return array{ok:bool,url:string,reason:string} */ private static function deny(string $reason): array { return ['ok' => false, 'url' => '', 'reason' => $reason]; } }