400]); } if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) { return new WP_Error('invalid_url', 'Only http and https URLs can be fetched.', ['status' => 400]); } // Strip IPv6 literal brackets, if present. $host = trim($parts['host'], '[]'); $ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : self::resolve_host_ips($host); if (empty($ips)) { return new WP_Error('invalid_url', 'The URL host could not be resolved.', ['status' => 400]); } foreach ($ips as $ip) { if (!self::is_public_ip($ip)) { return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]); } } return true; } /** * Whether a URL is safe to fetch. * * Boolean convenience wrapper around {@see self::validate_public_url()} for * call sites that only branch on safe/unsafe. * * @since 1.29.0 * * @param string $url URL to check. * @return bool True when safe to fetch. */ public static function is_safe_public_url(string $url): bool { return true === self::validate_public_url($url); } /** * Fetch a URL, re-validating the host against the block list on every * redirect hop. * * wp_safe_remote_get() re-validates redirect targets with * wp_http_validate_url(), which shares the link-local/CGNAT blind spot, so * redirects are followed manually (`redirection => 0`) and each hop is * checked before it is requested. That also closes the DNS-rebinding window * a single pre-flight check would leave open across hops. * * @since 1.29.0 * * @param string $url URL to fetch. * @param array $args Optional. wp_safe_remote_get() arguments. * @return array|WP_Error Response array on success, WP_Error otherwise. */ public static function safe_remote_get(string $url, array $args = []) { for ($hop = 0; $hop <= self::MAX_REDIRECTS; $hop++) { if (!wp_http_validate_url($url)) { return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]); } $host_check = self::validate_public_url($url); if (is_wp_error($host_check)) { return $host_check; } $response = wp_safe_remote_get($url, array_merge($args, ['redirection' => 0])); if (is_wp_error($response)) { return $response; } $code = (int) wp_remote_retrieve_response_code($response); if ($code < 300 || $code >= 400) { return $response; } $location = trim((string) wp_remote_retrieve_header($response, 'location')); if ('' === $location) { return $response; // Redirect without a target — treat as final. } // Resolve a relative Location against the current URL. $url = (string) WP_Http::make_absolute_url($location, $url); if ('' === $url) { return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]); } } return new WP_Error('too_many_redirects', 'The URL redirected too many times.', ['status' => 400]); } /** * Whether an IPv4 address falls within a CIDR block. * * @param string $ip IPv4 address. * @param string $cidr CIDR block (e.g. 100.64.0.0/10). * @return bool */ private static function ipv4_in_cidr(string $ip, string $cidr): bool { if (strpos($ip, ':') !== false) { return false; // IPv6 is not covered by these IPv4 blocks. } [$subnet, $bits] = array_pad(explode('/', $cidr, 2), 2, '32'); $ip_long = ip2long($ip); $subnet_long = ip2long($subnet); if (false === $ip_long || false === $subnet_long) { return false; } $mask = -1 << (32 - (int) $bits); return ($ip_long & $mask) === ($subnet_long & $mask); } /** * Resolve a hostname to its IPv4 + IPv6 addresses. * * @param string $host Hostname. * @return string[] Resolved IP addresses (may be empty). */ private static function resolve_host_ips(string $host): array { $ips = []; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- dns_get_record() emits a warning on NXDOMAIN, which is a normal answer here, not an error. $records = @dns_get_record($host, DNS_A + DNS_AAAA); if (is_array($records)) { foreach ($records as $record) { if (!empty($record['ip'])) { $ips[] = $record['ip']; // A record. } elseif (!empty($record['ipv6'])) { $ips[] = $record['ipv6']; // AAAA record. } } } // Fallback where dns_get_record is unavailable or returns nothing. if (empty($ips)) { $resolved = gethostbyname($host); if ($resolved && $resolved !== $host) { $ips[] = $resolved; } } return $ips; } }