diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index d318a275a9607..3d9e9d404c9ff 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -16,9 +16,17 @@ * * @since 4.7.0 * + * @see add_image_size() + * * @global array $_wp_additional_image_sizes * * @return array Additional images size data. + * + * @phpstan-return array */ function wp_get_additional_image_sizes() { global $_wp_additional_image_sizes; @@ -296,6 +304,10 @@ function image_downsize( $id, $size = 'medium' ) { * @type string $0 The x crop position. Accepts 'left', 'center', or 'right'. * @type string $1 The y crop position. Accepts 'top', 'center', or 'bottom'. * } + * + * @phpstan-param non-negative-int $width + * @phpstan-param non-negative-int $height + * @phpstan-param array{ 'left'|'center'|'right', 'top'|'center'|'bottom' }|bool $crop */ function add_image_size( $name, $width = 0, $height = 0, $crop = false ) { global $_wp_additional_image_sizes; @@ -908,16 +920,20 @@ function get_intermediate_image_sizes() { * * @return array[] Associative array of arrays of image sub-size information, * keyed by image size name. + * + * @phpstan-return array */ -function wp_get_registered_image_subsizes() { +function wp_get_registered_image_subsizes(): array { $additional_sizes = wp_get_additional_image_sizes(); $all_sizes = array(); foreach ( get_intermediate_image_sizes() as $size_name ) { $size_data = array( - 'width' => 0, - 'height' => 0, - 'crop' => false, + 'crop' => false, ); if ( isset( $additional_sizes[ $size_name ]['width'] ) ) { 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 21805778ba659..81a7b603b28fa 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 @@ -2003,6 +2003,123 @@ public function sideload_item_permissions_check( $request ) { return $this->edit_media_item_permissions_check( $request ); } + /** + * Validates that uploaded image dimensions are appropriate for the specified image size. + * + * @since 7.0.0 + * + * @param int $width Uploaded image width. + * @param int $height Uploaded image height. + * @param string $image_size The target image size name. + * @param int $attachment_id The attachment ID. + * @return true|WP_Error True if valid, WP_Error if invalid. + */ + private function validate_image_dimensions( int $width, int $height, string $image_size, int $attachment_id ) { + // All image sizes require positive dimensions. + if ( $width <= 0 || $height <= 0 ) { + return new WP_Error( + 'rest_upload_invalid_dimensions', + __( 'Uploaded image must have positive dimensions.' ), + array( 'status' => 400 ) + ); + } + + // 'original' size: should match original attachment dimensions. + if ( 'original' === $image_size ) { + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + if ( is_array( $metadata ) && isset( $metadata['width'], $metadata['height'] ) ) { + $expected_width = (int) $metadata['width']; + $expected_height = (int) $metadata['height']; + + if ( $width !== $expected_width || $height !== $expected_height ) { + return new WP_Error( + 'rest_upload_dimension_mismatch', + sprintf( + /* translators: 1: Actual width, 2: actual height, 3: expected width, 4: expected height. */ + __( 'Uploaded image dimensions (%1$dx%2$d) do not match original image dimensions (%3$dx%4$d).' ), + $width, + $height, + $expected_width, + $expected_height + ), + array( 'status' => 400 ) + ); + } + } + return true; + } + + // 'full' size (PDF thumbnails) and 'scaled': no further constraints. + if ( in_array( $image_size, array( 'full', 'scaled' ), true ) ) { + return true; + } + + // Regular image sizes: validate against registered size constraints. + $registered_sizes = wp_get_registered_image_subsizes(); + + if ( ! isset( $registered_sizes[ $image_size ] ) ) { + return new WP_Error( + 'rest_upload_unknown_size', + __( 'Unknown image size.' ), + array( 'status' => 400 ) + ); + } + + $size_data = $registered_sizes[ $image_size ]; + $max_width = (int) $size_data['width']; + $max_height = (int) $size_data['height']; + + // Validate dimensions don't exceed the registered size maximums. + // Allow 1px tolerance for rounding differences. + $tolerance = 1; + + if ( $this->dimension_exceeds_max( $width, $max_width, $tolerance ) ) { + return new WP_Error( + 'rest_upload_dimension_mismatch', + sprintf( + /* translators: 1: Image size name, 2: maximum width, 3: actual width. */ + __( 'Uploaded image width (%3$d) exceeds maximum for "%1$s" size (%2$d).' ), + $image_size, + $max_width, + $width + ), + array( 'status' => 400 ) + ); + } + + if ( $this->dimension_exceeds_max( $height, $max_height, $tolerance ) ) { + return new WP_Error( + 'rest_upload_dimension_mismatch', + sprintf( + /* translators: 1: Image size name, 2: maximum height, 3: actual height. */ + __( 'Uploaded image height (%3$d) exceeds maximum for "%1$s" size (%2$d).' ), + $image_size, + $max_height, + $height + ), + array( 'status' => 400 ) + ); + } + + return true; + } + + /** + * Checks whether a dimension exceeds the maximum allowed value. + * + * A maximum of zero means the dimension is unconstrained. + * + * @since 7.0.0 + * + * @param int $value The actual dimension in pixels. + * @param int $max The maximum allowed dimension in pixels. Zero means no constraint. + * @param int $tolerance Pixel tolerance allowed for rounding differences. + * @return bool True if the value exceeds the maximum plus tolerance. + */ + private function dimension_exceeds_max( int $value, int $max, int $tolerance ): bool { + return $max > 0 && $value > $max + $tolerance; + } + /** * Side-loads a media file without creating a new attachment. * @@ -2080,8 +2197,21 @@ public function sideload_item( WP_REST_Request $request ) { $type = $file['type']; $path = $file['file']; + /** @var non-empty-string $image_size */ $image_size = $request['image_size']; + $size = wp_getimagesize( $path ); + + // Validate dimensions match expected size. + if ( is_array( $size ) ) { + $validation = $this->validate_image_dimensions( $size[0], $size[1], $image_size, $attachment_id ); + if ( is_wp_error( $validation ) ) { + // Clean up the uploaded file. + wp_delete_file( $path ); + return $validation; + } + } + $metadata = wp_get_attachment_metadata( $attachment_id, true ); if ( ! $metadata ) { @@ -2135,8 +2265,6 @@ public function sideload_item( WP_REST_Request $request ) { } else { $metadata['sizes'] = $metadata['sizes'] ?? array(); - $size = wp_getimagesize( $path ); - $metadata['sizes'][ $image_size ] = array( 'width' => $size ? $size[0] : 0, 'height' => $size ? $size[1] : 0, diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 79e9d23cf9dd3..60817b5cad819 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3440,6 +3440,140 @@ public function test_sideload_scaled_unique_filename_conflict() { $this->assertMatchesRegularExpression( '/canola-scaled-\d+\.jpg$/', $basename, 'Scaled filename should have numeric suffix when file conflicts with a different attachment.' ); } + /** + * Tests that sideloading rejects an image whose dimensions exceed the + * registered maximum for the target image size. + * + * @ticket 64798 + * @covers WP_REST_Attachments_Controller::sideload_item + * @covers WP_REST_Attachments_Controller::validate_image_dimensions + * @requires function imagejpeg + */ + public function test_sideload_item_rejects_oversized_dimensions() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Create an attachment from canola.jpg (640x480). + $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_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id = $response->get_data()['id']; + + // Sideload the 640x480 image claiming it is a thumbnail (150x150 max). + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola-150x150.jpg' ); + $request->set_param( 'image_size', 'thumbnail' ); + $request->set_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 400, $response->get_status(), 'Oversized sideload should be rejected.' ); + $this->assertSame( 'rest_upload_dimension_mismatch', $response->get_data()['code'] ); + } + + /** + * Tests that sideloading accepts an image whose dimensions fit within the + * registered maximum for the target image size. + * + * @ticket 64798 + * @covers WP_REST_Attachments_Controller::sideload_item + * @covers WP_REST_Attachments_Controller::validate_image_dimensions + * @requires function imagejpeg + */ + public function test_sideload_item_accepts_valid_dimensions() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Create an attachment from canola.jpg (640x480). + $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_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id = $response->get_data()['id']; + + // test-image.jpg is 50x50, well within the thumbnail maximum (150x150). + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=test-thumbnail.jpg' ); + $request->set_param( 'image_size', 'thumbnail' ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Valid thumbnail sideload should succeed.' ); + } + + /** + * Tests that sideloading the 'original' size rejects an image whose + * dimensions do not match the original attachment dimensions. + * + * @ticket 64798 + * @covers WP_REST_Attachments_Controller::sideload_item + * @covers WP_REST_Attachments_Controller::validate_image_dimensions + * @requires function imagejpeg + */ + public function test_sideload_item_rejects_original_dimension_mismatch() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Create an attachment from canola.jpg (640x480). + $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_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id = $response->get_data()['id']; + + // Sideload a 50x50 image as the original; it does not match 640x480. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' ); + $request->set_param( 'image_size', 'original' ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image.jpg' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 400, $response->get_status(), 'Mismatched original sideload should be rejected.' ); + $this->assertSame( 'rest_upload_dimension_mismatch', $response->get_data()['code'] ); + } + + /** + * Tests that sideloading the 'original' size accepts an image whose + * dimensions match the original attachment dimensions. + * + * @ticket 64798 + * @covers WP_REST_Attachments_Controller::sideload_item + * @covers WP_REST_Attachments_Controller::validate_image_dimensions + * @requires function imagejpeg + */ + public function test_sideload_item_accepts_matching_original_dimensions() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Create an attachment from canola.jpg (640x480). + $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_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id = $response->get_data()['id']; + + // Sideload the same 640x480 image as the original; dimensions match. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola-original.jpg' ); + $request->set_param( 'image_size', 'original' ); + $request->set_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Matching original sideload should succeed.' ); + } + /** * Tests that the finalize endpoint triggers wp_generate_attachment_metadata. *