Make WordPress Core

Changeset 63018


Ignore:
Timestamp:
08/05/2026 06:40:32 AM (2 months ago)
Author:
adamsilverstein
Message:

REST API: Always register the media creation arguments.

The url, generate_sub_sizes, and convert_format arguments for POST /wp/v2/media were only registered when client side media processing is enabled, but create_item() and create_item_permissions_check() honored all three either way. Since an unregistered argument skips the validation and sanitization its registration carries, an unsafe sideload url failed with a bare http_request_failed instead of a 400. Gating registration also made the schema depend on request context rather than site configuration, since wp_is_client_side_media_processing_enabled() is derived from is_ssl() and the host, so the same site could advertise different arguments depending on how it was reached.

All three arguments are now registered unconditionally. None of them require the feature: sideloading from a URL works around a cross-origin fetch the browser cannot make, and skipping sub-size generation or format conversion is something the server can do on its own. One condition is kept: generate_sub_sizes of false no longer relaxes the unsupported image type check in create_item_permissions_check() unless client side media processing is enabled, since that check exists because the server cannot process the image and should only be relaxed when the client can. Behavior with client side media processing enabled is unchanged.

Follow-up to [62659], [62841].

Props andrewserong, jeremyfelt.
Fixes #65808. See #65517.

Location:
trunk
Files:
2 edited

