diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 78225e87e23da..61d33ed1957c8 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -237,50 +237,54 @@ public function register_routes() { public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) { $args = parent::get_endpoint_args_for_item_schema( $method ); - if ( WP_REST_Server::CREATABLE === $method && wp_is_client_side_media_processing_enabled() ) { - $args['generate_sub_sizes'] = array( - 'type' => 'boolean', - 'default' => true, - 'description' => __( 'Whether to generate image sub sizes.' ), - ); - $args['convert_format'] = array( - 'type' => 'boolean', - 'default' => true, - 'description' => __( 'Whether to convert image formats.' ), - ); - $args['url'] = array( - 'type' => 'string', - 'format' => 'uri', - 'description' => __( 'URL of an external image to sideload into the media library, instead of uploading a file.' ), - 'sanitize_callback' => 'sanitize_url', - 'validate_callback' => static function ( $url, $request, $param ) { - /* - * A custom validate_callback replaces the default - * rest_validate_request_arg(), so re-apply it first to keep - * the schema checks (string type, uri format) enforced. - */ - $valid = rest_validate_request_arg( $url, $request, $param ); - if ( is_wp_error( $valid ) ) { - return $valid; - } + if ( WP_REST_Server::CREATABLE !== $method ) { + return $args; + } - /* - * Reject URLs that are not safe to request server-side. wp_http_validate_url() - * enforces an HTTP(S) scheme and blocks private, local, and otherwise - * disallowed hosts, guarding the sideload against SSRF. - */ - if ( false === wp_http_validate_url( $url ) ) { - return new WP_Error( - 'rest_invalid_url', - __( 'Invalid URL. Provide a valid, publicly reachable HTTP or HTTPS image URL.' ), - array( 'status' => 400 ) - ); - } + $args['generate_sub_sizes'] = array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( 'Whether to generate image sub sizes.' ), + ); - return true; - }, - ); - } + $args['convert_format'] = array( + 'type' => 'boolean', + 'default' => true, + 'description' => __( 'Whether to convert image formats.' ), + ); + + $args['url'] = array( + 'type' => 'string', + 'format' => 'uri', + 'description' => __( 'URL of an external image to sideload into the media library, instead of uploading a file.' ), + 'sanitize_callback' => 'sanitize_url', + 'validate_callback' => static function ( $url, $request, $param ) { + /* + * A custom validate_callback replaces the default + * rest_validate_request_arg(), so re-apply it first to keep + * the schema checks (string type, uri format) enforced. + */ + $valid = rest_validate_request_arg( $url, $request, $param ); + if ( is_wp_error( $valid ) ) { + return $valid; + } + + /* + * Reject URLs that are not safe to request server-side. wp_http_validate_url() + * enforces an HTTP(S) scheme and blocks private, local, and otherwise + * disallowed hosts, guarding the sideload against SSRF. + */ + if ( false === wp_http_validate_url( $url ) ) { + return new WP_Error( + 'rest_invalid_url', + __( 'Invalid URL. Provide a valid, publicly reachable HTTP or HTTPS image URL.' ), + array( 'status' => 400 ) + ); + } + + return true; + }, + ); return $args; } @@ -381,9 +385,15 @@ public function create_item_permissions_check( $request ) { */ $prevent_unsupported_uploads = apply_filters( 'wp_prevent_unsupported_mime_type_uploads', true, $files['file']['type'] ?? null ); - // When the client handles image processing (generate_sub_sizes is false), - // skip the server-side image editor support check. - if ( false === $request['generate_sub_sizes'] ) { + /* + * When the client handles image processing (generate_sub_sizes is false), + * skip the server-side image editor support check. This check exists + * because the server cannot process the image, so it is only relaxed when + * client side media processing is enabled and something else can. Asking + * to skip sub sizes on a site without it does not make an unsupported + * image type any more usable. + */ + if ( wp_is_client_side_media_processing_enabled() && false === $request['generate_sub_sizes'] ) { $prevent_unsupported_uploads = false; } diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 72bb483d087be..d9d9b51030b2c 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -207,6 +207,18 @@ private function enable_client_side_media_processing(): void { do_action( 'rest_api_init', $wp_rest_server ); } + /** + * Turns client-side media processing off and rebuilds the REST server so the + * routes are registered with the feature disabled. + */ + private function disable_client_side_media_processing(): void { + add_filter( 'wp_client_side_media_processing_enabled', '__return_false' ); + + global $wp_rest_server; + $wp_rest_server = new Spy_REST_Server(); + do_action( 'rest_api_init', $wp_rest_server ); + } + public function test_register_routes() { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/wp/v2/media', $routes ); @@ -3412,9 +3424,16 @@ public function test_upload_unsupported_image_type_with_filter() { * Tests the permissions check directly with file params set, since the core * check uses get_file_params() which is only populated for multipart uploads. * + * The check is only relaxed when client-side media processing is enabled, + * since that is what makes the client able to handle the image, so the + * feature is enabled here. + * * @ticket 64836 + * @ticket 65517 */ public function test_upload_unsupported_image_type_skipped_when_not_generating_sub_sizes() { + $this->enable_client_side_media_processing(); + wp_set_current_user( self::$author_id ); add_filter( 'wp_image_editors', '__return_empty_array' ); @@ -5738,6 +5757,166 @@ public function test_url_registered_as_creatable_arg() { $this->assertSame( 'uri', $creatable['args']['url']['format'] ); } + /** + * Verifies that the media creation arguments are registered even when + * client-side media processing is disabled. + * + * The feature is determined per request, from the scheme and host, so gating + * the schema on it would advertise different arguments for the same site + * depending on how it was reached. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::get_endpoint_args_for_item_schema + */ + public function test_creatable_args_registered_without_client_side_media_processing() { + $this->disable_client_side_media_processing(); + + $routes = rest_get_server()->get_routes(); + $creatable = null; + foreach ( $routes['/wp/v2/media'] as $route ) { + if ( ! empty( $route['methods'][ WP_REST_Server::CREATABLE ] ) ) { + $creatable = $route; + break; + } + } + + $this->assertNotNull( $creatable, 'The media route should register a CREATABLE handler.' ); + $this->assertArrayHasKey( 'url', $creatable['args'] ); + $this->assertArrayHasKey( 'generate_sub_sizes', $creatable['args'] ); + $this->assertArrayHasKey( 'convert_format', $creatable['args'] ); + } + + /** + * Verifies that sideloading an external image works when client-side media + * processing is disabled. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_without_client_side_media_processing() { + $this->disable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/photo.jpg' ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'image', $data['media_type'] ); + $this->assertSame( 'https://example.com/photo.jpg', $this->last_download_url ); + } + + /** + * Verifies that the `url` argument's validation runs when client-side media + * processing is disabled, so an unsafe URL is rejected with a 400 rather than + * reaching the download. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::get_endpoint_args_for_item_schema + */ + public function test_url_arg_rejects_unsafe_urls_without_client_side_media_processing() { + $this->disable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'http://127.0.0.1/private.jpg' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Verifies that `generate_sub_sizes` is honored when client-side media + * processing is disabled. + * + * Skipping sub-size generation is a request the server can carry out on its + * own, so it does not depend on the feature. Sub-sizes can still be added + * later with wp_update_image_subsizes(). + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item + */ + public function test_generate_sub_sizes_honored_without_client_side_media_processing() { + $this->disable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/photo.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + + $metadata = wp_get_attachment_metadata( $data['id'], true ); + $this->assertEmpty( + $metadata['sizes'] ?? array(), + 'Sub-sizes should not be generated when generate_sub_sizes is false.' + ); + } + + /** + * Verifies that `generate_sub_sizes` does not relax the unsupported image + * type check when client-side media processing is disabled. + * + * That check exists because the server cannot process the image, so it should + * only be relaxed when the client can process it instead. Otherwise the + * upload is stored unprocessable. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item_permissions_check + */ + public function test_unsupported_image_type_still_checked_without_client_side_media_processing() { + $this->disable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + add_filter( 'wp_image_editors', '__return_empty_array' ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_file_params( + array( + 'file' => array( + 'name' => 'avif-lossy.avif', + 'type' => 'image/avif', + 'tmp_name' => self::$test_avif_file, + 'error' => 0, + 'size' => filesize( self::$test_avif_file ), + ), + ) + ); + $request->set_param( 'generate_sub_sizes', false ); + + $controller = new WP_REST_Attachments_Controller( 'attachment' ); + $result = $controller->create_item_permissions_check( $request ); + + $this->assertWPError( $result ); + $this->assertSame( 'rest_upload_image_type_not_supported', $result->get_error_code() ); + } + /** * Verifies that the `url` argument rejects values that are not safe to * request server-side, guarding the sideload against SSRF.