PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | _inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-external-media.php +484 -29 13.6.2 → 16.3 View file →
@@ -7,9 +7,14 @@
7 7 */
8 8
9 9 use Automattic\Jetpack\Connection\Client;
10 10 use Automattic\Jetpack\Connection\Manager;
11 +use Automattic\Jetpack\IP\Utils;
11 12
13 +if ( ! defined( 'ABSPATH' ) ) {
14 + exit( 0 );
15 +}
16 +
12 17 /**
13 18 * External Media helper API.
14 19 *
15 20 * @since 8.7.0
@@ -16,8 +21,25 @@
16 21 */
17 22 class WPCOM_REST_API_V2_Endpoint_External_Media extends WP_REST_Controller {
18 23
19 24 /**
25 + * Maximum number of redirect hops to follow when downloading a media file.
26 + *
27 + * Matches WordPress's default `redirection` limit, so media URLs that redirect
28 + * to a CDN keep resolving exactly as before.
29 + *
30 + * @var int
31 + */
32 + const MAX_REDIRECTS = 5;
33 +
34 + /**
35 + * Seconds a media download may take, redirects included.
36 + *
37 + * @var int
38 + */
39 + const DOWNLOAD_TIMEOUT = 300;
40 +
41 + /**
20 42 * Media argument schema for /copy endpoint.
21 43 *
22 44 * @var array
23 45 */
@@ -75,17 +97,8 @@
75 97 */
76 98 private static $services_regex = '(?P<service>google_photos|openverse|pexels)';
77 99
78 100 /**
79 - * Temporary filename.
80 - *
81 - * Needed to cope with Google's very long file names.
82 - *
83 - * @var string
84 - */
85 - private $tmp_name;
86 -
87 - /**
88 101 * Constructor.
89 102 */
90 103 public function __construct() {
91 104 $this->namespace = 'wpcom/v2';
@@ -120,8 +133,12 @@
120 133 ),
121 134 'page_handle' => array(
122 135 'type' => 'string',
123 136 ),
137 + 'session_id' => array(
138 + 'description' => __( 'Session id of a service, currently only Google Photos Picker', 'jetpack' ),
139 + 'type' => 'string',
140 + ),
124 141 ),
125 142 )
126 143 );
127 144
@@ -132,9 +149,9 @@
132 149 'methods' => \WP_REST_Server::CREATABLE,
133 150 'callback' => array( $this, 'copy_external_media' ),
134 151 'permission_callback' => array( $this, 'create_item_permissions_check' ),
135 152 'args' => array(
136 - 'media' => array(
153 + 'media' => array(
137 154 'description' => __( 'Media data to copy.', 'jetpack' ),
138 155 'items' => $this->media_schema,
139 156 'required' => true,
140 157 'type' => 'array',
@@ -140,13 +157,18 @@
140 157 'type' => 'array',
141 158 'sanitize_callback' => array( $this, 'sanitize_media' ),
142 159 'validate_callback' => array( $this, 'validate_media' ),
143 160 ),
144 - 'post_id' => array(
161 + 'post_id' => array(
145 162 'description' => __( 'The post ID to attach the upload to.', 'jetpack' ),
146 163 'type' => 'number',
147 164 'minimum' => 0,
148 165 ),
166 + 'should_proxy' => array(
167 + 'description' => __( 'Whether to proxy the media request.', 'jetpack' ),
168 + 'type' => 'boolean',
169 + 'default' => false,
170 + ),
149 171 ),
150 172 )
151 173 );
152 174
@@ -168,8 +190,68 @@
168 190 'callback' => array( $this, 'delete_connection' ),
169 191 'permission_callback' => array( $this, 'permission_callback' ),
170 192 )
171 193 );
194 +
195 + register_rest_route(
196 + $this->namespace,
197 + $this->rest_base . '/connection/(?P<service>google_photos)/picker_status',
198 + array(
199 + 'methods' => \WP_REST_Server::READABLE,
200 + 'callback' => array( $this, 'get_picker_status' ),
201 + 'permission_callback' => array( $this, 'permission_callback' ),
202 + )
203 + );
204 +
205 + // Add new session route, currently for Google Photos Picker only
206 + register_rest_route(
207 + $this->namespace,
208 + $this->rest_base . '/session/(?P<service>google_photos)',
209 + array(
210 + 'methods' => \WP_REST_Server::CREATABLE,
211 + 'callback' => array( $this, 'create_session' ),
212 + 'permission_callback' => array( $this, 'permission_callback' ),
213 + )
214 + );
215 +
216 + // Get new session route, currently for Google Photos Picker only
217 + register_rest_route(
218 + $this->namespace,
219 + $this->rest_base . '/session/(?P<service>google_photos)/(?P<session_id>.*)',
220 + array(
221 + 'methods' => \WP_REST_Server::READABLE,
222 + 'callback' => array( $this, 'get_session' ),
223 + 'permission_callback' => array( $this, 'permission_callback' ),
224 + )
225 + );
226 +
227 + // Delete session route, currently for Google Photos Picker only
228 + register_rest_route(
229 + $this->namespace,
230 + $this->rest_base . '/session/(?P<service>google_photos)/(?P<session_id>.*)',
231 + array(
232 + 'methods' => \WP_REST_Server::DELETABLE,
233 + 'callback' => array( $this, 'delete_session' ),
234 + 'permission_callback' => array( $this, 'permission_callback' ),
235 + )
236 + );
237 +
238 + // Add new proxy route for media files
239 + register_rest_route(
240 + $this->namespace,
241 + $this->rest_base . '/proxy/(?P<service>google_photos)',
242 + array(
243 + 'methods' => WP_REST_Server::CREATABLE,
244 + 'callback' => array( $this, 'proxy_media_request' ),
245 + 'permission_callback' => array( $this, 'permission_callback' ),
246 + 'args' => array(
247 + 'url' => array(
248 + 'required' => true,
249 + 'type' => 'string',
250 + ),
251 + ),
252 + )
253 + );
172 254 }
173 255
174 256 /**
175 257 * Checks if a given request has access to external media libraries.
@@ -210,8 +292,21 @@
210 292 array( 'status' => 400 )
211 293 );
212 294 }
213 295
296 + // Attaching media to a post requires the ability to edit that post, mirroring
297 + // WP_REST_Attachments_Controller::create_item_permissions_check(). Without this
298 + // check any user with upload_files could parent an attachment to a post they
299 + // cannot edit.
300 + $post_id = (int) $request->get_param( 'post_id' );
301 + if ( $post_id > 0 && ! current_user_can( 'edit_post', $post_id ) ) {
302 + return new WP_Error(
303 + 'rest_cannot_edit',
304 + __( 'Sorry, you are not allowed to upload media to this post.', 'jetpack' ),
305 + array( 'status' => rest_authorization_required_code() )
306 + );
307 + }
308 +
214 309 return true;
215 310 }
216 311
217 312 /**
@@ -281,9 +376,9 @@
281 376 // Build query string to pass to wpcom endpoint.
282 377 $service_args = array_filter(
283 378 $params,
284 379 function ( $key ) {
285 - return in_array( $key, array( 'search', 'number', 'path', 'page_handle', 'filter' ), true );
380 + return in_array( $key, array( 'search', 'number', 'path', 'page_handle', 'filter', 'session_id' ), true );
286 381 },
287 382 ARRAY_FILTER_USE_KEY
288 383 );
289 384 if ( ! empty( $service_args ) ) {
@@ -329,20 +424,60 @@
329 424 * Saves an external media item to the media library.
330 425 *
331 426 * @param \WP_REST_Request $request Full details about the request.
332 427 * @return array|\WP_Error|mixed
333 - */
428 + **/
334 429 public function copy_external_media( \WP_REST_Request $request ) {
335 430 require_once ABSPATH . 'wp-admin/includes/file.php';
336 431 require_once ABSPATH . 'wp-admin/includes/media.php';
337 432 require_once ABSPATH . 'wp-admin/includes/image.php';
338 433
339 - $post_id = $request->get_param( 'post_id' );
434 + $post_id = (int) $request->get_param( 'post_id' );
435 + $should_proxy = $request->get_param( 'should_proxy' );
436 + $service = rawurlencode( $request->get_param( 'service' ) );
340 437
438 + // Fail closed: never parent an attachment to a post the caller cannot edit,
439 + // even if a future change lets an unauthorized request reach this handler.
440 + // The permission callback already rejects such requests with a 403.
441 + if ( $post_id > 0 && ! current_user_can( 'edit_post', $post_id ) ) {
442 + $post_id = 0;
443 + }
444 +
341 445 $responses = array();
446 +
342 447 foreach ( $request->get_param( 'media' ) as $item ) {
343 448 // Download file to temp dir.
344 - $download_url = $this->get_download_url( $item['guid'] );
449 + if ( $should_proxy ) {
450 + $wpcom_path = sprintf( '/meta/external-media/proxy/%s', $service );
451 + $wpcom_path .= '?url=' . rawurlencode( $item['guid']['url'] );
452 + $download_url = wp_tempnam();
453 + $response = Client::wpcom_json_api_request_as_user(
454 + $wpcom_path,
455 + '2',
456 + array(
457 + 'method' => 'POST',
458 + )
459 + );
460 +
461 + if ( is_wp_error( $response ) ) {
462 + $responses[] = $response;
463 + continue;
464 + }
465 + $wp_filesystem = $this->get_wp_filesystem();
466 + $written = $wp_filesystem->put_contents( $download_url, wp_remote_retrieve_body( $response ) );
467 +
468 + if ( false === $written ) {
469 + $responses[] = new WP_Error(
470 + 'rest_upload_error',
471 + __( 'Could not download media file.', 'jetpack' ),
472 + array( 'status' => 400 )
473 + );
474 + continue;
475 + }
476 + } else {
477 + $download_url = $this->get_download_url( $item['guid'] );
478 + }
479 +
345 480 if ( is_wp_error( $download_url ) ) {
346 481 $responses[] = $download_url;
347 482 continue;
348 483 }
@@ -430,36 +565,340 @@
430 565 return json_decode( wp_remote_retrieve_body( $response ), true );
431 566 }
432 567
433 568 /**
434 - * Filter callback to provide a shorter file name for google images.
569 + * Gets Google Photos Picker enabled Status.
435 570 *
436 - * @return string
571 + * @param \WP_REST_Request $request Full details about the request.
572 + * @return array|\WP_Error|mixed
437 573 */
438 - public function tmp_name() {
439 - return $this->tmp_name;
574 + public function get_picker_status( \WP_REST_Request $request ) {
575 + $service = $request->get_param( 'service' );
576 + $wpcom_path = sprintf( '/meta/external-media/connection/%s/picker_status', rawurlencode( $service ) );
577 +
578 + if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
579 + $internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
580 + $internal_request->set_query_params( $request->get_params() );
581 +
582 + return rest_do_request( $internal_request );
583 + }
584 +
585 + $response = Client::wpcom_json_api_request_as_user(
586 + $wpcom_path,
587 + '2',
588 + array(
589 + 'method' => 'GET',
590 + )
591 + );
592 +
593 + return json_decode( wp_remote_retrieve_body( $response ), true );
440 594 }
441 595
442 596 /**
443 - * Returns a download URL, dealing with Google's long file names.
597 + * Creates a new session for a service.
444 598 *
599 + * @param \WP_REST_Request $request Full details about the request.
600 + * @return array|\WP_Error|mixed
601 + */
602 + public function create_session( \WP_REST_Request $request ) {
603 + $service = $request->get_param( 'service' );
604 + $wpcom_path = sprintf( '/meta/external-media/session/%s', rawurlencode( $service ) );
605 +
606 + if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
607 + $internal_request = new \WP_REST_Request( 'POST', '/' . $this->namespace . $wpcom_path );
608 + $internal_request->set_query_params( $request->get_params() );
609 +
610 + return rest_do_request( $internal_request );
611 + }
612 +
613 + $response = Client::wpcom_json_api_request_as_user(
614 + $wpcom_path,
615 + '2',
616 + array(
617 + 'method' => 'POST',
618 + )
619 + );
620 +
621 + return json_decode( wp_remote_retrieve_body( $response ), true );
622 + }
623 +
624 + /**
625 + * Gets a session for a service.
626 + *
627 + * @param \WP_REST_Request $request Full details about the request.
628 + * @return array|\WP_Error|mixed
629 + */
630 + public function get_session( \WP_REST_Request $request ) {
631 + $service = $request->get_param( 'service' );
632 + $session_id = $request->get_param( 'session_id' );
633 + $wpcom_path = sprintf( '/meta/external-media/session/%s/%s', rawurlencode( $service ), rawurlencode( $session_id ) );
634 +
635 + if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
636 + $internal_request = new \WP_REST_Request( 'GET', '/' . $this->namespace . $wpcom_path );
637 + $internal_request->set_query_params( $request->get_params() );
638 +
639 + return rest_do_request( $internal_request );
640 + }
641 +
642 + $response = Client::wpcom_json_api_request_as_user(
643 + $wpcom_path,
644 + '2',
645 + array(
646 + 'method' => 'GET',
647 + )
648 + );
649 +
650 + return json_decode( wp_remote_retrieve_body( $response ), true );
651 + }
652 +
653 + /**
654 + * Deletes a session for a service.
655 + *
656 + * @param \WP_REST_Request $request Full details about the request.
657 + * @return array|\WP_Error|mixed
658 + */
659 + public function delete_session( \WP_REST_Request $request ) {
660 + $service = $request->get_param( 'service' );
661 + $session_id = $request->get_param( 'session_id' );
662 + $wpcom_path = sprintf( '/meta/external-media/session/%s/%s', rawurlencode( $service ), rawurlencode( $session_id ) );
663 +
664 + if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
665 + $internal_request = new \WP_REST_Request( 'DELETE', '/' . $this->namespace . $wpcom_path );
666 + $internal_request->set_query_params( $request->get_params() );
667 +
668 + return rest_do_request( $internal_request );
669 + }
670 +
671 + $response = Client::wpcom_json_api_request_as_user(
672 + $wpcom_path,
673 + '2',
674 + array(
675 + 'method' => 'DELETE',
676 + )
677 + );
678 +
679 + return json_decode( wp_remote_retrieve_body( $response ), true );
680 + }
681 +
682 + /**
683 + * Proxies media requests with proper authorization headers
684 + *
685 + * @param WP_REST_Request $request Full details about the request.
686 + * @return WP_REST_Response|WP_Error|array Response object or WP_Error.
687 + */
688 + public function proxy_media_request( $request ) {
689 + $params = $request->get_params();
690 + $service = rawurlencode( $request->get_param( 'service' ) );
691 + $wpcom_path = sprintf( '/meta/external-media/proxy/%s', $service );
692 +
693 + if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
694 + $request = new \WP_REST_Request( 'POST', '/' . $this->namespace . $wpcom_path );
695 + $request->set_query_params( $params );
696 +
697 + return rest_do_request( $request );
698 +
699 + } else {
700 + // Build query string to pass to wpcom endpoint.
701 + $service_args = array_filter(
702 + $params,
703 + function ( $key ) {
704 + return in_array( $key, array( 'url' ), true );
705 + },
706 + ARRAY_FILTER_USE_KEY
707 + );
708 +
709 + if ( ! empty( $service_args ) ) {
710 + $wpcom_path .= '?' . http_build_query( $service_args );
711 + }
712 +
713 + $response = Client::wpcom_json_api_request_as_user(
714 + $wpcom_path,
715 + '2',
716 + array(
717 + 'method' => 'POST',
718 + )
719 + );
720 +
721 + $status_code = wp_remote_retrieve_response_code( $response );
722 + $headers = wp_remote_retrieve_headers( $response );
723 + $body = wp_remote_retrieve_body( $response );
724 +
725 + // For non-200 responses, parse and return JSON error
726 + if ( $status_code !== 200 ) {
727 + $error_data = json_decode( $body, true );
728 + return new \WP_REST_Response( $error_data, $status_code );
729 + }
730 + }
731 +
732 + // Return binary content directly
733 + $valid_headers = array(
734 + 'content-type',
735 + 'content-length',
736 + 'content-disposition',
737 + );
738 + // Set content headers
739 + foreach ( $valid_headers as $header ) {
740 + if ( ! empty( $headers[ $header ] ) ) {
741 + header( ucwords( $header, '-' ) . ': ' . $headers[ $header ] );
742 + }
743 + }
744 +
745 + // Set cache headers
746 + header( 'Cache-Control: no-cache, no-store, must-revalidate' );
747 + header( 'Pragma: no-cache' );
748 + header( 'Expires: 0' );
749 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Media binary data
750 + echo $body;
751 + exit( 0 );
752 + }
753 +
754 + /**
755 + * Downloads a remote media file into a temporary file for sideloading.
756 + *
757 + * The URL is checked against Utils::url_is_public() before the fetch and again
758 + * on every redirect hop, the same rule the resolve-redirect endpoint applies.
759 + *
760 + * The remote file is streamed into a randomly-named temporary file created by
761 + * wp_tempnam(). The caller-supplied name is never used for the temporary file
762 + * itself; it is only applied — and validated by WordPress — later, when the
763 + * completed download is handed to media_handle_sideload(). This prevents a
764 + * crafted name from controlling the physical path or extension of the file
765 + * written to disk.
766 + *
445 767 * @param array $guid Media information.
446 - * @return string|\WP_Error
768 + * @return string|\WP_Error Path to the downloaded temporary file, or WP_Error on failure.
447 769 */
448 770 public function get_download_url( $guid ) {
449 - $this->tmp_name = $guid['name'];
450 - add_filter( 'wp_unique_filename', array( $this, 'tmp_name' ) );
451 - $download_url = download_url( $guid['url'] );
452 - remove_filter( 'wp_unique_filename', array( $this, 'tmp_name' ) );
771 + require_once ABSPATH . 'wp-admin/includes/file.php';
453 772
454 - if ( is_wp_error( $download_url ) ) {
455 - $download_url->add_data( array( 'status' => 400 ) );
773 + $url = isset( $guid['url'] ) && is_string( $guid['url'] ) ? $guid['url'] : '';
774 +
775 + if ( ! $this->url_is_public( $url ) ) {
776 + return $this->download_failed_error();
456 777 }
457 778
458 - return $download_url;
779 + $tmp_name = wp_tempnam();
780 + if ( ! $tmp_name ) {
781 + return new WP_Error(
782 + 'rest_upload_error',
783 + __( 'Could not create a temporary file.', 'jetpack' ),
784 + array( 'status' => 500 )
785 + );
786 + }
787 +
788 + $result = $this->stream_to_temp_file( $url, $tmp_name );
789 +
790 + if ( is_wp_error( $result ) ) {
791 + wp_delete_file( $tmp_name );
792 + }
793 +
794 + return $result;
459 795 }
460 796
461 797 /**
798 + * Streams an already-validated URL into a temporary file, following redirects.
799 + *
800 + * Each hop is fetched with `redirection => 0` and re-checked with
801 + * Utils::url_is_public() before the next request. The caller owns $tmp_name and
802 + * deletes it when this returns an error.
803 + *
804 + * @param string $url Validated URL to download.
805 + * @param string $tmp_name Path of the temporary file to stream into.
806 + * @return string|\WP_Error $tmp_name on success, WP_Error on failure.
807 + */
808 + private function stream_to_temp_file( $url, $tmp_name ) {
809 + // One budget for the whole chain: WordPress used to apply the timeout across
810 + // the redirects it followed itself, and following them here must not multiply
811 + // how long a single import can hold a request open.
812 + $deadline = microtime( true ) + self::DOWNLOAD_TIMEOUT;
813 +
814 + for ( $hop = 0; $hop <= self::MAX_REDIRECTS; $hop++ ) {
815 + $remaining = (int) ceil( $deadline - microtime( true ) );
816 + if ( $remaining < 1 ) {
817 + return $this->download_failed_error();
818 + }
819 +
820 + $response = wp_safe_remote_get(
821 + $url,
822 + array(
823 + 'timeout' => $remaining,
824 + 'stream' => true,
825 + 'filename' => $tmp_name,
826 + // Do not let WordPress follow redirects for us; we validate each hop first.
827 + 'redirection' => 0,
828 + )
829 + );
830 +
831 + if ( is_wp_error( $response ) ) {
832 + return $this->download_failed_error();
833 + }
834 +
835 + $status = (int) wp_remote_retrieve_response_code( $response );
836 +
837 + if ( $status < 300 || $status >= 400 ) {
838 + return 200 === $status ? $tmp_name : $this->download_failed_error();
839 + }
840 +
841 + // Budget exhausted: stop before validating a destination we will never fetch.
842 + if ( self::MAX_REDIRECTS === $hop ) {
843 + break;
844 + }
845 +
846 + $location = wp_remote_retrieve_header( $response, 'location' );
847 +
848 + // Multiple Location headers: follow the last, as core does.
849 + if ( is_array( $location ) ) {
850 + $location = end( $location );
851 + }
852 +
853 + // Location may be relative; resolve it against the current URL.
854 + $next_url = is_string( $location ) && '' !== $location
855 + ? WP_Http::make_absolute_url( $location, $url )
856 + : '';
857 +
858 + if ( ! is_string( $next_url ) || ! $this->url_is_public( $next_url ) ) {
859 + return $this->download_failed_error();
860 + }
861 +
862 + $url = $next_url;
863 + }
864 +
865 + return $this->download_failed_error();
866 + }
867 +
868 + /**
869 + * Checks whether a URL is a public destination for a media download.
870 + *
871 + * An older jetpack-ip without url_is_public() may win the autoloader; that case
872 + * falls back to core's check, the same one wp_safe_remote_get() applies.
873 + *
874 + * @param string $url URL to check.
875 + * @return bool
876 + */
877 + private function url_is_public( $url ) {
878 + if ( method_exists( Utils::class, 'url_is_public' ) ) {
879 + return Utils::url_is_public( $url );
880 + }
881 +
882 + return (bool) wp_http_validate_url( $url );
883 + }
884 +
885 + /**
886 + * Builds the WP_Error returned when a media file cannot be downloaded.
887 + *
888 + * Every failed download shares this one generic error.
889 + *
890 + * @return WP_Error
891 + */
892 + private function download_failed_error() {
893 + return new WP_Error(
894 + 'rest_upload_error',
895 + __( 'Could not download the media file.', 'jetpack' ),
896 + array( 'status' => 400 )
897 + );
898 + }
899 +
900 + /**
462 901 * Uploads media file and creates attachment object.
463 902 *
464 903 * @param string $file_name Name of media file.
465 904 * @param string $download_url Download URL.
@@ -468,9 +907,9 @@
468 907 * @return int|\WP_Error
469 908 */
470 909 public function sideload_media( $file_name, $download_url, $post_id = 0 ) {
471 910 $file = array(
472 - 'name' => wp_basename( $file_name ),
911 + 'name' => sanitize_file_name( wp_basename( $file_name ) ),
473 912 'tmp_name' => $download_url,
474 913 );
475 914
476 915 $id = media_handle_sideload( $file, $post_id, null );
@@ -537,8 +976,24 @@
537 976 );
538 977 }
539 978
540 979 return $response;
980 + }
981 +
982 + /**
983 + * Get the wp filesystem.
984 + *
985 + * @return \WP_Filesystem_Base|null
986 + */
987 + private function get_wp_filesystem() {
988 + global $wp_filesystem;
989 +
990 + if ( ! isset( $wp_filesystem ) ) {
991 + require_once ABSPATH . '/wp-admin/includes/file.php';
992 + WP_Filesystem();
993 + }
994 +
995 + return $wp_filesystem;
541 996 }
542 997 }
543 998
544 999 wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_External_Media' );