| @@ -140,8 +140,28 @@ | ||
| 140 | 140 | * @param string $url URL to validate. |
| 141 | 141 | * @return true|WP_Error True when safe to fetch, WP_Error otherwise. |
| 142 | 142 | */ |
| 143 | 143 | public static function validate_public_url(string $url) { |
| 144 | + $ips = self::validated_ips($url); | |
| 145 | + | |
| 146 | + return is_wp_error($ips) ? $ips : true; | |
| 147 | + } | |
| 148 | + | |
| 149 | + /** | |
| 150 | + * The addresses a URL's host resolves to, once every one has been checked | |
| 151 | + * against the block list. | |
| 152 | + * | |
| 153 | + * Returned rather than discarded so the fetch can be pinned to them: | |
| 154 | + * handing the *hostname* to the HTTP transport lets it resolve a second | |
| 155 | + * time, and a host answering a public address on this lookup and a private | |
| 156 | + * one on the fetch walks straight past the block list (#405). | |
| 157 | + * | |
| 158 | + * @since 2.0.1 | |
| 159 | + * | |
| 160 | + * @param string $url URL to validate. | |
| 161 | + * @return string[]|WP_Error Validated IPs, or the reason the URL is refused. | |
| 162 | + */ | |
| 163 | + private static function validated_ips(string $url) { | |
| 144 | 164 | $parts = wp_parse_url($url); |
| 145 | 165 | |
| 146 | 166 | if (empty($parts['scheme']) || empty($parts['host'])) { |
| 147 | 167 | return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]); |
| @@ -164,12 +184,67 @@ | ||
| 164 | 184 | return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]); |
| 165 | 185 | } |
| 166 | 186 | } |
| 167 | 187 | |
| 168 | - return true; | |
| 188 | + return $ips; | |
| 169 | 189 | } |
| 170 | 190 | |
| 171 | 191 | /** |
| 192 | + * Perform the request against the addresses validate_public_url() approved. | |
| 193 | + * | |
| 194 | + * CURLOPT_RESOLVE pre-seeds cURL's name cache, so the connection goes to a | |
| 195 | + * checked address while the hostname — and therefore SNI and certificate | |
| 196 | + * validation — stays intact. Without it the transport performs its own | |
| 197 | + * lookup and a 0-TTL record can answer differently the second time. | |
| 198 | + * | |
| 199 | + * The pin only applies to the cURL transport. On a site whose HTTP requests | |
| 200 | + * go through the PHP streams fallback the request still runs, with the | |
| 201 | + * pre-flight check alone — the behaviour before this change — rather than | |
| 202 | + * failing closed on an install that simply lacks cURL. | |
| 203 | + * | |
| 204 | + * @since 2.0.1 | |
| 205 | + * | |
| 206 | + * @param string $url URL to fetch. | |
| 207 | + * @param array $args wp_safe_remote_get() arguments. | |
| 208 | + * @param string[] $ips Validated addresses for the URL's host. | |
| 209 | + * @return array|WP_Error Response array on success, WP_Error otherwise. | |
| 210 | + */ | |
| 211 | + private static function request_pinned(string $url, array $args, array $ips) { | |
| 212 | + $parts = wp_parse_url($url); | |
| 213 | + $host = trim((string) ($parts['host'] ?? ''), '[]'); | |
| 214 | + | |
| 215 | + if ('' === $host || empty($ips)) { | |
| 216 | + return wp_safe_remote_get($url, $args); | |
| 217 | + } | |
| 218 | + | |
| 219 | + $port = isset($parts['port']) | |
| 220 | + ? (int) $parts['port'] | |
| 221 | + : ('https' === strtolower((string) ($parts['scheme'] ?? '')) ? 443 : 80); | |
| 222 | + | |
| 223 | + // One entry per host:port, listing every validated address — pinning a | |
| 224 | + // single one would turn a multi-A-record host into a single point of | |
| 225 | + // failure, and they have all passed the same check. | |
| 226 | + $resolve = sprintf('%s:%d:%s', $host, $port, implode(',', $ips)); | |
| 227 | + | |
| 228 | + $pin = static function ($handle) use ($resolve): void { | |
| 229 | + if (!defined('CURLOPT_RESOLVE')) { | |
| 230 | + return; | |
| 231 | + } | |
| 232 | + | |
| 233 | + // 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. | |
| 234 | + curl_setopt($handle, CURLOPT_RESOLVE, [$resolve]); | |
| 235 | + }; | |
| 236 | + | |
| 237 | + add_action('http_api_curl', $pin, 10, 1); | |
| 238 | + | |
| 239 | + try { | |
| 240 | + return wp_safe_remote_get($url, $args); | |
| 241 | + } finally { | |
| 242 | + remove_action('http_api_curl', $pin, 10); | |
| 243 | + } | |
| 244 | + } | |
| 245 | + | |
| 246 | + /** | |
| 172 | 247 | * Whether a URL is safe to fetch. |
| 173 | 248 | * |
| 174 | 249 | * Boolean convenience wrapper around {@see self::validate_public_url()} for |
| 175 | 250 | * call sites that only branch on safe/unsafe. |
| @@ -189,11 +264,16 @@ | ||
| 189 | 264 | * |
| 190 | 265 | * wp_safe_remote_get() re-validates redirect targets with |
| 191 | 266 | * wp_http_validate_url(), which shares the link-local/CGNAT blind spot, so |
| 192 | 267 | * redirects are followed manually (`redirection => 0`) and each hop is |
| 193 | - * checked before it is requested. That also closes the DNS-rebinding window | |
| 194 | - * a single pre-flight check would leave open across hops. | |
| 268 | + * checked before it is requested. | |
| 195 | 269 | * |
| 270 | + * Each hop is then *pinned* to the addresses its check approved, via | |
| 271 | + * CURLOPT_RESOLVE. Per-hop revalidation alone closes the rebinding window | |
| 272 | + * across hops but not the one inside a single hop, between resolving the | |
| 273 | + * host and connecting to it — the transport resolved the name a second | |
| 274 | + * time, and a 0-TTL record could answer differently (#405). | |
| 275 | + * | |
| 196 | 276 | * @since 1.29.0 |
| 197 | 277 | * |
| 198 | 278 | * @param string $url URL to fetch. |
| 199 | 279 | * @param array $args Optional. wp_safe_remote_get() arguments. |
| @@ -204,14 +284,14 @@ | ||
| 204 | 284 | if (!wp_http_validate_url($url)) { |
| 205 | 285 | return new WP_Error('invalid_url', 'The URL is not allowed.', ['status' => 400]); |
| 206 | 286 | } |
| 207 | 287 | |
| 208 | - $host_check = self::validate_public_url($url); | |
| 209 | - if (is_wp_error($host_check)) { | |
| 210 | - return $host_check; | |
| 288 | + $ips = self::validated_ips($url); | |
| 289 | + if (is_wp_error($ips)) { | |
| 290 | + return $ips; | |
| 211 | 291 | } |
| 212 | 292 | |
| 213 | - $response = wp_safe_remote_get($url, array_merge($args, ['redirection' => 0])); | |
| 293 | + $response = self::request_pinned($url, array_merge($args, ['redirection' => 0]), $ips); | |
| 214 | 294 | |
| 215 | 295 | if (is_wp_error($response)) { |
| 216 | 296 | return $response; |
| 217 | 297 | } |