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 $ips; } /** * Perform the request against the addresses validate_public_url() approved. * * CURLOPT_RESOLVE pre-seeds cURL's name cache, so the connection goes to a * checked address while the hostname — and therefore SNI and certificate * validation — stays intact. Without it the transport performs its own * lookup and a 0-TTL record can answer differently the second time. * * The pin only applies to the cURL transport. On a site whose HTTP requests * go through the PHP streams fallback the request still runs, with the * pre-flight check alone — the behaviour before this change — rather than * failing closed on an install that simply lacks cURL. * * @since 2.0.1 * * @param string $url URL to fetch. * @param array $args wp_safe_remote_get() arguments. * @param string[] $ips Validated addresses for the URL's host. * @return array|WP_Error Response array on success, WP_Error otherwise. */ private static function request_pinned(string $url, array $args, array $ips) { $parts = wp_parse_url($url); $host = trim((string) ($parts['host'] ?? ''), '[]'); if ('' === $host || empty($ips)) { return wp_safe_remote_get($url, $args); } $port = isset($parts['port']) ? (int) $parts['port'] : ('https' === strtolower((string) ($parts['scheme'] ?? '')) ? 443 : 80); // One entry per host:port, listing every validated address — pinning a // single one would turn a multi-A-record host into a single point of // failure, and they have all passed the same check. $resolve = sprintf('%s:%d:%s', $host, $port, implode(',', $ips)); $pin = static function ($handle) use ($resolve): void { if (!defined('CURLOPT_RESOLVE')) { return; } // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- pinning the connection to an address the block list already approved; there is no WP_Http equivalent. curl_setopt($handle, CURLOPT_RESOLVE, [$resolve]); }; add_action('http_api_curl', $pin, 10, 1); try { return wp_safe_remote_get($url, $args); } finally { remove_action('http_api_curl', $pin, 10); } } /** * 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. * * Each hop is then *pinned* to the addresses its check approved, via * CURLOPT_RESOLVE. Per-hop revalidation alone closes the rebinding window * across hops but not the one inside a single hop, between resolving the * host and connecting to it — the transport resolved the name a second * time, and a 0-TTL record could answer differently (#405). * * @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]); } $ips = self::validated_ips($url); if (is_wp_error($ips)) { return $ips; } $response = self::request_pinned($url, array_merge($args, ['redirection' => 0]), $ips); 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; } }