Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0229c11
REST API: Add 'scaled' to sideload route image_size enum
adamsilverstein Feb 23, 2026
3832e25
Merge branch 'trunk' into add-scaled-to-sideload-route
adamsilverstein Feb 26, 2026
0dcab83
REST API: Update auto-generated JS fixture for sideload route.
adamsilverstein Feb 26, 2026
2fe2162
Tests: Add unit tests for scaled image sideloading via REST API.
adamsilverstein Feb 26, 2026
133ec0f
REST API: Validate get_attached_file() in sideload
adamsilverstein Feb 26, 2026
a868b57
Tests: Fix expected error code in sideload auth test
adamsilverstein Feb 26, 2026
e5baa99
fix ticket number for test annotations
adamsilverstein Mar 2, 2026
5405e38
Merge branch 'trunk' into add-scaled-to-sideload-route
adamsilverstein Mar 2, 2026
02a9794
Merge branch 'trunk' into add-scaled-to-sideload-route
adamsilverstein Mar 3, 2026
458d94c
Add value assertion for metadata file key
adamsilverstein Mar 3, 2026
978ac79
Extract image_size key into variable
adamsilverstein Mar 3, 2026
eeca10e
Add test for scaled filename numeric suffix on conflict
adamsilverstein Mar 3, 2026
ae029f1
Cast $number to int in regex for safety
adamsilverstein Mar 3, 2026
a484e07
Apply suggestion from @westonruter
adamsilverstein Mar 3, 2026
fbe7458
Apply suggestion from @westonruter
adamsilverstein Mar 3, 2026
b30edb6
Handle update_attached_file failure in sideload
adamsilverstein Mar 3, 2026
56c05e4
Use is_int check instead of empty for $number
adamsilverstein Mar 3, 2026
d558ceb
Validate scaled image before updating attached file
adamsilverstein Mar 3, 2026
851f14a
Improve regex grouping in filter_wp_unique_filename.
adamsilverstein Mar 3, 2026
6a95756
REST API: Add dimension validation to sideload endpoint.
adamsilverstein Mar 1, 2026
2cb2454
REST API: Refactor dimension validation in sideload endpoint.
adamsilverstein Mar 2, 2026
5474fcd
Update src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-c…
adamsilverstein Mar 3, 2026
394bec7
Merge branch 'trunk' into add-dimension-validation-to-sideload
adamsilverstein Mar 3, 2026
0cec0b0
revert unrelated
adamsilverstein Mar 3, 2026
2566f7c
revert dupicate change
adamsilverstein Mar 3, 2026
3a17ad8
Merge branch 'trunk' into add-dimension-validation-to-sideload
adamsilverstein Mar 4, 2026
5b3d79c
Merge branch 'trunk' into add-dimension-validation-to-sideload
adamsilverstein Mar 5, 2026
c57a975
REST API: Refactor sideload dimension validation per review.
adamsilverstein May 6, 2026
642209e
Merge remote-tracking branch 'origin/trunk' into add-dimension-valida…
adamsilverstein May 28, 2026
5634d1d
Add missing types for image sizes
westonruter May 28, 2026
c7ea9f6
REST API: Add tests for sideload dimension validation.
adamsilverstein May 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions src/wp-includes/media.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{
* width: non-negative-int,
* height: non-negative-int,
* crop: array{ 'left'|'center'|'right', 'top'|'center'|'bottom' }|bool,
* }>
*/
function wp_get_additional_image_sizes() {
global $_wp_additional_image_sizes;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, array{
* width: non-negative-int,
* height: non-negative-int,
* crop: array{ 'left'|'center'|'right', 'top'|'center'|'bottom' }|bool,
* }>
*/
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'] ) ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 ) {
Expand Down Expand Up @@ -2135,8 +2265,6 @@ public function sideload_item( WP_REST_Request $request ) {
} else {
$metadata['sizes'] = $metadata['sizes'] ?? array();

$size = wp_getimagesize( $path );

Comment thread
adamsilverstein marked this conversation as resolved.
$metadata['sizes'][ $image_size ] = array(
'width' => $size ? $size[0] : 0,
'height' => $size ? $size[1] : 0,
Expand Down
134 changes: 134 additions & 0 deletions tests/phpunit/tests/rest-api/rest-attachments-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Loading