Make WordPress Core

Changeset 62659


Ignore:
Timestamp:
07/07/2026 10:12:17 PM (3 months ago)
Author:
adamsilverstein
Message:

REST API: Sideload external images via a url parameter.

Extend the attachments endpoint (POST /wp/v2/media) to accept an optional url parameter. When a request supplies a url, WP_REST_Attachments_Controller::create_item() routes it through a new create_item_from_url() method that downloads the remote file with download_url() and stores it with media_handle_sideload(), rather than the browser fetching the bytes and posting a blob. The existing uploaded-file path is unchanged when no url is supplied, and the sub-size and scaling filters continue to govern derivative generation.

See related Gutenberg pull request: ​https://github.com/WordPress/gutenberg/pull/79409 and issue: ​https://github.com/WordPress/gutenberg/issues/79407.

Props swissspidy, khokansardar, westonruter.
Fixes #65517.

Location:
trunk
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php

    r62630 r62659  
    249249                                'description' => __( 'Whether to convert image formats.' ),
    250250                        );
     251                        $args['url']                = array(
     252                                'type'              => 'string',
     253                                'format'            => 'uri',
     254                                'description'       => __( 'URL of an external image to sideload into the media library, instead of uploading a file.' ),
     255                                'sanitize_callback' => 'sanitize_url',
     256                                'validate_callback' => static function ( $url, $request, $param ) {
     257                                        /*
     258                                         * A custom validate_callback replaces the default
     259                                         * rest_validate_request_arg(), so re-apply it first to keep
     260                                         * the schema checks (string type, uri format) enforced.
     261                                         */
     262                                        $valid = rest_validate_request_arg( $url, $request, $param );
     263                                        if ( is_wp_error( $valid ) ) {
     264                                                return $valid;
     265                                        }
     266
     267                                        /*
     268                                         * Reject URLs that are not safe to request server-side. wp_http_validate_url()
     269                                         * enforces an HTTP(S) scheme and blocks private, local, and otherwise
     270                                         * disallowed hosts, guarding the sideload against SSRF.
     271                                         */
     272                                        if ( false === wp_http_validate_url( $url ) ) {
     273                                                return new WP_Error(
     274                                                        'rest_invalid_url',
     275                                                        __( 'Invalid URL. Provide a valid, publicly reachable HTTP or HTTPS image URL.' ),
     276                                                        array( 'status' => 400 )
     277                                                );
     278                                        }
     279
     280                                        return true;
     281                                },
     282                        );
    251283                }
    252284
    … …  
    407439         *
    408440         * @since 4.7.0
    409          * @since 7.1.0 Added `generate_sub_sizes` and `convert_format` parameters.
     441         * @since 7.1.0 Added the `generate_sub_sizes`, `convert_format`, and `url` parameters.
    410442         *
    411443         * @param WP_REST_Request $request Full details about the request.
    … …  
    433465                if ( false === $request['convert_format'] ) {
    434466                        add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
     467                }
     468
     469                /*
     470                 * When a URL is supplied instead of an uploaded file, sideload the
     471                 * remote image on the server. This avoids a cross-origin browser fetch,
     472                 * which fails under cross-origin isolation. The sub-size and scaling
     473                 * filters applied above still govern whether derivatives are generated.
     474                 */
     475                if ( ! empty( $request['url'] ) ) {
     476                        $response = $this->create_item_from_url( $request );
     477                        $this->remove_client_side_media_processing_filters();
     478                        return $response;
    435479                }
    436480
    … …  
    524568                $response->set_status( 201 );
    525569                $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $attachment_id ) ) );
     570
     571                return $response;
     572        }
     573
     574        /**
     575         * Sideloads an external image from a URL into the media library.
     576         *
     577         * Downloads the remote file on the server, avoiding a cross-origin browser
     578         * fetch that fails under cross-origin isolation. Whether sub-sizes are
     579         * generated is governed by the filters applied in create_item().
     580         *
     581         * @since 7.1.0
     582         *
     583         * @param WP_REST_Request $request Full details about the request.
     584         * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure.
     585         */
     586        protected function create_item_from_url( $request ) {
     587                // Sideloading downloads and stores a file, so require the upload capability.
     588                if ( ! current_user_can( 'upload_files' ) ) {
     589                        return new WP_Error(
     590                                'rest_cannot_create',
     591                                __( 'Sorry, you are not allowed to upload media on this site.' ),
     592                                array( 'status' => rest_authorization_required_code() )
     593                        );
     594                }
     595
     596                require_once ABSPATH . 'wp-admin/includes/file.php';
     597                require_once ABSPATH . 'wp-admin/includes/media.php';
     598                require_once ABSPATH . 'wp-admin/includes/image.php';
     599
     600                $url     = $request['url'];
     601                $post_id = ! empty( $request['post'] ) ? (int) $request['post'] : 0;
     602
     603                // Derive the filename from the URL path before downloading anything.
     604                $url_path = wp_parse_url( $url, PHP_URL_PATH );
     605                $filename = $url_path ? wp_basename( $url_path ) : '';
     606                if ( '' === $filename ) {
     607                        return new WP_Error(
     608                                'rest_invalid_url',
     609                                __( 'Could not determine a filename from the provided URL.' ),
     610                                array( 'status' => 400 )
     611                        );
     612                }
     613
     614                /*
     615                 * Only download URLs whose extension maps to an allowed image MIME type.
     616                 * The sideload handler would reject other types anyway (via
     617                 * wp_check_filetype_and_ext()), but checking first avoids downloading
     618                 * files that can never be accepted, such as PHP scripts.
     619                 */
     620                $filetype = wp_check_filetype( $filename );
     621                if ( ! $filetype['type'] || ! str_starts_with( $filetype['type'], 'image/' ) ) {
     622                        return new WP_Error(
     623                                'rest_invalid_url',
     624                                __( 'The provided URL does not point to a supported image file.' ),
     625                                array( 'status' => 400 )
     626                        );
     627                }
     628
     629                /*
     630                 * Download the remote file with WordPress's HTTP API, which validates
     631                 * the host and blocks requests to private or local addresses. This is
     632                 * the same primitive core's media_sideload_image() relies on.
     633                 */
     634                $tmp_file = download_url( $url );
     635                if ( is_wp_error( $tmp_file ) ) {
     636                        return $tmp_file;
     637                }
     638
     639                $file_array = array(
     640                        'name'     => $filename,
     641                        'tmp_name' => $tmp_file,
     642                );
     643
     644                $attachment_id = media_handle_sideload( $file_array, $post_id );
     645
     646                if ( is_wp_error( $attachment_id ) ) {
     647                        /*
     648                         * media_handle_sideload() deletes the temp file on success; remove
     649                         * it explicitly when the sideload fails.
     650                         */
     651                        if ( file_exists( $tmp_file ) ) {
     652                                wp_delete_file( $tmp_file );
     653                        }
     654                        return $attachment_id;
     655                }
     656
     657                $attachment = get_post( $attachment_id );
     658
     659                $request->set_param( 'context', 'edit' );
     660
     661                /*
     662                 * media_handle_sideload() fires the standard insert hooks (including
     663                 * wp_after_insert_post), but not the REST-specific action, so fire it
     664                 * here for parity with the uploaded-file path in create_item().
     665                 */
     666                /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php */
     667                do_action( 'rest_after_insert_attachment', $attachment, $request, true );
     668
     669                $response = $this->prepare_item_for_response( $attachment, $request );
     670                $response->set_status( 201 );
     671                $response->header( 'Location', rest_url( rest_get_route_for_post( $attachment_id ) ) );
    526672
    527673                return $response;
  • trunk/tests/phpunit/tests/rest-api/rest-attachments-controller.php

    r62630 r62659  
    46294629                $this->assertSame( 400, $response->get_status(), 'An unknown size name should be rejected.' );
    46304630        }
     4631
     4632        /**
     4633         * The URL requested by the most recent mocked HTTP download.
     4634         *
     4635         * @var string|null
     4636         */
     4637        protected $last_download_url = null;
     4638
     4639        /**
     4640         * Short-circuits download_url()'s HTTP request, writing a local fixture into
     4641         * the streamed temp file so media_handle_sideload() has a real image to process.
     4642         *
     4643         * Mirrors the approach core's media_sideload_image() tests use: returning a
     4644         * non-false value from `pre_http_request` skips the network, so the mock must
     4645         * copy the fixture into the `filename` the request would have streamed to.
     4646         *
     4647         * @param false|array|WP_Error $response A preempted response, or false to continue.
     4648         * @param array                $args     HTTP request arguments.
     4649         * @param string               $url      The request URL.
     4650         * @return array A faked 200 response.
     4651         */
     4652        public function mock_image_download( $response, $args, $url ) {
     4653                $this->last_download_url = $url;
     4654
     4655                if ( ! empty( $args['filename'] ) ) {
     4656                        copy( DIR_TESTDATA . '/images/canola.jpg', $args['filename'] );
     4657                }
     4658
     4659                return array(
     4660                        'response' => array(
     4661                                'code'    => 200,
     4662                                'message' => 'OK',
     4663                        ),
     4664                        'headers'  => array(),
     4665                        'cookies'  => array(),
     4666                        'body'     => '',
     4667                );
     4668        }
     4669
     4670        /**
     4671         * Verifies that supplying a `url` to the create endpoint sideloads the remote
     4672         * image on the server and, with generate_sub_sizes=false, creates no sub-sizes.
     4673         *
     4674         * This is the cross-origin-isolation fallback path: the server fetches the
     4675         * remote image so the browser does not have to, and only the original is kept.
     4676         *
     4677         * @ticket 65517
     4678         *
     4679         * @covers WP_REST_Attachments_Controller::create_item
     4680         * @covers WP_REST_Attachments_Controller::create_item_from_url
     4681         */
     4682        public function test_create_item_from_url_sideloads_without_subsizes() {
     4683                $this->enable_client_side_media_processing();
     4684
     4685                wp_set_current_user( self::$superadmin_id );
     4686
     4687                add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 );
     4688
     4689                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     4690                $request->set_param( 'url', 'https://example.com/photo.jpg' );
     4691                $request->set_param( 'generate_sub_sizes', false );
     4692
     4693                $response = rest_get_server()->dispatch( $request );
     4694
     4695                remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 );
     4696
     4697                $data = $response->get_data();
     4698
     4699                $this->assertSame( 201, $response->get_status() );
     4700                $this->assertSame( 'image', $data['media_type'] );
     4701                $this->assertSame( 'https://example.com/photo.jpg', $this->last_download_url );
     4702
     4703                // No sub-sizes should have been generated; only the original is stored.
     4704                $metadata = wp_get_attachment_metadata( $data['id'], true );
     4705                $this->assertEmpty( $metadata['sizes'] ?? array(), 'Sideloaded external image should have no sub-sizes.' );
     4706        }
     4707
     4708        /**
     4709         * Verifies that, with the default generate_sub_sizes (true), sideloading an
     4710         * external image generates sub-sizes, so the filters applied in create_item()
     4711         * still govern derivative generation on the URL path.
     4712         *
     4713         * @ticket 65517
     4714         *
     4715         * @covers WP_REST_Attachments_Controller::create_item
     4716         * @covers WP_REST_Attachments_Controller::create_item_from_url
     4717         */
     4718        public function test_create_item_from_url_generates_subsizes_by_default() {
     4719                $this->enable_client_side_media_processing();
     4720
     4721                wp_set_current_user( self::$superadmin_id );
     4722
     4723                add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 );
     4724
     4725                // Note: generate_sub_sizes is intentionally not set, so it defaults to true.
     4726                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     4727                $request->set_param( 'url', 'https://example.com/full.jpg' );
     4728
     4729                $response = rest_get_server()->dispatch( $request );
     4730
     4731                remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 );
     4732
     4733                $data = $response->get_data();
     4734
     4735                $this->assertSame( 201, $response->get_status() );
     4736
     4737                $metadata = wp_get_attachment_metadata( $data['id'], true );
     4738                $this->assertNotEmpty( $metadata['sizes'] ?? array(), 'Sub-sizes should be generated when generate_sub_sizes is true.' );
     4739        }
     4740
     4741        /**
     4742         * Verifies that the REST-specific rest_after_insert_attachment action fires on
     4743         * the URL sideload path, for parity with the uploaded-file path.
     4744         *
     4745         * @ticket 65517
     4746         *
     4747         * @covers WP_REST_Attachments_Controller::create_item_from_url
     4748         */
     4749        public function test_create_item_from_url_fires_rest_after_insert_attachment() {
     4750                $this->enable_client_side_media_processing();
     4751
     4752                wp_set_current_user( self::$superadmin_id );
     4753
     4754                $fired = array();
     4755                $spy   = static function ( $attachment, $request, $creating ) use ( &$fired ) {
     4756                        $fired = array(
     4757                                'id'       => $attachment->ID,
     4758                                'creating' => $creating,
     4759                        );
     4760                };
     4761
     4762                add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 );
     4763                add_action( 'rest_after_insert_attachment', $spy, 10, 3 );
     4764
     4765                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     4766                $request->set_param( 'url', 'https://example.com/hooked.jpg' );
     4767                $request->set_param( 'generate_sub_sizes', false );
     4768
     4769                $response = rest_get_server()->dispatch( $request );
     4770
     4771                remove_action( 'rest_after_insert_attachment', $spy, 10 );
     4772                remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 );
     4773
     4774                $data = $response->get_data();
     4775
     4776                $this->assertSame( 201, $response->get_status() );
     4777                $this->assertSame( $data['id'], $fired['id'] ?? null, 'rest_after_insert_attachment should fire with the new attachment.' );
     4778                $this->assertTrue( $fired['creating'] ?? null, 'rest_after_insert_attachment should report creating=true.' );
     4779        }
     4780
     4781        /**
     4782         * Verifies that a sideloaded external image is attached to the post passed in
     4783         * the `post` parameter.
     4784         *
     4785         * @ticket 65517
     4786         *
     4787         * @covers WP_REST_Attachments_Controller::create_item
     4788         * @covers WP_REST_Attachments_Controller::create_item_from_url
     4789         */
     4790        public function test_create_item_from_url_attaches_to_post() {
     4791                $this->enable_client_side_media_processing();
     4792
     4793                wp_set_current_user( self::$superadmin_id );
     4794
     4795                $parent_post = self::factory()->post->create();
     4796
     4797                add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 );
     4798
     4799                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     4800                $request->set_param( 'url', 'https://example.com/attached.jpg' );
     4801                $request->set_param( 'generate_sub_sizes', false );
     4802                $request->set_param( 'post', $parent_post );
     4803
     4804                $response = rest_get_server()->dispatch( $request );
     4805
     4806                remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 );
     4807
     4808                $data = $response->get_data();
     4809
     4810                $this->assertSame( 201, $response->get_status() );
     4811                $this->assertSame( $parent_post, get_post( $data['id'] )->post_parent );
     4812        }
     4813
     4814        /**
     4815         * Verifies that a failed download propagates the WP_Error from download_url()
     4816         * rather than creating an attachment.
     4817         *
     4818         * @ticket 65517
     4819         *
     4820         * @covers WP_REST_Attachments_Controller::create_item
     4821         * @covers WP_REST_Attachments_Controller::create_item_from_url
     4822         */
     4823        public function test_create_item_from_url_returns_error_on_download_failure() {
     4824                $this->enable_client_side_media_processing();
     4825
     4826                wp_set_current_user( self::$superadmin_id );
     4827
     4828                $fail_download = static function () {
     4829                        return new WP_Error( 'http_request_failed', 'Could not resolve host.' );
     4830                };
     4831                add_filter( 'pre_http_request', $fail_download );
     4832
     4833                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     4834                $request->set_param( 'url', 'https://example.com/missing.jpg' );
     4835                $request->set_param( 'generate_sub_sizes', false );
     4836
     4837                $response = rest_get_server()->dispatch( $request );
     4838
     4839                remove_filter( 'pre_http_request', $fail_download );
     4840
     4841                $this->assertSame( 'http_request_failed', $response->get_data()['code'] );
     4842                $this->assertSame( 500, $response->get_status() );
     4843        }
     4844
     4845        /**
     4846         * Verifies that a URL with no usable path bails with a 400 before any
     4847         * download is attempted, rather than handing an empty filename to the
     4848         * sideload handler.
     4849         *
     4850         * @ticket 65517
     4851         *
     4852         * @covers WP_REST_Attachments_Controller::create_item_from_url
     4853         */
     4854        public function test_create_item_from_url_rejects_url_without_filename() {
     4855                $this->enable_client_side_media_processing();
     4856
     4857                wp_set_current_user( self::$superadmin_id );
     4858
     4859                // Fail loudly if the guard does not bail and a download is attempted.
     4860                $downloaded = false;
     4861                $track      = static function () use ( &$downloaded ) {
     4862                        $downloaded = true;
     4863                        return new WP_Error( 'http_request_failed', 'Should not be reached.' );
     4864                };
     4865                add_filter( 'pre_http_request', $track );
     4866
     4867                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     4868                $request->set_param( 'url', 'https://example.com/?img=123' );
     4869
     4870                $response = rest_get_server()->dispatch( $request );
     4871
     4872                remove_filter( 'pre_http_request', $track );
     4873
     4874                $this->assertSame( 'rest_invalid_url', $response->get_data()['code'] );
     4875                $this->assertSame( 400, $response->get_status() );
     4876                $this->assertFalse( $downloaded, 'No download should be attempted for a URL without a filename.' );
     4877        }
     4878
     4879        /**
     4880         * Verifies that a URL pointing to a file without an allowed image extension,
     4881         * such as a PHP script, is rejected before any download is attempted.
     4882         *
     4883         * @ticket 65517
     4884         *
     4885         * @dataProvider data_create_item_from_url_rejects_non_image_extension
     4886         *
     4887         * @covers WP_REST_Attachments_Controller::create_item_from_url
     4888         *
     4889         * @param string $url URL with a disallowed file extension.
     4890         */
     4891        public function test_create_item_from_url_rejects_non_image_extension( $url ) {
     4892                $this->enable_client_side_media_processing();
     4893
     4894                wp_set_current_user( self::$superadmin_id );
     4895
     4896                // Fail loudly if the guard does not bail and a download is attempted.
     4897                $downloaded = false;
     4898                $track      = static function () use ( &$downloaded ) {
     4899                        $downloaded = true;
     4900                        return new WP_Error( 'http_request_failed', 'Should not be reached.' );
     4901                };
     4902                add_filter( 'pre_http_request', $track );
     4903
     4904                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     4905                $request->set_param( 'url', $url );
     4906
     4907                $response = rest_get_server()->dispatch( $request );
     4908
     4909                remove_filter( 'pre_http_request', $track );
     4910
     4911                $this->assertSame( 'rest_invalid_url', $response->get_data()['code'] );
     4912                $this->assertSame( 400, $response->get_status() );
     4913                $this->assertFalse( $downloaded, 'No download should be attempted for a non-image URL.' );
     4914        }
     4915
     4916        /**
     4917         * Data provider for test_create_item_from_url_rejects_non_image_extension().
     4918         *
     4919         * @return array[]
     4920         */
     4921        public function data_create_item_from_url_rejects_non_image_extension() {
     4922                return array(
     4923                        'PHP script'       => array( 'https://example.com/evil.php' ),
     4924                        'HTML document'    => array( 'https://example.com/page.html' ),
     4925                        'video file'       => array( 'https://example.com/clip.mp4' ),
     4926                        'no extension'     => array( 'https://example.com/image' ),
     4927                        'double extension' => array( 'https://example.com/photo.jpg.php' ),
     4928                );
     4929        }
     4930
     4931        /**
     4932         * Verifies that a user without the `upload_files` capability cannot sideload
     4933         * an external image and that the request bails before any download happens.
     4934         *
     4935         * @ticket 65517
     4936         *
     4937         * @covers WP_REST_Attachments_Controller::create_item_from_url
     4938         */
     4939        public function test_create_item_from_url_requires_upload_capability() {
     4940                $subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
     4941                wp_set_current_user( $subscriber_id );
     4942
     4943                // Fail loudly if the guard does not bail and a download is attempted.
     4944                $downloaded = false;
     4945                $track      = static function () use ( &$downloaded ) {
     4946                        $downloaded = true;
     4947                        return new WP_Error( 'http_request_failed', 'Should not be reached.' );
     4948                };
     4949                add_filter( 'pre_http_request', $track );
     4950
     4951                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     4952                $request->set_param( 'url', 'https://example.com/denied.jpg' );
     4953
     4954                $controller = new WP_REST_Attachments_Controller( 'attachment' );
     4955                $method     = new ReflectionMethod( $controller, 'create_item_from_url' );
     4956                if ( PHP_VERSION_ID < 80100 ) {
     4957                        $method->setAccessible( true );
     4958                }
     4959                $result = $method->invoke( $controller, $request );
     4960
     4961                remove_filter( 'pre_http_request', $track );
     4962
     4963                $this->assertWPError( $result );
     4964                $this->assertSame( 'rest_cannot_create', $result->get_error_code() );
     4965                $this->assertSame( 403, $result->get_error_data()['status'] );
     4966                $this->assertFalse( $downloaded, 'No download should be attempted without upload_files.' );
     4967        }
     4968
     4969        /**
     4970         * Verifies that schema validation still applies to the `url` argument even
     4971         * though it registers a custom `validate_callback`, which replaces the
     4972         * default rest_validate_request_arg() unless re-applied.
     4973         *
     4974         * @ticket 65517
     4975         *
     4976         * @covers WP_REST_Attachments_Controller::get_endpoint_args_for_item_schema
     4977         */
     4978        public function test_create_item_from_url_rejects_non_string_url() {
     4979                $this->enable_client_side_media_processing();
     4980
     4981                wp_set_current_user( self::$superadmin_id );
     4982
     4983                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     4984                $request->set_param( 'url', array( 'https://example.com/image.jpg' ) );
     4985
     4986                $response = rest_get_server()->dispatch( $request );
     4987
     4988                $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] );
     4989                $this->assertSame( 400, $response->get_status() );
     4990        }
     4991
     4992        /**
     4993         * Verifies that the `url` argument is registered on the creatable media route
     4994         * so requests can supply an external image URL to sideload.
     4995         *
     4996         * @ticket 65517
     4997         *
     4998         * @covers WP_REST_Attachments_Controller::get_endpoint_args_for_item_schema
     4999         */
     5000        public function test_url_registered_as_creatable_arg() {
     5001                $this->enable_client_side_media_processing();
     5002
     5003                $routes = rest_get_server()->get_routes();
     5004                $this->assertArrayHasKey( '/wp/v2/media', $routes );
     5005
     5006                $creatable = null;
     5007                foreach ( $routes['/wp/v2/media'] as $route ) {
     5008                        if ( ! empty( $route['methods'][ WP_REST_Server::CREATABLE ] ) ) {
     5009                                $creatable = $route;
     5010                                break;
     5011                        }
     5012                }
     5013
     5014                $this->assertNotNull( $creatable, 'The media route should register a CREATABLE handler.' );
     5015                $this->assertArrayHasKey( 'url', $creatable['args'] );
     5016                $this->assertSame( 'string', $creatable['args']['url']['type'] );
     5017                $this->assertSame( 'uri', $creatable['args']['url']['format'] );
     5018        }
     5019
     5020        /**
     5021         * Verifies that the `url` argument rejects values that are not safe to
     5022         * request server-side, guarding the sideload against SSRF.
     5023         *
     5024         * @ticket 65517
     5025         *
     5026         * @covers WP_REST_Attachments_Controller::get_endpoint_args_for_item_schema
     5027         */
     5028        public function test_url_arg_rejects_unsafe_urls() {
     5029                $this->enable_client_side_media_processing();
     5030
     5031                $routes    = rest_get_server()->get_routes();
     5032                $creatable = null;
     5033                foreach ( $routes['/wp/v2/media'] as $route ) {
     5034                        if ( ! empty( $route['methods'][ WP_REST_Server::CREATABLE ] ) ) {
     5035                                $creatable = $route;
     5036                                break;
     5037                        }
     5038                }
     5039
     5040                $this->assertNotNull( $creatable, 'The media route should register a CREATABLE handler.' );
     5041                $this->assertArrayHasKey( 'validate_callback', $creatable['args']['url'] );
     5042
     5043                $validate = $creatable['args']['url']['validate_callback'];
     5044                $request  = new WP_REST_Request( 'POST', '/wp/v2/media' );
     5045
     5046                // A well-formed URL on the site's own host passes validation.
     5047                $this->assertTrue( $validate( home_url( '/image.jpg' ), $request, 'url' ), 'A safe URL should pass validation.' );
     5048
     5049                // A disallowed scheme and a malformed URL are both rejected.
     5050                $invalid_urls = array(
     5051                        'ftp://example.org/image.jpg',
     5052                        'javascript:alert(1)',
     5053                        'not-a-url',
     5054                );
     5055
     5056                foreach ( $invalid_urls as $invalid ) {
     5057                        $result = $validate( $invalid, $request, 'url' );
     5058                        $this->assertWPError( $result, 'An unsafe URL should be rejected.' );
     5059                        $this->assertSame( 'rest_invalid_url', $result->get_error_code() );
     5060                        $this->assertSame( 400, $result->get_error_data()['status'] );
     5061                }
     5062        }
    46315063}
  • trunk/tests/qunit/fixtures/wp-api-generated.js

    r62617 r62659  
    31603160                            "default": true,
    31613161                            "description": "Whether to convert image formats.",
     3162                            "required": false
     3163                        },
     3164                        "url": {
     3165                            "type": "string",
     3166                            "format": "uri",
     3167                            "description": "URL of an external image to sideload into the media library, instead of uploading a file.",
    31623168                            "required": false
    31633169                        }
Note: See TracChangeset for help on using the changeset viewer.