Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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'] ) {

@andrewserong andrewserong Aug 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if my earlier question got lost: but why do we guard generate_sub_sizes and convert_format behind the presence of client side media processing? Should we register these unconditionally, too?

(Not a blocker, you've already explained part of the reasoning in the code comments. My only hesitation was really if it's a strange thing for a REST API to conditionally have some args available only some of the time)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and thanks for asking twice - I've registered all three unconditionally now.

The thing that convinced me is that wp_is_client_side_media_processing_enabled() comes from is_ssl() and the host, so gating registration on it means the same site advertises different args over http vs https, or behind a proxy that doesn't set HTTPS. That's request context leaking into the schema, and since an unregistered param is still readable we were duplicating the check at runtime anyway.

I did keep one guard: 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. That check is there because the server can't process the image, so it seems worth only relaxing when the client can - otherwise we store something nothing can process. Skipping sub-sizes or conversion is fine either way, and they can still be regenerated with wp_update_image_subsizes().

Does that split make sense to you?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that split makes sense to me! And thanks for digging in further there, that's exactly the nuance I was wondering about, but unsure of 😄

$prevent_unsupported_uploads = false;
}

Expand Down
179 changes: 179 additions & 0 deletions tests/phpunit/tests/rest-api/rest-attachments-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down Expand Up @@ -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' );
Expand Down Expand Up @@ -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.
Expand Down
Loading