*/ private static $resolved = array(); /** * Transient prefix holding whether a host resolves somewhere public. */ const RESOLVED_TRANSIENT = 'pagecore_host_ok_'; /** * Whether a URL may be requested at all. * * @param string $url URL. * @return true|\WP_Error */ private function validate_url( $url ) { $parts = wp_parse_url( (string) $url ); if ( ! $parts || empty( $parts['host'] ) || empty( $parts['scheme'] ) ) { return $this->refuse( $url, __( 'it is not a complete web address', 'pagecore' ) ); } // Everything PageCore fetches is published over https. Accepting // anything else would mean a demo could be rewritten in transit by // whatever sits between the two servers. if ( 'https' !== strtolower( $parts['scheme'] ) ) { return $this->refuse( $url, __( 'only https addresses are fetched', 'pagecore' ) ); } // Credentials in a URL are a way of smuggling a different host past a // reader: https://trusted.example@evil.example/ is a request to // evil.example. if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) { return $this->refuse( $url, __( 'it carries a username or password', 'pagecore' ) ); } if ( ! Trusted_Hosts::is_trusted( $url ) ) { return $this->refuse( $url, __( 'that host is not one this site is configured to fetch from. Demo sources are listed in the plugin\'s demos.json', 'pagecore' ) ); } $problem = $this->host_problem( $parts['host'] ); if ( 'unresolved' === $problem ) { return $this->refuse( $url, __( 'its host name could not be looked up. Try again in a moment', 'pagecore' ) ); } if ( 'private' === $problem ) { return $this->refuse( $url, __( 'it resolves to a private or reserved address', 'pagecore' ) ); } return true; } /** * What, if anything, stops this site connecting to a host. * * Only addresses on the public internet are acceptable, and a host whose * addresses cannot be found cannot be shown to be one of them. * * @param string $host Host name. * @return string Empty when the host may be connected to; 'unresolved' * when no address was found; 'private' when any address * is private or reserved. */ private function host_problem( $host ) { $host = strtolower( trim( (string) $host, '.[]' ) ); if ( '' === $host ) { return 'unresolved'; } if ( isset( self::$resolved[ $host ] ) ) { return self::$resolved[ $host ]; } /* * Held across requests as well as within one. A name lookup has no * timeout this code can set, so on a host whose resolver is slow or * broken the cost is paid in full, once per request, with nothing * able to interrupt it. An import makes hundreds of requests. Five * minutes is long enough that a run never pays twice and short enough * that a demo site moving is noticed the same day. */ $cached = get_transient( self::RESOLVED_TRANSIENT . md5( $host ) ); if ( false !== $cached ) { self::$resolved[ $host ] = '1' === $cached ? '' : 'private'; return self::$resolved[ $host ]; } $addresses = $this->addresses_for( $host ); // No address: nothing can be checked, so nothing is let through. // Remembered for this request only, so a short outage of the name // servers is not held against the next one. if ( ! $addresses ) { self::$resolved[ $host ] = 'unresolved'; return 'unresolved'; } $problem = ''; foreach ( $addresses as $address ) { if ( ! self::is_public_ip( $address ) ) { $problem = 'private'; break; } } self::$resolved[ $host ] = $problem; set_transient( self::RESOLVED_TRANSIENT . md5( $host ), '' === $problem ? '1' : '0', 5 * MINUTE_IN_SECONDS ); return $problem; } /** * Every address a host resolves to. * * @param string $host Host name, or a literal address. * @return string[] */ private function addresses_for( $host ) { if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { return array( $host ); } /* * The system's own lookup, the one the connection itself goes through, * so it answers as fast as the request would and gives the IPv6 * addresses along with the IPv4 ones. WordPress's own check looks at * IPv4 only, so a AAAA record pointing at ::1 walks straight past it. * * Not dns_get_record(): that asks the name servers itself, waiting on * each in turn, and where the first one does not answer it every * request is held for a minute. */ $addresses = array(); if ( function_exists( 'socket_addrinfo_lookup' ) ) { $found = socket_addrinfo_lookup( $host, '443', array( 'ai_socktype' => SOCK_STREAM ) ); foreach ( is_array( $found ) ? $found : array() as $info ) { $address = socket_addrinfo_explain( $info )['ai_addr']; if ( isset( $address['sin6_addr'] ) ) { $addresses[] = $address['sin6_addr']; } elseif ( isset( $address['sin_addr'] ) ) { $addresses[] = $address['sin_addr']; } } } // Without the sockets extension, or when its lookup found nothing, // the IPv4 lookup WordPress itself uses is asked as well, so a host // is never called unresolved that WordPress could still reach. if ( ! $addresses ) { $v4 = gethostbynamel( $host ); $addresses = is_array( $v4 ) ? $v4 : array(); } return array_values( array_unique( $addresses ) ); } /** * Whether an address is on the public internet. * * PHP's own filter covers the private and reserved ranges, which includes * loopback, the RFC 1918 blocks, the link-local block that cloud metadata * services answer on, and their IPv6 equivalents. The explicit tests after * it are the ones the filter has historically been inconsistent about. * * @param string $ip Address. * @return bool */ public static function is_public_ip( $ip ) { $ip = trim( (string) $ip, '[]' ); if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { return false; } if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { return false; } if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { $parts = array_map( 'intval', explode( '.', $ip ) ); // Carrier grade NAT, which is inside somebody's network even // though it is not an RFC 1918 block. if ( 100 === $parts[0] && $parts[1] >= 64 && $parts[1] <= 127 ) { return false; } // Benchmarking, IETF protocol assignments, and the link-local // block cloud metadata lives on. if ( 198 === $parts[0] && ( 18 === $parts[1] || 19 === $parts[1] ) ) { return false; } if ( 192 === $parts[0] && 0 === $parts[1] && 0 === $parts[2] ) { return false; } if ( 169 === $parts[0] && 254 === $parts[1] ) { return false; } if ( 0 === $parts[0] ) { return false; } } $lower = strtolower( $ip ); // An IPv4 address wearing an IPv6 costume. if ( false !== strpos( $lower, '::ffff:' ) ) { $mapped = substr( $lower, strrpos( $lower, ':' ) + 1 ); if ( filter_var( $mapped, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { return self::is_public_ip( $mapped ); } } return true; } /** * Build the error for an address that will not be requested. * * @param string $url Address. * @param string $reason Why, in plain language. * @return \WP_Error */ private function refuse( $url, $reason ) { return new \WP_Error( 'pagecore_bad_url', sprintf( /* translators: 1: the address that was refused, 2: the reason. */ __( 'PageCore will not request %1$s, because %2$s.', 'pagecore' ), esc_url_raw( (string) $url ), $reason ), array( 'status' => 400 ) ); } /** * Fetch and decode a JSON document. * * @param string $url Absolute URL. * @return array|\WP_Error Decoded body as an associative array. */ public function get_json( $url ) { $response = $this->get( $url ); if ( is_wp_error( $response ) ) { return $response; } $body = wp_remote_retrieve_body( $response ); if ( strlen( $body ) >= self::SIZE_JSON ) { return new \WP_Error( 'pagecore_response_too_large', sprintf( /* translators: 1: URL, 2: formatted size. */ __( '%1$s returned more than %2$s, which is more than a demo document should ever be.', 'pagecore' ), $url, size_format( self::SIZE_JSON ) ), array( 'status' => 502 ) ); } $data = json_decode( $body, true ); if ( JSON_ERROR_NONE !== json_last_error() ) { return new \WP_Error( 'pagecore_bad_json', sprintf( /* translators: %s: the URL that returned unreadable content. */ __( '%s did not return readable JSON.', 'pagecore' ), $url ), array( 'status' => 502 ) ); } return $data; } /** * Perform a GET, retrying transient failures. * * @param string $url Absolute URL. * @param array $args Overrides passed to wp_remote_get. * @return array|\WP_Error Raw response array. */ public function get( $url, $args = array() ) { $allowed = $this->validate_url( $url ); if ( is_wp_error( $allowed ) ) { return $allowed; } $args = wp_parse_args( $args, $this->default_args() ); /* * WordPress follows no redirects; request() does. WordPress can check a * redirect's address for a private network, but it knows nothing of * which hosts this plugin trusts, so left to WordPress a trusted host * could send the request to any host on the public internet. */ $hops = max( 0, (int) $args['redirection'] ); $args['redirection'] = 0; // WordPress checks every address too, the first and each redirect // request() follows, as a second opinion on the private address rule. $args['reject_unsafe_urls'] = true; $attempts = max( 1, (int) self::RETRIES + 1 ); $last = null; for ( $attempt = 1; $attempt <= $attempts; $attempt++ ) { $response = $this->request( $url, $args, $hops ); // A redirect that was refused is a decision, not a fault, and // asking again would only get the same answer. if ( is_wp_error( $response ) && in_array( $response->get_error_code(), array( 'pagecore_bad_redirect', 'pagecore_too_many_redirects' ), true ) ) { return $response; } if ( ! is_wp_error( $response ) ) { $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code >= 200 && $code < 300 ) { return $response; } // A 4xx is the server's considered answer. Retrying will not change it. if ( $code >= 400 && $code < 500 ) { return $this->http_error( $url, $code, $response ); } $last = $this->http_error( $url, $code, $response ); } else { $last = $response; } if ( $attempt < $attempts ) { usleep( 400000 * $attempt ); } } return $last; } /** * Make one request, following its redirects one hop at a time. * * A redirect is an address the remote end chose, so it gets no more trust * than the first one: before it is requested it has to pass validate_url(), * which means a trusted host, https, no credentials and no * private address. A relative Location is resolved against the address * that sent it. * * A file download sees the redirect's own body written to its file first; * the next hop writes over it, and a refusal leaves download_to_file() to * delete it. * * @param string $url Address, already validated. * @param array $args Arguments for wp_remote_get, redirection 0. * @param int $hops How many redirects may be followed. * @return array|\WP_Error The first response that is not a redirect. */ private function request( $url, $args, $hops ) { $start = $url; for ( $hop = 0; $hop <= $hops; $hop++ ) { $response = wp_remote_get( $url, $args ); if ( is_wp_error( $response ) ) { return $response; } if ( ! in_array( (int) wp_remote_retrieve_response_code( $response ), self::REDIRECT_CODES, true ) ) { return $response; } if ( $hop === $hops ) { break; } $location = wp_remote_retrieve_header( $response, 'location' ); // A header sent twice arrives as a list; the last one is the one // a browser would act on. if ( is_array( $location ) ) { $location = end( $location ); } $location = trim( (string) $location ); if ( '' === $location ) { return new \WP_Error( 'pagecore_bad_redirect', sprintf( /* translators: %s: the address that redirected. */ __( '%s redirected without saying where to.', 'pagecore' ), esc_url_raw( $start ) ), array( 'status' => 502 ) ); } $next = \WP_Http::make_absolute_url( $location, $url ); $allowed = $this->validate_url( $next ); if ( is_wp_error( $allowed ) ) { return new \WP_Error( 'pagecore_bad_redirect', sprintf( /* translators: 1: the address that redirected, 2: why its destination was refused. */ __( '%1$s redirected to an address PageCore will not follow. %2$s', 'pagecore' ), esc_url_raw( $start ), $allowed->get_error_message() ), array( 'status' => 502 ) ); } $url = $next; } return new \WP_Error( 'pagecore_too_many_redirects', sprintf( /* translators: 1: the address that redirected, 2: the number of redirects allowed. */ __( '%1$s redirected more than %2$d times, so PageCore stopped following it.', 'pagecore' ), esc_url_raw( $start ), $hops ), array( 'status' => 502 ) ); } /** * Stream a remote file to a local path. * * @param string $url Absolute URL. * @param string $destination Absolute local path to write to. * @param int $max_bytes Largest file that may be written. * @return string|\WP_Error The destination path on success. */ public function download_to_file( $url, $destination, $max_bytes = self::SIZE_MEDIA ) { $max_bytes = (int) $max_bytes > 0 ? (int) $max_bytes : self::SIZE_MEDIA; $args = array( 'timeout' => self::DOWNLOAD_TIMEOUT, 'stream' => true, 'filename' => $destination, 'limit_response_size' => $max_bytes, 'headers' => array( 'Accept' => '*/*' ), ); $response = $this->get( $url, $args ); if ( is_wp_error( $response ) ) { if ( file_exists( $destination ) ) { wp_delete_file( $destination ); } return $response; } if ( ! file_exists( $destination ) || 0 === filesize( $destination ) ) { return new \WP_Error( 'pagecore_empty_download', sprintf( /* translators: %s: the URL that produced an empty file. */ __( 'Downloading %s produced an empty file.', 'pagecore' ), $url ), array( 'status' => 502 ) ); } /* * limit_response_size stops the transfer, but a truncated file is * still a file, and a half written zip or image is worse than none. * Anything at or over the ceiling is treated as refused and removed. */ if ( filesize( $destination ) >= $max_bytes ) { wp_delete_file( $destination ); return new \WP_Error( 'pagecore_download_too_large', sprintf( /* translators: 1: URL, 2: formatted size limit. */ __( '%1$s is larger than the %2$s PageCore will download, so it was left out.', 'pagecore' ), $url, size_format( $max_bytes ) ), array( 'status' => 502 ) ); } return $destination; } /** * Arguments applied to every request. * * The user agent names the plugin and its version and nothing else. It used * to carry this site's address, which told every demo host who was reading * it and turned an anonymous read into an identifiable one. Nothing about * the request needs it: a demo site serves the same public manifest to * everyone, so the address was information given away for nothing. * * @return array */ private function default_args() { return array( 'timeout' => self::TIMEOUT, // Redirects request() may follow. WordPress itself is told none. 'redirection' => 3, 'sslverify' => true, 'limit_response_size' => self::SIZE_JSON, 'user-agent' => 'PageCore/' . PAGECORE_VERSION, 'headers' => array( 'Accept' => 'application/json', ), // Nothing this plugin reads is per-visitor, so no cookie belonging // to this site is ever sent to another one. 'cookies' => array(), ); } /** * Build the error for a non-success status code. * * @param string $url Requested URL. * @param int $code HTTP status code. * @param array $response Raw response. * @return \WP_Error */ private function http_error( $url, $code, $response ) { $message = wp_remote_retrieve_response_message( $response ); return new \WP_Error( 'pagecore_http_' . $code, sprintf( /* translators: 1: URL, 2: HTTP status code, 3: HTTP status message. */ __( '%1$s responded with %2$d %3$s.', 'pagecore' ), $url, $code, $message ? $message : __( 'Error', 'pagecore' ) ), array( 'status' => 502, 'code' => $code, ) ); } }