Legend:

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

    r63015 r63018  
    238238                $args = parent::get_endpoint_args_for_item_schema( $method );
    239239
    240                 if ( WP_REST_Server::CREATABLE === $method && wp_is_client_side_media_processing_enabled() ) {
    241                         $args['generate_sub_sizes'] = array(
    242                                 'type'        => 'boolean',
    243                                 'default'     => true,
    244                                 'description' => __( 'Whether to generate image sub sizes.' ),
    245                         );
    246                         $args['convert_format']     = array(
    247                                 'type'        => 'boolean',
    248                                 'default'     => true,
    249                                 'description' => __( 'Whether to convert image formats.' ),
    250                         );
    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                         );
    283                 }
     240                if ( WP_REST_Server::CREATABLE !== $method ) {
     241                        return $args;
     242                }
     243
     244                $args['generate_sub_sizes'] = array(
     245                        'type'        => 'boolean',
     246                        'default'     => true,
     247                        'description' => __( 'Whether to generate image sub sizes.' ),
     248                );
     249
     250                $args['convert_format'] = array(
     251                        'type'        => 'boolean',
     252                        'default'     => true,
     253                        'description' => __( 'Whether to convert image formats.' ),
     254                );
     255
     256                $args['url'] = array(
     257                        'type'              => 'string',
     258                        'format'            => 'uri',
     259                        'description'       => __( 'URL of an external image to sideload into the media library, instead of uploading a file.' ),
     260                        'sanitize_callback' => 'sanitize_url',
     261                        'validate_callback' => static function ( $url, $request, $param ) {
     262                                /*
     263                                 * A custom validate_callback replaces the default
     264                                 * rest_validate_request_arg(), so re-apply it first to keep
     265                                 * the schema checks (string type, uri format) enforced.
     266                                 */
     267                                $valid = rest_validate_request_arg( $url, $request, $param );
     268                                if ( is_wp_error( $valid ) ) {
     269                                        return $valid;
     270                                }
     271
     272                                /*
     273                                 * Reject URLs that are not safe to request server-side. wp_http_validate_url()
     274                                 * enforces an HTTP(S) scheme and blocks private, local, and otherwise
     275                                 * disallowed hosts, guarding the sideload against SSRF.
     276                                 */
     277                                if ( false === wp_http_validate_url( $url ) ) {
     278                                        return new WP_Error(
     279                                                'rest_invalid_url',
     280                                                __( 'Invalid URL. Provide a valid, publicly reachable HTTP or HTTPS image URL.' ),
     281                                                array( 'status' => 400 )
     282                                        );
     283                                }
     284
     285                                return true;
     286                        },
     287                );
    284288
    285289                return $args;
    … …  
    382386                $prevent_unsupported_uploads = apply_filters( 'wp_prevent_unsupported_mime_type_uploads', true, $files['file']['type'] ?? null );
    383387
    384                 // When the client handles image processing (generate_sub_sizes is false),
    385                 // skip the server-side image editor support check.
    386                 if ( false === $request['generate_sub_sizes'] ) {
     388                /*
     389                 * When the client handles image processing (generate_sub_sizes is false),
     390                 * skip the server-side image editor support check. This check exists
     391                 * because the server cannot process the image, so it is only relaxed when
     392                 * client side media processing is enabled and something else can. Asking
     393                 * to skip sub sizes on a site without it does not make an unsupported
     394                 * image type any more usable.
     395                 */
     396                if ( wp_is_client_side_media_processing_enabled() && false === $request['generate_sub_sizes'] ) {
    387397                        $prevent_unsupported_uploads = false;
    388398                }
  • trunk/tests/phpunit/tests/rest-api/rest-attachments-controller.php

    r63015 r63018  
    202202        private function enable_client_side_media_processing(): void {
    203203                add_filter( 'wp_client_side_media_processing_enabled', '__return_true' );
     204
     205                global $wp_rest_server;
     206                $wp_rest_server = new Spy_REST_Server();
     207                do_action( 'rest_api_init', $wp_rest_server );
     208        }
     209
     210        /**
     211         * Turns client-side media processing off and rebuilds the REST server so the
     212         * routes are registered with the feature disabled.
     213         */
     214        private function disable_client_side_media_processing(): void {
     215                add_filter( 'wp_client_side_media_processing_enabled', '__return_false' );
    204216
    205217                global $wp_rest_server;
    … …  
    34133425         * check uses get_file_params() which is only populated for multipart uploads.
    34143426         *
     3427         * The check is only relaxed when client-side media processing is enabled,
     3428         * since that is what makes the client able to handle the image, so the
     3429         * feature is enabled here.
     3430         *
    34153431         * @ticket 64836
     3432         * @ticket 65517
    34163433         */
    34173434        public function test_upload_unsupported_image_type_skipped_when_not_generating_sub_sizes() {
     3435                $this->enable_client_side_media_processing();
     3436
    34183437                wp_set_current_user( self::$author_id );
    34193438
    … …  
    58325851
    58335852        /**
    5834          * Verifies that the `url` argument rejects values that are not safe to
    5835          * request server-side, guarding the sideload against SSRF.
     5853         * Verifies that the media creation arguments are registered even when
     5854         * client-side media processing is disabled.
     5855         *
     5856         * The feature is determined per request, from the scheme and host, so gating
     5857         * the schema on it would advertise different arguments for the same site
     5858         * depending on how it was reached.
    58365859         *
    58375860         * @ticket 65517
    … …  
    58395862         * @covers WP_REST_Attachments_Controller::get_endpoint_args_for_item_schema
    58405863         */
    5841         public function test_url_arg_rejects_unsafe_urls() {
    5842                 $this->enable_client_side_media_processing();
     5864        public function test_creatable_args_registered_without_client_side_media_processing() {
     5865                $this->disable_client_side_media_processing();
    58435866
    58445867                $routes    = rest_get_server()->get_routes();
    … …  
    58525875
    58535876                $this->assertNotNull( $creatable, 'The media route should register a CREATABLE handler.' );
     5877                $this->assertArrayHasKey( 'url', $creatable['args'] );
     5878                $this->assertArrayHasKey( 'generate_sub_sizes', $creatable['args'] );
     5879                $this->assertArrayHasKey( 'convert_format', $creatable['args'] );
     5880        }
     5881
     5882        /**
     5883         * Verifies that sideloading an external image works when client-side media
     5884         * processing is disabled.
     5885         *
     5886         * @ticket 65517
     5887         *
     5888         * @covers WP_REST_Attachments_Controller::create_item
     5889         * @covers WP_REST_Attachments_Controller::create_item_from_url
     5890         */
     5891        public function test_create_item_from_url_without_client_side_media_processing() {
     5892                $this->disable_client_side_media_processing();
     5893
     5894                wp_set_current_user( self::$superadmin_id );
     5895
     5896                add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 );
     5897
     5898                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     5899                $request->set_param( 'url', 'https://example.com/photo.jpg' );
     5900
     5901                $response = rest_get_server()->dispatch( $request );
     5902
     5903                remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 );
     5904
     5905                $data = $response->get_data();
     5906
     5907                $this->assertSame( 201, $response->get_status() );
     5908                $this->assertSame( 'image', $data['media_type'] );
     5909                $this->assertSame( 'https://example.com/photo.jpg', $this->last_download_url );
     5910        }
     5911
     5912        /**
     5913         * Verifies that the `url` argument's validation runs when client-side media
     5914         * processing is disabled, so an unsafe URL is rejected with a 400 rather than
     5915         * reaching the download.
     5916         *
     5917         * @ticket 65517
     5918         *
     5919         * @covers WP_REST_Attachments_Controller::get_endpoint_args_for_item_schema
     5920         */
     5921        public function test_url_arg_rejects_unsafe_urls_without_client_side_media_processing() {
     5922                $this->disable_client_side_media_processing();
     5923
     5924                wp_set_current_user( self::$superadmin_id );
     5925
     5926                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     5927                $request->set_param( 'url', 'http://127.0.0.1/private.jpg' );
     5928
     5929                $response = rest_get_server()->dispatch( $request );
     5930
     5931                $this->assertErrorResponse( 'rest_invalid_param', $response, 400 );
     5932        }
     5933
     5934        /**
     5935         * Verifies that `generate_sub_sizes` is honored when client-side media
     5936         * processing is disabled.
     5937         *
     5938         * Skipping sub-size generation is a request the server can carry out on its
     5939         * own, so it does not depend on the feature. Sub-sizes can still be added
     5940         * later with wp_update_image_subsizes().
     5941         *
     5942         * @ticket 65517
     5943         *
     5944         * @covers WP_REST_Attachments_Controller::create_item
     5945         */
     5946        public function test_generate_sub_sizes_honored_without_client_side_media_processing() {
     5947                $this->disable_client_side_media_processing();
     5948
     5949                wp_set_current_user( self::$superadmin_id );
     5950
     5951                add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 );
     5952
     5953                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     5954                $request->set_param( 'url', 'https://example.com/photo.jpg' );
     5955                $request->set_param( 'generate_sub_sizes', false );
     5956
     5957                $response = rest_get_server()->dispatch( $request );
     5958
     5959                remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 );
     5960
     5961                $data = $response->get_data();
     5962
     5963                $this->assertSame( 201, $response->get_status() );
     5964
     5965                $metadata = wp_get_attachment_metadata( $data['id'], true );
     5966                $this->assertEmpty(
     5967                        $metadata['sizes'] ?? array(),
     5968                        'Sub-sizes should not be generated when generate_sub_sizes is false.'
     5969                );
     5970        }
     5971
     5972        /**
     5973         * Verifies that `generate_sub_sizes` does not relax the unsupported image
     5974         * type check when client-side media processing is disabled.
     5975         *
     5976         * That check exists because the server cannot process the image, so it should
     5977         * only be relaxed when the client can process it instead. Otherwise the
     5978         * upload is stored unprocessable.
     5979         *
     5980         * @ticket 65517
     5981         *
     5982         * @covers WP_REST_Attachments_Controller::create_item_permissions_check
     5983         */
     5984        public function test_unsupported_image_type_still_checked_without_client_side_media_processing() {
     5985                $this->disable_client_side_media_processing();
     5986
     5987                wp_set_current_user( self::$author_id );
     5988
     5989                add_filter( 'wp_image_editors', '__return_empty_array' );
     5990
     5991                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     5992                $request->set_file_params(
     5993                        array(
     5994                                'file' => array(
     5995                                        'name'     => 'avif-lossy.avif',
     5996                                        'type'     => 'image/avif',
     5997                                        'tmp_name' => self::$test_avif_file,
     5998                                        'error'    => 0,
     5999                                        'size'     => filesize( self::$test_avif_file ),
     6000                                ),
     6001                        )
     6002                );
     6003                $request->set_param( 'generate_sub_sizes', false );
     6004
     6005                $controller = new WP_REST_Attachments_Controller( 'attachment' );
     6006                $result     = $controller->create_item_permissions_check( $request );
     6007
     6008                $this->assertWPError( $result );
     6009                $this->assertSame( 'rest_upload_image_type_not_supported', $result->get_error_code() );
     6010        }
     6011
     6012        /**
     6013         * Verifies that the `url` argument rejects values that are not safe to
     6014         * request server-side, guarding the sideload against SSRF.
     6015         *
     6016         * @ticket 65517
     6017         *
     6018         * @covers WP_REST_Attachments_Controller::get_endpoint_args_for_item_schema
     6019         */
     6020        public function test_url_arg_rejects_unsafe_urls() {
     6021                $this->enable_client_side_media_processing();
     6022
     6023                $routes    = rest_get_server()->get_routes();
     6024                $creatable = null;
     6025                foreach ( $routes['/wp/v2/media'] as $route ) {
     6026                        if ( ! empty( $route['methods'][ WP_REST_Server::CREATABLE ] ) ) {
     6027                                $creatable = $route;
     6028                                break;
     6029                        }
     6030                }
     6031
     6032                $this->assertNotNull( $creatable, 'The media route should register a CREATABLE handler.' );
    58546033                $this->assertArrayHasKey( 'validate_callback', $creatable['args']['url'] );
    58556034
Note: See TracChangeset for help on using the changeset viewer.