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 +130 -18 16.3-beta → 16.3 View file →
@@ -7,8 +7,9 @@
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
12 13 if ( ! defined( 'ABSPATH' ) ) {
13 14 exit( 0 );
14 15 }
@@ -20,8 +21,25 @@
20 21 */
21 22 class WPCOM_REST_API_V2_Endpoint_External_Media extends WP_REST_Controller {
22 23
23 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 + /**
24 42 * Media argument schema for /copy endpoint.
25 43 *
26 44 * @var array
27 45 */
@@ -735,8 +753,11 @@
735 753
736 754 /**
737 755 * Downloads a remote media file into a temporary file for sideloading.
738 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 + *
739 760 * The remote file is streamed into a randomly-named temporary file created by
740 761 * wp_tempnam(). The caller-supplied name is never used for the temporary file
741 762 * itself; it is only applied — and validated by WordPress — later, when the
742 763 * completed download is handed to media_handle_sideload(). This prevents a
@@ -748,8 +769,14 @@
748 769 */
749 770 public function get_download_url( $guid ) {
750 771 require_once ABSPATH . 'wp-admin/includes/file.php';
751 772
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();
777 + }
778 +
752 779 $tmp_name = wp_tempnam();
753 780 if ( ! $tmp_name ) {
754 781 return new WP_Error(
755 782 'rest_upload_error',
@@ -757,33 +784,118 @@
757 784 array( 'status' => 500 )
758 785 );
759 786 }
760 787
761 - $response = wp_safe_remote_get(
762 - $guid['url'],
763 - array(
764 - 'timeout' => 300,
765 - 'stream' => true,
766 - 'filename' => $tmp_name,
767 - )
768 - );
788 + $result = $this->stream_to_temp_file( $url, $tmp_name );
769 789
770 - if ( is_wp_error( $response ) ) {
790 + if ( is_wp_error( $result ) ) {
771 791 wp_delete_file( $tmp_name );
772 - $response->add_data( array( 'status' => 400 ) );
773 - return $response;
774 792 }
775 793
776 - if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
777 - wp_delete_file( $tmp_name );
778 - return new WP_Error(
779 - 'rest_upload_error',
780 - __( 'Could not download the media file.', 'jetpack' ),
781 - array( 'status' => 400 )
794 + return $result;
795 + }
796 +
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 + )
782 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;
783 863 }
784 864
785 - return $tmp_name;
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 + );
786 898 }
787 899
788 900 /**
789 901 * Uploads media file and creates attachment object.