| 1 |
<?php |
| 2 |
/** |
| 3 |
* Safe access to user-controlled remote resources. |
| 4 |
* |
| 5 |
* @package Visualizer |
| 6 |
*/ |
| 7 |
|
| 8 |
/** |
| 9 |
* Centralizes the network policy for remote chart imports. |
| 10 |
*/ |
| 11 |
class Visualizer_Remote_Fetch { |
| 12 |
|
| 13 |
const MAX_DOWNLOAD_BYTES = 10485760; |
| 14 |
const MAX_REDIRECTS = 5; |
| 15 |
|
| 16 |
/** |
| 17 |
* Performs a request after validating every destination. |
| 18 |
* |
| 19 |
* @param string $url Remote URL. |
| 20 |
* @param array<string, mixed> $args Optional WordPress HTTP arguments. |
| 21 |
* @return array<string, mixed>|WP_Error |
| 22 |
*/ |
| 23 |
public static function request( $url, $args = array() ) { |
| 24 |
$args = wp_parse_args( |
| 25 |
$args, |
| 26 |
array( |
| 27 |
'method' => 'GET', |
| 28 |
'timeout' => 15, |
| 29 |
) |
| 30 |
); |
| 31 |
|
| 32 |
$redirects = isset( $args['redirection'] ) ? min( self::MAX_REDIRECTS, max( 0, (int) $args['redirection'] ) ) : self::MAX_REDIRECTS; |
| 33 |
unset( $args['redirection'] ); |
| 34 |
|
| 35 |
$args = self::enforce_request_policy( $args ); |
| 36 |
if ( is_wp_error( $args ) ) { |
| 37 |
return $args; |
| 38 |
} |
| 39 |
|
| 40 |
for ( $redirect = 0; $redirect <= $redirects; $redirect++ ) { |
| 41 |
$ips = array(); |
| 42 |
$validated_url = self::validate_url( $url, $ips ); |
| 43 |
if ( is_wp_error( $validated_url ) ) { |
| 44 |
return $validated_url; |
| 45 |
} |
| 46 |
|
| 47 |
$request_args = $args; |
| 48 |
$request_args['redirection'] = 0; |
| 49 |
$request_args['reject_unsafe_urls'] = true; |
| 50 |
|
| 51 |
$pin = self::pin_validated_addresses( $validated_url, $ips ); |
| 52 |
if ( is_wp_error( $pin ) ) { |
| 53 |
return $pin; |
| 54 |
} |
| 55 |
$response = wp_safe_remote_request( $validated_url, $request_args ); |
| 56 |
if ( $pin ) { |
| 57 |
remove_action( 'http_api_curl', $pin ); |
| 58 |
} |
| 59 |
if ( is_wp_error( $response ) ) { |
| 60 |
return $response; |
| 61 |
} |
| 62 |
|
| 63 |
$status = (int) wp_remote_retrieve_response_code( $response ); |
| 64 |
$location = wp_remote_retrieve_header( $response, 'location' ); |
| 65 |
if ( $status < 300 || $status > 399 || empty( $location ) ) { |
| 66 |
return $response; |
| 67 |
} |
| 68 |
|
| 69 |
if ( $redirect === $redirects ) { |
| 70 |
return new WP_Error( 'visualizer_too_many_redirects', 'The remote URL redirected too many times.' ); |
| 71 |
} |
| 72 |
|
| 73 |
$next_url = WP_Http::make_absolute_url( $location, $validated_url ); |
| 74 |
if ( ! self::same_origin( $validated_url, $next_url ) ) { |
| 75 |
$args['headers'] = self::headers_for_cross_origin_redirect( isset( $args['headers'] ) ? $args['headers'] : array() ); |
| 76 |
unset( $args['cookies'] ); |
| 77 |
} else { |
| 78 |
$response_cookies = wp_remote_retrieve_cookies( $response ); |
| 79 |
if ( ! empty( $response_cookies ) ) { |
| 80 |
$args['cookies'] = array_merge( isset( $args['cookies'] ) ? $args['cookies'] : array(), $response_cookies ); |
| 81 |
} |
| 82 |
} |
| 83 |
|
| 84 |
if ( in_array( $status, array( 302, 303 ), true ) ) { |
| 85 |
$args['method'] = 'GET'; |
| 86 |
unset( $args['body'] ); |
| 87 |
} |
| 88 |
|
| 89 |
$url = $next_url; |
| 90 |
} |
| 91 |
|
| 92 |
return new WP_Error( 'visualizer_remote_request', 'The remote request could not be completed.' ); |
| 93 |
} |
| 94 |
|
| 95 |
/** |
| 96 |
* Downloads a remote resource to a temporary file. |
| 97 |
* |
| 98 |
* The caller is responsible for deleting the returned file. |
| 99 |
* |
| 100 |
* @param string $url Remote URL. |
| 101 |
* @param array<string, mixed> $args Optional WordPress HTTP arguments. |
| 102 |
* @return string|WP_Error |
| 103 |
*/ |
| 104 |
public static function download( $url, $args = array() ) { |
| 105 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 106 |
|
| 107 |
$max_bytes = isset( $args['limit_response_size'] ) ? (int) $args['limit_response_size'] : self::MAX_DOWNLOAD_BYTES; |
| 108 |
if ( $max_bytes < 1 ) { |
| 109 |
return new WP_Error( 'visualizer_remote_size', 'The remote file size limit is invalid.' ); |
| 110 |
} |
| 111 |
|
| 112 |
$tmpfile = wp_tempnam( (string) wp_parse_url( $url, PHP_URL_PATH ) ); |
| 113 |
if ( ! $tmpfile ) { |
| 114 |
return new WP_Error( 'visualizer_temp_file', 'Could not create a temporary file.' ); |
| 115 |
} |
| 116 |
|
| 117 |
$args['stream'] = true; |
| 118 |
$args['filename'] = $tmpfile; |
| 119 |
$args['limit_response_size'] = $max_bytes < PHP_INT_MAX ? $max_bytes + 1 : $max_bytes; |
| 120 |
$response = self::request( $url, $args ); |
| 121 |
|
| 122 |
if ( is_wp_error( $response ) ) { |
| 123 |
wp_delete_file( $tmpfile ); |
| 124 |
return $response; |
| 125 |
} |
| 126 |
|
| 127 |
if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { |
| 128 |
wp_delete_file( $tmpfile ); |
| 129 |
return new WP_Error( 'visualizer_remote_status', 'The remote server returned an unexpected response.' ); |
| 130 |
} |
| 131 |
|
| 132 |
// Download one extra byte so an exactly-at-limit file remains valid. |
| 133 |
clearstatcache( true, $tmpfile ); |
| 134 |
if ( filesize( $tmpfile ) > $max_bytes ) { |
| 135 |
wp_delete_file( $tmpfile ); |
| 136 |
return new WP_Error( 'visualizer_remote_size', 'The remote file is too large to import.' ); |
| 137 |
} |
| 138 |
|
| 139 |
return $tmpfile; |
| 140 |
} |
| 141 |
|
| 142 |
/** |
| 143 |
* Applies method and header restrictions. |
| 144 |
* |
| 145 |
* @param array<string, mixed> $args HTTP arguments. |
| 146 |
* @return array<string, mixed>|WP_Error |
| 147 |
*/ |
| 148 |
private static function enforce_request_policy( $args ) { |
| 149 |
$args['method'] = strtoupper( (string) $args['method'] ); |
| 150 |
if ( ! preg_match( '/^[!#$%&\'*+\-.^_`|~0-9A-Z]+$/', $args['method'] ) || in_array( $args['method'], array( 'CONNECT', 'TRACE' ), true ) ) { |
| 151 |
return new WP_Error( 'visualizer_remote_method', 'The remote request method is not allowed.' ); |
| 152 |
} |
| 153 |
|
| 154 |
if ( isset( $args['headers'] ) && is_string( $args['headers'] ) ) { |
| 155 |
$headers = array(); |
| 156 |
foreach ( preg_split( '/\r\n|\r|\n/', $args['headers'] ) as $header ) { |
| 157 |
if ( false === strpos( $header, ':' ) ) { |
| 158 |
continue; |
| 159 |
} |
| 160 |
list( $name, $value ) = explode( ':', $header, 2 ); |
| 161 |
$headers[ strtolower( trim( $name ) ) ] = trim( $value ); |
| 162 |
} |
| 163 |
$args['headers'] = $headers; |
| 164 |
} elseif ( ! isset( $args['headers'] ) || ! is_array( $args['headers'] ) ) { |
| 165 |
$args['headers'] = array(); |
| 166 |
} |
| 167 |
|
| 168 |
$blocked_headers = array( 'connection', 'content-length', 'host', 'proxy-authorization', 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade' ); |
| 169 |
foreach ( $args['headers'] as $name => $value ) { |
| 170 |
if ( in_array( strtolower( (string) $name ), $blocked_headers, true ) ) { |
| 171 |
unset( $args['headers'][ $name ] ); |
| 172 |
} |
| 173 |
} |
| 174 |
|
| 175 |
$args['timeout'] = min( 30, max( 1, (int) $args['timeout'] ) ); |
| 176 |
|
| 177 |
return $args; |
| 178 |
} |
| 179 |
|
| 180 |
/** |
| 181 |
* Binds the cURL transport to the addresses that passed validation. |
| 182 |
* |
| 183 |
* Without this the transport re-resolves the host on connect, letting a |
| 184 |
* rebinding nameserver answer with a private address after validation |
| 185 |
* passed. Hostname requests fail closed when cURL pinning is unavailable. |
| 186 |
* |
| 187 |
* @param string $url Validated URL. |
| 188 |
* @param string[] $ips Validated addresses. |
| 189 |
* @param string|null $curl_version Optional cURL version override. |
| 190 |
* @return callable|WP_Error|null The registered hook to remove after dispatch, an error when pinning is unavailable, or null for an IP literal or exempt host. |
| 191 |
*/ |
| 192 |
private static function pin_validated_addresses( $url, $ips, $curl_version = null ) { |
| 193 |
if ( empty( $ips ) ) { |
| 194 |
return null; |
| 195 |
} |
| 196 |
|
| 197 |
$parsed = wp_parse_url( $url ); |
| 198 |
if ( filter_var( rtrim( $parsed['host'], '.' ), FILTER_VALIDATE_IP ) ) { |
| 199 |
return null; |
| 200 |
} |
| 201 |
|
| 202 |
if ( ! function_exists( 'curl_init' ) || ! function_exists( 'curl_exec' ) || ! defined( 'CURLOPT_RESOLVE' ) ) { |
| 203 |
return new WP_Error( 'visualizer_remote_transport', 'The remote host cannot be fetched securely on this server.' ); |
| 204 |
} |
| 205 |
|
| 206 |
$curl = curl_version(); |
| 207 |
if ( 'https' === strtolower( $parsed['scheme'] ) ) { |
| 208 |
if ( empty( $curl['features'] ) || ! defined( 'CURL_VERSION_SSL' ) || ! ( $curl['features'] & CURL_VERSION_SSL ) ) { |
| 209 |
return new WP_Error( 'visualizer_remote_transport', 'The remote host cannot be fetched securely on this server.' ); |
| 210 |
} |
| 211 |
} |
| 212 |
if ( null === $curl_version ) { |
| 213 |
$curl_version = isset( $curl['version'] ) ? $curl['version'] : '0.0.0'; |
| 214 |
} |
| 215 |
|
| 216 |
$proxy = new WP_HTTP_Proxy(); |
| 217 |
if ( $proxy->is_enabled() && $proxy->send_through_proxy( $url ) ) { |
| 218 |
return new WP_Error( 'visualizer_remote_transport', 'The remote host cannot be fetched securely through the configured proxy.' ); |
| 219 |
} |
| 220 |
|
| 221 |
if ( version_compare( $curl_version, '7.59.0', '<' ) ) { |
| 222 |
foreach ( $ips as $ip ) { |
| 223 |
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { |
| 224 |
$ips = array( $ip ); |
| 225 |
break; |
| 226 |
} |
| 227 |
} |
| 228 |
$ips = array( reset( $ips ) ); |
| 229 |
} |
| 230 |
|
| 231 |
if ( version_compare( $curl_version, '7.57.0', '<' ) && filter_var( reset( $ips ), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { |
| 232 |
return new WP_Error( 'visualizer_remote_transport', 'The remote host cannot be fetched securely on this server.' ); |
| 233 |
} |
| 234 |
|
| 235 |
$addresses = array(); |
| 236 |
foreach ( $ips as $ip ) { |
| 237 |
$addresses[] = filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ? '[' . $ip . ']' : $ip; |
| 238 |
} |
| 239 |
|
| 240 |
$entry = sprintf( '%s:%d:%s', strtolower( rtrim( $parsed['host'], '.' ) ), self::url_port( $parsed ), implode( ',', $addresses ) ); |
| 241 |
$pin = function ( $handle ) use ( $entry ) { |
| 242 |
curl_setopt( $handle, CURLOPT_RESOLVE, array( $entry ) ); |
| 243 |
}; |
| 244 |
add_action( 'http_api_curl', $pin ); |
| 245 |
|
| 246 |
return $pin; |
| 247 |
} |
| 248 |
|
| 249 |
/** |
| 250 |
* Validates URL syntax and every address returned by DNS. |
| 251 |
* |
| 252 |
* @param string $url Remote URL. |
| 253 |
* @param string[] $ips Filled with the validated addresses; stays empty when the host is exempt from the check. |
| 254 |
* @return string|WP_Error |
| 255 |
*/ |
| 256 |
private static function validate_url( $url, &$ips = array() ) { |
| 257 |
$ips = array(); |
| 258 |
$validated_url = wp_http_validate_url( $url ); |
| 259 |
if ( false === $validated_url ) { |
| 260 |
return new WP_Error( 'visualizer_invalid_remote_url', 'The remote URL is not allowed.' ); |
| 261 |
} |
| 262 |
|
| 263 |
$host = strtolower( rtrim( (string) wp_parse_url( $validated_url, PHP_URL_HOST ), '.' ) ); |
| 264 |
|
| 265 |
// Mirror core's same-host exemption so media library URLs import on hosts that resolve internally. |
| 266 |
$home_host = strtolower( rtrim( (string) wp_parse_url( get_option( 'home' ), PHP_URL_HOST ), '.' ) ); |
| 267 |
if ( $host === $home_host ) { |
| 268 |
return $validated_url; |
| 269 |
} |
| 270 |
|
| 271 |
$ips = self::resolve_host( $host ); |
| 272 |
if ( empty( $ips ) ) { |
| 273 |
return new WP_Error( 'visualizer_remote_dns', 'The remote host could not be resolved.' ); |
| 274 |
} |
| 275 |
|
| 276 |
foreach ( $ips as $ip ) { |
| 277 |
if ( ! self::is_global_ip( $ip ) ) { |
| 278 |
return new WP_Error( 'visualizer_unsafe_remote_url', 'The remote URL resolves to a non-public address.' ); |
| 279 |
} |
| 280 |
} |
| 281 |
|
| 282 |
return $validated_url; |
| 283 |
} |
| 284 |
|
| 285 |
/** |
| 286 |
* Resolves every IPv4 and IPv6 address for a host. |
| 287 |
* |
| 288 |
* @param string $host Host name or IP literal. |
| 289 |
* @return string[] |
| 290 |
*/ |
| 291 |
private static function resolve_host( $host ) { |
| 292 |
if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { |
| 293 |
return array( $host ); |
| 294 |
} |
| 295 |
|
| 296 |
$ips = array(); |
| 297 |
if ( function_exists( 'dns_get_record' ) ) { |
| 298 |
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- DNS failures are handled below. |
| 299 |
$records = @dns_get_record( $host, DNS_A | DNS_AAAA ); |
| 300 |
foreach ( is_array( $records ) ? $records : array() as $record ) { |
| 301 |
if ( ! empty( $record['ip'] ) ) { |
| 302 |
$ips[] = $record['ip']; |
| 303 |
} elseif ( ! empty( $record['ipv6'] ) ) { |
| 304 |
$ips[] = $record['ipv6']; |
| 305 |
} |
| 306 |
} |
| 307 |
} |
| 308 |
|
| 309 |
if ( empty( $ips ) ) { |
| 310 |
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- DNS failures are handled by returning no addresses. |
| 311 |
$ipv4 = @gethostbynamel( $host ); |
| 312 |
$ips = is_array( $ipv4 ) ? $ipv4 : array(); |
| 313 |
} |
| 314 |
|
| 315 |
return array_values( array_unique( $ips ) ); |
| 316 |
} |
| 317 |
|
| 318 |
/** |
| 319 |
* Whether an address is globally routable. |
| 320 |
* |
| 321 |
* @param string $ip IP address. |
| 322 |
* @return bool |
| 323 |
*/ |
| 324 |
private static function is_global_ip( $ip ) { |
| 325 |
if ( self::ip_in_range( $ip, '::ffff:0:0/96' ) ) { |
| 326 |
$packed = inet_pton( $ip ); |
| 327 |
$ip = inet_ntop( substr( $packed, 12 ) ); |
| 328 |
} |
| 329 |
|
| 330 |
if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { |
| 331 |
return false; |
| 332 |
} |
| 333 |
|
| 334 |
$ranges = false !== strpos( $ip, ':' ) |
| 335 |
? array( 'fc00::/7', 'fe80::/10', 'ff00::/8' ) |
| 336 |
: array( '100.64.0.0/10', '192.0.0.0/24', '192.0.2.0/24', '198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4' ); |
| 337 |
|
| 338 |
foreach ( $ranges as $range ) { |
| 339 |
if ( self::ip_in_range( $ip, $range ) ) { |
| 340 |
return false; |
| 341 |
} |
| 342 |
} |
| 343 |
|
| 344 |
return true; |
| 345 |
} |
| 346 |
|
| 347 |
/** |
| 348 |
* Checks whether an IP belongs to a CIDR range. |
| 349 |
* |
| 350 |
* @param string $ip IP address. |
| 351 |
* @param string $cidr CIDR range. |
| 352 |
* @return bool |
| 353 |
*/ |
| 354 |
private static function ip_in_range( $ip, $cidr ) { |
| 355 |
list( $network, $prefix ) = explode( '/', $cidr, 2 ); |
| 356 |
$address = inet_pton( $ip ); |
| 357 |
$network = inet_pton( $network ); |
| 358 |
if ( false === $address || false === $network || strlen( $address ) !== strlen( $network ) ) { |
| 359 |
return false; |
| 360 |
} |
| 361 |
|
| 362 |
$bytes = intdiv( (int) $prefix, 8 ); |
| 363 |
$bits = (int) $prefix % 8; |
| 364 |
if ( substr( $address, 0, $bytes ) !== substr( $network, 0, $bytes ) ) { |
| 365 |
return false; |
| 366 |
} |
| 367 |
|
| 368 |
return 0 === $bits || ( ord( $address[ $bytes ] ) & ( 0xff << ( 8 - $bits ) ) ) === ( ord( $network[ $bytes ] ) & ( 0xff << ( 8 - $bits ) ) ); |
| 369 |
} |
| 370 |
|
| 371 |
/** |
| 372 |
* Whether two URLs share scheme, host, and port. |
| 373 |
* |
| 374 |
* @param string $first First URL. |
| 375 |
* @param string $second Second URL. |
| 376 |
* @return bool |
| 377 |
*/ |
| 378 |
private static function same_origin( $first, $second ) { |
| 379 |
$first = wp_parse_url( $first ); |
| 380 |
$second = wp_parse_url( $second ); |
| 381 |
if ( ! is_array( $first ) || ! is_array( $second ) || empty( $first['scheme'] ) || empty( $first['host'] ) || empty( $second['scheme'] ) || empty( $second['host'] ) ) { |
| 382 |
return false; |
| 383 |
} |
| 384 |
|
| 385 |
return strtolower( $first['scheme'] ) === strtolower( $second['scheme'] ) |
| 386 |
&& strtolower( $first['host'] ) === strtolower( $second['host'] ) |
| 387 |
&& self::url_port( $first ) === self::url_port( $second ); |
| 388 |
} |
| 389 |
|
| 390 |
/** |
| 391 |
* Gets an explicit or scheme-default URL port. |
| 392 |
* |
| 393 |
* @param array<string, mixed> $url Parsed URL. |
| 394 |
* @return int |
| 395 |
*/ |
| 396 |
private static function url_port( $url ) { |
| 397 |
return isset( $url['port'] ) ? (int) $url['port'] : ( 'https' === strtolower( $url['scheme'] ) ? 443 : 80 ); |
| 398 |
} |
| 399 |
|
| 400 |
/** |
| 401 |
* Retains only non-sensitive headers across an origin change. |
| 402 |
* |
| 403 |
* @param array<string, mixed> $headers Request headers. |
| 404 |
* @return array<string, mixed> |
| 405 |
*/ |
| 406 |
private static function headers_for_cross_origin_redirect( $headers ) { |
| 407 |
$allowed = array( 'accept', 'accept-encoding', 'range', 'user-agent' ); |
| 408 |
return array_filter( |
| 409 |
$headers, |
| 410 |
function ( $value, $name ) use ( $allowed ) { |
| 411 |
return in_array( strtolower( (string) $name ), $allowed, true ); |
| 412 |
}, |
| 413 |
ARRAY_FILTER_USE_BOTH |
| 414 |
); |
| 415 |
} |
| 416 |
} |
| 417 |
|