From 5dead041709873e0c19d2dd52e530022b6a495e8 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:57:12 -0500 Subject: [PATCH 1/2] Tests: Demonstrate multisite rejecting sideloaded sub-sizes by file type. On the classic path, the image editor writes sub-sizes to disk directly, so a network that does not allow WebP uploads still gets WebP sub-sizes when `image_editor_output_format` asks for them. With client-side media processing, the browser transcodes each sub-size to that format and sideloads it, and the sideload is checked against the network's allowed upload file types, so it is rejected with "Sorry, you are not allowed to upload this file type." Networks created before WordPress 5.8 have no `webp` in `upload_filetypes`, and upgrades never add it. The first test is the classic baseline and passes. The second fails until the sideload endpoint allows the attachment's own output format. Co-Authored-By: Claude Opus 5.5 --- .../rest-api/rest-attachments-controller.php | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index a8499a34ed274..3869f9171cc22 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -2854,6 +2854,104 @@ public function test_create_item_with_data_exceeds_multisite_site_upload_space() $this->assertErrorResponse( 'rest_upload_limited_space', $response, 400 ); } + /** + * Tests that sub-sizes generated on the server are not limited to the + * network's allowed upload file types. + * + * The image editor writes sub-sizes straight to disk, so a network that + * does not allow WebP uploads still gets WebP sub-sizes when an + * `image_editor_output_format` filter asks for them. This is the + * baseline for test_sideload_sub_size_in_output_format_not_allowed_by_network(). + * + * @group multisite + * @group ms-required + * @covers ::wp_generate_attachment_metadata + */ + public function test_server_sub_sizes_in_output_format_not_allowed_by_network(): void { + if ( ! wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) ) ) { + $this->markTestSkipped( 'This test requires an image editor that can save WebP.' ); + } + + /* + * Networks created before WordPress 5.8 have no `webp` in this list, + * and upgrades never add it. This is also the list + * check_upload_mimes() falls back to. + */ + update_site_option( 'upload_filetypes', 'jpg jpeg png gif' ); + $this->assertNotContains( 'image/webp', get_allowed_mime_types(), 'The network should not allow WebP uploads.' ); + + add_filter( 'image_editor_output_format', array( $this, 'filter_output_format_jpeg_to_webp' ) ); + + $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertArrayHasKey( 'thumbnail', $metadata['sizes'], 'The server should generate a thumbnail.' ); + $this->assertSame( 'image/webp', $metadata['sizes']['thumbnail']['mime-type'], 'The thumbnail should be a WebP.' ); + } + + /** + * Tests that a client-generated sub-size can be sideloaded in an output + * format the network does not allow for upload. + * + * With client-side media processing, the browser transcodes each sub-size + * to the `image_output_format` returned by the upload, then sideloads it. + * The server generates the same sub-size itself on the classic path (see + * test_server_sub_sizes_in_output_format_not_allowed_by_network()), so the + * sideload should not be rejected for its file type. + * + * @group multisite + * @group ms-required + * @covers WP_REST_Attachments_Controller::sideload_item + */ + public function test_sideload_sub_size_in_output_format_not_allowed_by_network(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // See test_server_sub_sizes_in_output_format_not_allowed_by_network(). + update_site_option( 'upload_filetypes', 'jpg jpeg png gif' ); + $this->assertNotContains( 'image/webp', get_allowed_mime_types(), 'The network should not allow WebP uploads.' ); + + add_filter( 'image_editor_output_format', array( $this, 'filter_output_format_jpeg_to_webp' ) ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + $request->set_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status(), 'The original upload should succeed.' ); + + $attachment_id = $response->get_data()['id']; + + // A WebP thumbnail, as the client would sideload it. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/webp' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola-50x50.webp' ); + $request->set_param( 'image_size', 'thumbnail' ); + $request->set_param( 'convert_format', false ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image.webp' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( + 200, + $response->get_status(), + 'Sideloading a WebP sub-size should succeed. Response: ' . wp_json_encode( $response->get_data() ) + ); + } + + /** + * Maps JPEG output to WebP, as image format plugins do. + * + * @param array $formats Output formats keyed by source mime type. + * @return array The filtered output formats. + */ + public function filter_output_format_jpeg_to_webp( $formats ) { + $formats['image/jpeg'] = 'image/webp'; + return $formats; + } + /** * Ensure the `rest_after_insert_attachment` and `rest_insert_attachment` hooks only fire * once when attachments are created. From 4b584c1c5350eda74388a0d4c866da8e4542afc9 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 12 Nov 2026 10:34:36 -0800 Subject: [PATCH 2/2] Media: Allow sideloading sub-sizes in the attachment's output format on multisite. The image editor writes sub-sizes to disk in the format `image_editor_output_format` asks for, without checking it against the network's "Upload file types" setting. With client-side media processing the same sub-sizes are sideloaded, and each one was rejected when its format was missing from that setting, as it is for WebP on networks created before 5.8. Exempt the attachment's own output format from the network setting for the duration of a sideload, so both paths accept the same files. Any other file type is still rejected. See #66681. --- .../class-wp-rest-attachments-controller.php | 72 ++++++++++++++----- .../rest-api/rest-attachments-controller.php | 53 ++++++++++++-- 2 files changed, 105 insertions(+), 20 deletions(-) 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 0277a7dd44f76..2a14083968635 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 @@ -1603,24 +1603,12 @@ public function prepare_item_for_response( $item, $request ) { $mime_type = (string) get_post_mime_type( $post ); /* - * Per-file output format for images, evaluated with the real filename - * and MIME type so plugins filtering image_editor_output_format can - * make per-attachment decisions (e.g. JPEG -> WebP). Resolved the same - * way WP_Image_Editor::set_quality() resolves the output format. + * Per-file output format for images, so plugins filtering + * image_editor_output_format can make per-attachment decisions + * (e.g. JPEG -> WebP). */ if ( in_array( 'image_output_format', $fields, true ) ) { - $filename = get_attached_file( $post->ID ); - - /** This filter is documented in wp-includes/media.php */ - $output_formats = apply_filters( - 'image_editor_output_format', - array( $mime_type => $mime_type ), - $filename ? $filename : '', - $mime_type - ); - - $output_mime = $output_formats[ $mime_type ] ?? $mime_type; - $data['image_output_format'] = ( $output_mime !== $mime_type ) ? $output_mime : null; + $data['image_output_format'] = $this->get_image_output_format( $post ); } /* @@ -2783,6 +2771,34 @@ private function validate_image_dimensions( int $width, int $height, string $ima return true; } + /** + * Gets the MIME type an image attachment's sub-sizes are generated in. + * + * Resolved the same way WP_Image_Editor::set_quality() resolves the output + * format, with the attachment's real file name and MIME type. + * + * @since 7.2.0 + * + * @param WP_Post $post Image attachment post object. + * @return string|null The output MIME type, or null if it matches the attachment's own. + */ + private function get_image_output_format( WP_Post $post ): ?string { + $mime_type = (string) get_post_mime_type( $post ); + $filename = get_attached_file( $post->ID ); + + /** This filter is documented in wp-includes/media.php */ + $output_formats = apply_filters( + 'image_editor_output_format', + array( $mime_type => $mime_type ), + $filename ? $filename : '', + $mime_type + ); + + $output_mime = is_array( $output_formats ) ? ( $output_formats[ $mime_type ] ?? $mime_type ) : $mime_type; + + return ( is_string( $output_mime ) && $output_mime !== $mime_type ) ? $output_mime : null; + } + /** * Checks whether a dimension exceeds the maximum allowed value. * @@ -2847,6 +2863,25 @@ public function sideload_item( WP_REST_Request $request ) { ); } + /* + * The server writes sub-sizes in the attachment's output format without + * checking it against the network's allowed upload file types, so a + * sideload in that format is exempt from them too. Resolved before the + * output format filter below is added. + */ + $output_format = wp_attachment_is_image( $post ) ? $this->get_image_output_format( $post ) : null; + $output_extensions = null !== $output_format ? array_search( $output_format, wp_get_mime_types(), true ) : false; + $filter_filetypes = null; + + if ( is_string( $output_extensions ) ) { + $filter_filetypes = static function ( $filetypes ) use ( $output_extensions ) { + return is_string( $filetypes ) ? $filetypes . ' ' . str_replace( '|', ' ', $output_extensions ) : $filetypes; + }; + + add_filter( 'site_option_upload_filetypes', $filter_filetypes ); + add_filter( 'default_site_option_upload_filetypes', $filter_filetypes ); + } + if ( false === $request['convert_format'] ) { // Prevent image conversion as that is done client-side. add_filter( 'image_editor_output_format', '__return_empty_array', 100 ); @@ -2898,6 +2933,11 @@ public function sideload_item( WP_REST_Request $request ) { remove_filter( 'image_editor_output_format', '__return_empty_array', 100 ); remove_filter( 'upload_dir', $filter_upload_dir, 100 ); + if ( null !== $filter_filetypes ) { + remove_filter( 'site_option_upload_filetypes', $filter_filetypes ); + remove_filter( 'default_site_option_upload_filetypes', $filter_filetypes ); + } + if ( is_wp_error( $file ) ) { return $file; } diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 3869f9171cc22..56a62748a1c80 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -2863,6 +2863,8 @@ public function test_create_item_with_data_exceeds_multisite_site_upload_space() * `image_editor_output_format` filter asks for them. This is the * baseline for test_sideload_sub_size_in_output_format_not_allowed_by_network(). * + * @ticket 66681 + * * @group multisite * @group ms-required * @covers ::wp_generate_attachment_metadata @@ -2883,7 +2885,11 @@ public function test_server_sub_sizes_in_output_format_not_allowed_by_network(): add_filter( 'image_editor_output_format', array( $this, 'filter_output_format_jpeg_to_webp' ) ); $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); - $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertIsInt( $attachment_id ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertIsArray( $metadata ); + $this->assertArrayHasKey( 'sizes', $metadata ); $this->assertArrayHasKey( 'thumbnail', $metadata['sizes'], 'The server should generate a thumbnail.' ); $this->assertSame( 'image/webp', $metadata['sizes']['thumbnail']['mime-type'], 'The thumbnail should be a WebP.' ); @@ -2899,6 +2905,8 @@ public function test_server_sub_sizes_in_output_format_not_allowed_by_network(): * test_server_sub_sizes_in_output_format_not_allowed_by_network()), so the * sideload should not be rejected for its file type. * + * @ticket 66681 + * * @group multisite * @group ms-required * @covers WP_REST_Attachments_Controller::sideload_item @@ -2918,12 +2926,16 @@ public function test_sideload_sub_size_in_output_format_not_allowed_by_network() $request->set_header( 'Content-Type', 'image/jpeg' ); $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' ); $request->set_param( 'generate_sub_sizes', false ); - $request->set_body( file_get_contents( self::$test_file ) ); + $request->set_body( (string) file_get_contents( self::$test_file ) ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 201, $response->get_status(), 'The original upload should succeed.' ); - $attachment_id = $response->get_data()['id']; + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertIsInt( $data['id'] ); + + $attachment_id = $data['id']; // A WebP thumbnail, as the client would sideload it. $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); @@ -2931,7 +2943,7 @@ public function test_sideload_sub_size_in_output_format_not_allowed_by_network() $request->set_header( 'Content-Disposition', 'attachment; filename=canola-50x50.webp' ); $request->set_param( 'image_size', 'thumbnail' ); $request->set_param( 'convert_format', false ); - $request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image.webp' ) ); + $request->set_body( (string) file_get_contents( DIR_TESTDATA . '/images/test-image.webp' ) ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( @@ -2941,6 +2953,39 @@ public function test_sideload_sub_size_in_output_format_not_allowed_by_network() ); } + /** + * Tests that a sideload is still limited to the network's allowed upload + * file types when it is not in the attachment's output format. + * + * @ticket 66681 + * + * @group multisite + * @group ms-required + * @covers WP_REST_Attachments_Controller::sideload_item + */ + public function test_sideload_sub_size_not_in_output_format_not_allowed_by_network(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + update_site_option( 'upload_filetypes', 'jpg jpeg png gif' ); + + $attachment_id = self::factory()->attachment->create_upload_object( self::$test_file ); + $this->assertIsInt( $attachment_id ); + + // No output format filter, so the sub-sizes of a JPEG are JPEGs. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/webp' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola-50x50.webp' ); + $request->set_param( 'image_size', 'thumbnail' ); + $request->set_param( 'convert_format', false ); + $request->set_body( (string) file_get_contents( DIR_TESTDATA . '/images/test-image.webp' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_upload_sideload_error', $response, 500 ); + $this->assertNotContains( 'image/webp', get_allowed_mime_types(), 'The network should still not allow WebP uploads.' ); + } + /** * Maps JPEG output to WebP, as image format plugins do. *