story_post_type = $story_post_type; $this->types = $types; $this->namespace = 'web-stories/v1'; $this->rest_base = 'hotlink'; } /** * Get the list of service IDs required for this service to be registered. * * Needed because the story post type needs to be registered first. * * @since 1.13.0 * * @return string[] List of required services. */ public static function get_requirements(): array { return [ 'story_post_type' ]; } /** * Registers routes for urls. * * @since 1.11.0 * * @see register_rest_route() */ public function register_routes(): void { register_rest_route( $this->namespace, '/' . $this->rest_base . '/validate', [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'parse_url' ], 'permission_callback' => [ $this, 'parse_url_permissions_check' ], 'args' => [ 'url' => [ 'description' => __( 'The URL to process.', 'web-stories' ), 'required' => true, 'type' => 'string', 'format' => 'uri', 'validate_callback' => [ $this, 'validate_callback' ], 'sanitize_callback' => 'esc_url_raw', ], ], ], ] ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/proxy', [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'proxy_url' ], 'permission_callback' => [ $this, 'parse_url_permissions_check' ], 'args' => [ 'url' => [ 'description' => __( 'The URL to process.', 'web-stories' ), 'required' => true, 'type' => 'string', 'format' => 'uri', 'validate_callback' => [ $this, 'validate_callback' ], 'sanitize_callback' => 'esc_url_raw', ], ], ], ] ); } /** * Parses a URL to return some metadata for inserting external media. * * @SuppressWarnings(PHPMD.NPathComplexity) * @SuppressWarnings(PHPMD.ExcessiveMethodLength) * * @since 1.11.0 * * @param WP_REST_Request $request Full data about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function parse_url( WP_REST_Request $request ) { /** * Requested URL. * * @var string $raw_url */ $raw_url = $request['url']; $raw_url = untrailingslashit( $raw_url ); $url_or_ip = $this->validate_url( $raw_url ); $host = wp_parse_url( $raw_url, PHP_URL_HOST ); if ( ! $url_or_ip || ! $host ) { return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 400 ] ); } /** * Filters the hotlinking data TTL value. * * @since 1.11.0 * * @param int $time Time to live (in seconds). Default is 1 day. * @param string $url The attempted URL. */ $cache_ttl = apply_filters( 'web_stories_hotlinking_url_data_cache_ttl', DAY_IN_SECONDS, $raw_url ); $cache_key = 'web_stories_url_data_' . md5( $raw_url ); $data = get_transient( $cache_key ); if ( \is_string( $data ) && ! empty( $data ) ) { /** * Decoded cached link data. * * @var array|null $link * @phpstan-var LinkData|null $link */ $link = json_decode( $data, true ); if ( $link ) { $response = $this->prepare_item_for_response( $link, $request ); return rest_ensure_response( $response ); } } $callback = $this->get_curl_resolve_callback( $raw_url, $url_or_ip ); add_action( 'http_api_curl', $callback ); $response = wp_safe_remote_head( $raw_url, [ 'redirection' => 0, // No redirects allowed. 'headers' => [ 'Host' => $host, ], ] ); remove_action( 'http_api_curl', $callback ); if ( is_wp_error( $response ) && 'http_request_failed' === $response->get_error_code() ) { return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 404 ] ); } if ( WP_Http::OK !== wp_remote_retrieve_response_code( $response ) ) { return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 404 ] ); } $headers = wp_remote_retrieve_headers( $response ); $mime_type = $headers['content-type']; if ( $mime_type && str_contains( $mime_type, ';' ) ) { $pieces = explode( ';', $mime_type ); $mime_type = array_shift( $pieces ); } $file_size = (int) $headers['content-length']; $path = wp_parse_url( $raw_url, PHP_URL_PATH ); if ( ! \is_string( $path ) ) { return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 404 ] ); } $file_name = basename( $path ); $exts = $this->types->get_file_type_exts( [ $mime_type ] ); $ext = ''; if ( $exts ) { $ext = end( $exts ); } $allowed_mime_types = $this->get_allowed_mime_types(); $type = ''; foreach ( $allowed_mime_types as $key => $mime_types ) { if ( \in_array( $mime_type, $mime_types, true ) ) { $type = $key; break; } } $data = [ 'ext' => $ext, 'file_name' => $file_name, 'file_size' => $file_size, 'mime_type' => $mime_type, 'type' => $type, ]; set_transient( $cache_key, wp_json_encode( $data ), $cache_ttl ); $response = $this->prepare_item_for_response( $data, $request ); return rest_ensure_response( $response ); } /** * Parses a URL to return proxied file. * * @SuppressWarnings(PHPMD.ErrorControlOperator) * * @since 1.13.0 * * @param WP_REST_Request $request Full data about the request. * @return WP_Error|void Proxied data on success, error otherwise. */ public function proxy_url( WP_REST_Request $request ) { /** * Requested URL. * * @var string $raw_url */ $raw_url = $request['url']; $raw_url = untrailingslashit( $raw_url ); $url_or_ip = $this->validate_url( $raw_url ); $host = wp_parse_url( $raw_url, PHP_URL_HOST ); if ( ! $url_or_ip || ! $host ) { return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 400 ] ); } // Remove any relevant headers already set by WP_REST_Server::serve_request() // wp_get_nocache_headers(). if ( ! headers_sent() ) { header_remove( 'Cache-Control' ); header_remove( 'Content-Type' ); header_remove( 'Expires' ); header_remove( 'Last Modified' ); } header( 'Cache-Control: max-age=3600' ); header( 'Accept-Ranges: bytes' ); $args = [ 'timeout' => 60, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout 'blocking' => false, 'headers' => [ 'Range' => $request->get_header( 'Range' ), 'Host' => $host, ], 'redirection' => 0, // No redirects allowed. ]; $callback = $this->get_curl_resolve_callback( $raw_url, $url_or_ip ); add_action( 'http_api_curl', $callback ); $http = _wp_http_get_object(); $transport = $http->_get_first_available_transport( $args, $raw_url ); // When cURL is available, we might be able to use it together with fopen(). if ( 'WP_Http_Curl' === $transport ) { // php://temp is a read-write streams that allows temporary data to be stored in a file-like wrapper. // Other than php://memory, php://temp will use a temporary file once the amount of data stored hits a predefined limit (the default is 2 MB). // The location of this temporary file is determined in the same way as the {@see sys_get_temp_dir()} function. if ( WP_DEBUG ) { $stream_handle = fopen( 'php://memory', 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen } else { $stream_handle = @fopen( 'php://memory', 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged, Generic.PHP.NoSilencedErrors.Forbidden } if ( $stream_handle ) { $this->stream_handle = $stream_handle; $this->proxy_url_curl( $raw_url, $args ); exit; } } // If either cURL is not available or fopen() did not succeed, // fall back to using whatever else is set up on the site, // presumably WP_Http_Streams or still WP_Http_Curl but without streams. unset( $args['blocking'] ); $this->proxy_url_fallback( $raw_url, $args ); exit; } /** * Prepares response asset response. * * @since 1.11.0 * * @param LinkData|false $link URL data value, default to false is not set. * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error Response object. * * @phpstan-param LinkData $link */ public function prepare_item_for_response( $link, $request ) { $fields = $this->get_fields_for_response( $request ); $schema = $this->get_item_schema(); $data = []; $error = new WP_Error(); foreach ( $schema['properties'] as $field => $args ) { if ( ! isset( $link[ $field ] ) || ! rest_is_field_included( $field, $fields ) ) { continue; } $check = rest_validate_value_from_schema( $link[ $field ], $args, $field ); if ( is_wp_error( $check ) ) { $error->add( 'rest_invalid_' . $field, $check->get_error_message(), [ 'status' => 400 ] ); continue; } $data[ $field ] = rest_sanitize_value_from_schema( $link[ $field ], $args, $field ); } if ( $error->get_error_codes() ) { return $error; } /** * Request context. * * @var string $context */ $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); return rest_ensure_response( $data ); } /** * Retrieves the link's schema, conforming to JSON Schema. * * @since 1.11.0 * * @return array Item schema data. * * @phpstan-return Schema */ public function get_item_schema(): array { if ( $this->schema ) { /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } $allowed_mime_types = $this->get_allowed_mime_types(); $types = array_keys( $allowed_mime_types ); $allowed_mime_types = array_merge( ...array_values( $allowed_mime_types ) ); $exts = $this->types->get_file_type_exts( $allowed_mime_types ); $schema = [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'link', 'type' => 'object', 'properties' => [ 'ext' => [ 'description' => __( 'File extension', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], 'enum' => $exts, ], 'file_name' => [ 'description' => __( 'File name', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], ], 'file_size' => [ 'description' => __( 'File size', 'web-stories' ), 'type' => 'integer', 'context' => [ 'view', 'edit', 'embed' ], ], 'mime_type' => [ 'description' => __( 'Mime type', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], 'enum' => $allowed_mime_types, ], 'type' => [ 'description' => __( 'Type', 'web-stories' ), 'type' => 'string', 'context' => [ 'view', 'edit', 'embed' ], 'enum' => $types, ], ], ]; $this->schema = $schema; /** * Schema. * * @phpstan-var Schema $schema */ $schema = $this->add_additional_fields_schema( $this->schema ); return $schema; } /** * Checks if current user can process urls. * * @since 1.11.0 * * @return true|WP_Error True if the request has read access, WP_Error object otherwise. */ public function parse_url_permissions_check() { if ( ! $this->story_post_type->has_cap( 'edit_posts' ) ) { return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to insert external media.', 'web-stories' ), [ 'status' => rest_authorization_required_code() ] ); } return true; } /** * Callback to validate urls. * * @since 1.11.0 * * @param string $value Value to be validated. * @return true|WP_Error */ public function validate_callback( $value ) { $url = untrailingslashit( $value ); if ( empty( $url ) || ! $this->validate_url( $url ) ) { return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 400 ] ); } $path = wp_parse_url( $url, PHP_URL_PATH ); if ( ! $path ) { return new WP_Error( 'rest_invalid_url', __( 'Invalid URL', 'web-stories' ), [ 'status' => 400 ] ); } return true; } /** * Returns a callback to modify the cURL configuration before the request is executed. * * @since 1.22.1 * * @param string $url URL. * @param string $url_or_ip URL or IP address. */ public function get_curl_resolve_callback( string $url, string $url_or_ip ): callable { /** * CURL configuration callback. * * @param resource $handle The cURL handle returned by curl_init() (passed by reference). */ return static function ( $handle ) use ( $url, $url_or_ip ): void { // Just some safeguard in case cURL is not really available, // despite this method being run in the context of WP_Http_Curl. if ( ! \function_exists( '\curl_setopt' ) ) { return; } if ( $url === $url_or_ip ) { return; } $host = wp_parse_url( $url, PHP_URL_HOST ); $scheme = wp_parse_url( $url, PHP_URL_SCHEME ) ?? 'http'; $port = wp_parse_url( $url, PHP_URL_PORT ) ?? 'http' === $scheme ? 80 : 443; // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt curl_setopt( $handle, CURLOPT_RESOLVE, [ "$host:$port:$url_or_ip", ] ); // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_setopt }; } /** * Modifies the cURL configuration before the request is executed. * * @since 1.15.0 * * @param resource $handle The cURL handle returned by {@see curl_init()} (passed by reference). */ public function modify_curl_configuration( $handle ): void { // Just some safeguard in case cURL is not really available, // despite this method being run in the context of WP_Http_Curl. if ( ! \function_exists( '\curl_setopt' ) ) { return; } // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt curl_setopt( $handle, CURLOPT_FILE, $this->stream_handle ); curl_setopt( $handle, CURLOPT_HEADERFUNCTION, [ $this, 'stream_headers' ] ); // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_setopt } /** * Grabs the headers of the cURL request. * * Each header is sent individually to this callback, * so we take a look at each one to see if we should "forward" it. * * @since 1.15.0 * * @param resource $handle cURL handle. * @param string $header cURL header. * @return int Header length. */ public function stream_headers( $handle, $header ): int { // Parse Status-Line, the first component in the HTTP response, e.g. HTTP/1.1 200 OK. // Extract the status code to re-send that here. if ( str_starts_with( $header, 'HTTP/' ) ) { $status = explode( ' ', $header ); http_response_code( (int) $status[1] ); return \strlen( $header ); } foreach ( self::PROXY_HEADERS_ALLOWLIST as $_header ) { if ( str_starts_with( strtolower( $header ), strtolower( $_header ) . ': ' ) ) { header( $header, true ); } } return \strlen( $header ); } /** * Proxy a given URL via a PHP read-write stream. * * @since 1.15.0 * * @param string $url Request URL. * @param array $args Request args. * * @phpstan-param array{ * method?: string, * timeout?: float, * redirection?: int, * httpversion?: string, * user-agent?: string, * reject_unsafe_urls?: bool, * blocking?: bool, * headers?: string|array, * cookies?: array, * body?: string|array, * compress?: bool, * decompress?: bool, * sslverify?: bool, * sslcertificates?: string, * stream?: bool, * filename?: string, * limit_response_size?: int, * } $args */ private function proxy_url_curl( string $url, array $args ): void { add_action( 'http_api_curl', [ $this, 'modify_curl_configuration' ] ); wp_safe_remote_get( $url, $args ); remove_action( 'http_api_curl', [ $this, 'modify_curl_configuration' ] ); rewind( $this->stream_handle ); while ( ! feof( $this->stream_handle ) ) { echo fread( $this->stream_handle, 1024 * 1024 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.WP.AlternativeFunctions.file_system_operations_fread } fclose( $this->stream_handle ); } /** * Proxy a given URL by storing in memory. * * @since 1.15.0 * * @param string $url Request URL. * @param array $args Request args. * * @phpstan-param array{ * method?: string, * timeout?: float, * redirection?: int, * httpversion?: string, * user-agent?: string, * reject_unsafe_urls?: bool, * blocking?: bool, * headers?: string|array, * cookies?: array, * body?: string|array, * compress?: bool, * decompress?: bool, * sslverify?: bool, * sslcertificates?: string, * stream?: bool, * filename?: string, * limit_response_size?: int, * } $args */ private function proxy_url_fallback( string $url, array $args ): void { $response = wp_safe_remote_get( $url, $args ); $status = wp_remote_retrieve_response_code( $response ); if ( ! $status ) { http_response_code( 404 ); return; } http_response_code( (int) $status ); $headers = wp_remote_retrieve_headers( $response ); foreach ( self::PROXY_HEADERS_ALLOWLIST as $_header ) { if ( isset( $headers[ $_header ] ) ) { header( $_header . ': ' . $headers[ $_header ] ); } } echo wp_remote_retrieve_body( $response ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } /** * Validate a URL for safe use in the HTTP API. * * Like {@see wp_http_validate_url} in core, but with extra hardening * to avoid DNS rebinding issues. * * @SuppressWarnings(PHPMD.NPathComplexity) * @SuppressWarnings(PHPMD.CyclomaticComplexity) * * @since 1.22.0 * * @param string $url Request URL. * @return string|false Original URL, resolved IP address, or false on failure. */ private function validate_url( string $url ) { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh if ( '' === $url || is_numeric( $url ) ) { return false; } $original_url = $url; $url = wp_kses_bad_protocol( $url, [ 'http', 'https' ] ); if ( ! $url || strtolower( $url ) !== strtolower( $original_url ) ) { return false; } $parsed_url = wp_parse_url( $url ); if ( ! $parsed_url || ! isset( $parsed_url['host'], $parsed_url['scheme'] ) ) { return false; } if ( isset( $parsed_url['user'] ) || isset( $parsed_url['pass'] ) ) { return false; } if ( false !== strpbrk( $parsed_url['host'], ':#?[]' ) ) { return false; } /** * Home URL. * * @var string */ $home_url = get_option( 'home' ); $parsed_home = wp_parse_url( $home_url ); if ( ! $parsed_home ) { return false; } $same_host = isset( $parsed_home['host'] ) && strtolower( $parsed_home['host'] ) === strtolower( $parsed_url['host'] ); $host = trim( $parsed_url['host'], '.' ); $validated_url = $url; if ( ! $same_host ) { if ( preg_match( '#^(([1-9]?\d|1\d\d|25[0-5]|2[0-4]\d)\.){3}([1-9]?\d|1\d\d|25[0-5]|2[0-4]\d)$#', $host ) ) { $ip = $host; } else { $ip = gethostbyname( $host ); if ( $ip === $host ) { // Error condition for gethostbyname(). return false; } } $parts = array_map( 'intval', explode( '.', $ip ) ); if ( 0 === $parts[0] // 0.0.0.0/8. || 127 === $parts[0] // 127.0.0.0/8. || 10 === $parts[0] // 10.0.0.0/8. || ( 172 === $parts[0] && 16 <= $parts[1] && 31 >= $parts[1] ) // 172.16.0.0/12. || ( 192 === $parts[0] && 168 === $parts[1] ) // 192.168.0.0/16. || ( 169 === $parts[0] && 254 === $parts[1] ) // 169.254.0.0/16. || // phpcs:ignore Squiz.PHP.CommentedOutCode.Found ( 100 === $parts[0] && 64 <= $parts[1] && 127 >= $parts[1] ) // Private: 100.64.0.0/10. ) { // If host appears local, reject. return false; } // Use resolved IP address to avoid DNS rebinding issues. $validated_url = $ip; } /** This filter is documented in wp-includes/http.php */ $allowed_ports = apply_filters( 'http_allowed_safe_ports', [ 80, 443, 8080 ], $host, $url ); if ( ! isset( $parsed_url['port'] ) || ( \is_array( $allowed_ports ) && \in_array( $parsed_url['port'], $allowed_ports, true ) ) ) { return $validated_url; } if ( $same_host && isset( $parsed_home['port'] ) && $parsed_home['port'] === $parsed_url['port'] ) { return $validated_url; } return false; } /** * Returns a list of allowed mime types per media type (image, audio, video). * * @since 1.19.0 * * @return array List of allowed mime types. */ protected function get_allowed_mime_types(): array { $mime_type = $this->types->get_allowed_mime_types(); // Do not support hotlinking SVGs for security reasons. unset( $mime_type['vector'] ); return $mime_type; } }