You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

874 lines
24 KiB
PHTML

<?php
/**
* Class Hotlinking_Controller
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2021 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories\REST_API;
use Google\Web_Stories\Infrastructure\HasRequirements;
use Google\Web_Stories\Media\Types;
use Google\Web_Stories\Story_Post_Type;
use WP_Error;
use WP_Http;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Hotlinking_Controller class.
*
* API endpoint for pinging and hotlinking media URLs.
*
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*
* @phpstan-type LinkData array{
* ext?: string,
* file_name?: string,
* file_size?: int,
* mime_type?: string,
* type?: string
* }
*
* @phpstan-type SchemaEntry array{
* description: string,
* type: string,
* context: string[],
* default?: mixed,
* enum?: string[]
* }
*
* @phpstan-type Schema array{
* properties: array{
* ext?: SchemaEntry,
* file_name?: SchemaEntry,
* file_size?: SchemaEntry,
* mime_type?: SchemaEntry,
* type?: SchemaEntry
* }
* }
* @phpstan-type URLParts array{
* scheme?: string,
* user?: string,
* pass?: string,
* host?: string,
* port?: int,
* path?: string,
* query?: string
* }
*/
class Hotlinking_Controller extends REST_Controller implements HasRequirements {
public const PROXY_HEADERS_ALLOWLIST = [
'Content-Type',
'Cache-Control',
'Etag',
'Last-Modified',
'Content-Range',
];
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Types instance.
*
* @var Types Types instance.
*/
private Types $types;
/**
* File pointer resource.
*
* @var resource
*/
protected $stream_handle;
/**
* Constructor.
*
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
* @param Types $types Types instance.
* @return void
*/
public function __construct( Story_Post_Type $story_post_type, Types $types ) {
$this->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<string, mixed> $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<string, mixed> $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<string, string[]> 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;
}
}