From 400ada3fb5cdd067d447b2a9a759eebd18ae1e7f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 22 Jun 2026 12:24:52 -0700 Subject: [PATCH 1/8] Media: Sideload external images on the server via a `url` REST parameter. Extend the attachments REST endpoint (`POST /wp/v2/media`) to accept an optional `url` parameter. When present, the server downloads the remote image with `download_url()` and sideloads it with `media_handle_sideload()`, instead of the browser fetching the bytes and posting a blob. A browser cross-origin fetch is subject to CORS, so uploading an externally-hosted image to the media library fails for any host that does not send permissive headers. This breaks entirely once the editor is cross-origin isolated, which client-side media processing requires. Letting the server fetch the URL avoids browser CORS, so external uploads work regardless of isolation. The existing sub-size and scaling filters continue to govern derivative generation, the `upload_files` capability is required, and a URL without a usable filename is rejected before any download is attempted. --- .../class-wp-rest-attachments-controller.php | 95 ++++++++ .../rest-api/rest-attachments-controller.php | 226 ++++++++++++++++++ 2 files changed, 321 insertions(+) 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..5aaea47082636 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 @@ -151,6 +151,12 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE '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', + ); } return $args; @@ -290,6 +296,7 @@ public function create_item_permissions_check( $request ) { * * @since 4.7.0 * @since 7.1.0 Added `generate_sub_sizes` and `convert_format` parameters. + * @since 7.1.0 Added the `url` parameter to sideload an external image on the server. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. @@ -317,6 +324,18 @@ public function create_item( $request ) { add_filter( 'image_editor_output_format', '__return_empty_array', 100 ); } + /* + * When a URL is supplied instead of an uploaded file, sideload the + * remote image on the server. This avoids a cross-origin browser fetch, + * which fails under cross-origin isolation. The sub-size and scaling + * filters applied above still govern whether derivatives are generated. + */ + if ( ! empty( $request['url'] ) ) { + $response = $this->create_item_from_url( $request ); + $this->remove_client_side_media_processing_filters(); + return $response; + } + $insert = $this->insert_attachment( $request ); if ( is_wp_error( $insert ) ) { @@ -410,6 +429,82 @@ public function create_item( $request ) { return $response; } + /** + * Sideloads an external image from a URL into the media library. + * + * Downloads the remote file on the server, avoiding a cross-origin browser + * fetch that fails under cross-origin isolation. Whether sub-sizes are + * generated is governed by the filters applied in create_item(). + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. + */ + protected function create_item_from_url( $request ) { + // Sideloading downloads and stores a file, so require the upload capability. + if ( ! current_user_can( 'upload_files' ) ) { + return new WP_Error( + 'rest_cannot_create', + __( 'Sorry, you are not allowed to upload media on this site.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + + $url = $request['url']; + $post_id = ! empty( $request['post'] ) ? (int) $request['post'] : 0; + + // Derive the filename from the URL path before downloading anything. + $url_path = wp_parse_url( $url, PHP_URL_PATH ); + $filename = $url_path ? wp_basename( $url_path ) : ''; + if ( '' === $filename ) { + return new WP_Error( + 'rest_invalid_url', + __( 'Could not determine a filename from the provided URL.' ), + array( 'status' => 400 ) + ); + } + + /* + * Download the remote file with WordPress's HTTP API, which validates + * the host and blocks requests to private or local addresses. This is + * the same primitive core's media_sideload_image() relies on. + */ + $tmp_file = download_url( $url ); + if ( is_wp_error( $tmp_file ) ) { + return $tmp_file; + } + + $file_array = array( + 'name' => $filename, + 'tmp_name' => $tmp_file, + ); + + $attachment_id = media_handle_sideload( $file_array, $post_id ); + + if ( is_wp_error( $attachment_id ) ) { + /* + * media_handle_sideload() deletes the temp file on success; remove + * it explicitly when the sideload fails. + */ + if ( file_exists( $tmp_file ) ) { + wp_delete_file( $tmp_file ); + } + return $attachment_id; + } + + $request->set_param( 'context', 'edit' ); + $response = $this->prepare_item_for_response( get_post( $attachment_id ), $request ); + $response->set_status( 201 ); + $response->header( 'Location', rest_url( rest_get_route_for_post( $attachment_id ) ) ); + + return $response; + } + /** * Removes filters added for client-side media processing. * diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 79e9d23cf9dd3..3757f26f8ec8a 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3541,4 +3541,230 @@ public function test_finalize_item_invalid_id(): void { $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); } + + /** + * The URL requested by the most recent mocked HTTP download. + * + * @var string|null + */ + protected $last_download_url = null; + + /** + * Short-circuits download_url()'s HTTP request, writing a local fixture into + * the streamed temp file so media_handle_sideload() has a real image to process. + * + * Mirrors the approach core's media_sideload_image() tests use: returning a + * non-false value from `pre_http_request` skips the network, so the mock must + * copy the fixture into the `filename` the request would have streamed to. + * + * @param false|array|WP_Error $response A preempted response, or false to continue. + * @param array $args HTTP request arguments. + * @param string $url The request URL. + * @return array A faked 200 response. + */ + public function mock_image_download( $response, $args, $url ) { + $this->last_download_url = $url; + + if ( ! empty( $args['filename'] ) ) { + copy( DIR_TESTDATA . '/images/canola.jpg', $args['filename'] ); + } + + return array( + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'headers' => array(), + 'cookies' => array(), + 'body' => '', + ); + } + + /** + * Verifies that supplying a `url` to the create endpoint sideloads the remote + * image on the server and, with generate_sub_sizes=false, creates no sub-sizes. + * + * This is the cross-origin-isolation fallback path: the server fetches the + * remote image so the browser does not have to, and only the original is kept. + * + * @covers WP_REST_Attachments_Controller::create_item + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_sideloads_without_subsizes() { + $this->enable_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() ); + $this->assertSame( 'image', $data['media_type'] ); + $this->assertSame( 'https://example.com/photo.jpg', $this->last_download_url ); + + // No sub-sizes should have been generated; only the original is stored. + $metadata = wp_get_attachment_metadata( $data['id'], true ); + $this->assertEmpty( $metadata['sizes'] ?? array(), 'Sideloaded external image should have no sub-sizes.' ); + } + + /** + * Verifies that a sideloaded external image is attached to the post passed in + * the `post` parameter. + * + * @covers WP_REST_Attachments_Controller::create_item + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_attaches_to_post() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + $parent_post = self::factory()->post->create(); + + 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/attached.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + $request->set_param( 'post', $parent_post ); + + $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( $parent_post, get_post( $data['id'] )->post_parent ); + } + + /** + * Verifies that a failed download propagates the WP_Error from download_url() + * rather than creating an attachment. + * + * @covers WP_REST_Attachments_Controller::create_item + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_returns_error_on_download_failure() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + $fail_download = static function () { + return new WP_Error( 'http_request_failed', 'Could not resolve host.' ); + }; + add_filter( 'pre_http_request', $fail_download ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/missing.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', $fail_download ); + + $this->assertSame( 'http_request_failed', $response->get_data()['code'] ); + $this->assertSame( 500, $response->get_status() ); + } + + /** + * Verifies that a URL with no usable path bails with a 400 before any + * download is attempted, rather than handing an empty filename to the + * sideload handler. + * + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_rejects_url_without_filename() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + // Fail loudly if the guard does not bail and a download is attempted. + $downloaded = false; + $track = static function () use ( &$downloaded ) { + $downloaded = true; + return new WP_Error( 'http_request_failed', 'Should not be reached.' ); + }; + add_filter( 'pre_http_request', $track ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/?img=123' ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', $track ); + + $this->assertSame( 'rest_invalid_url', $response->get_data()['code'] ); + $this->assertSame( 400, $response->get_status() ); + $this->assertFalse( $downloaded, 'No download should be attempted for a URL without a filename.' ); + } + + /** + * Verifies that a user without the `upload_files` capability cannot sideload + * an external image and that the request bails before any download happens. + * + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_requires_upload_capability() { + $subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $subscriber_id ); + + // Fail loudly if the guard does not bail and a download is attempted. + $downloaded = false; + $track = static function () use ( &$downloaded ) { + $downloaded = true; + return new WP_Error( 'http_request_failed', 'Should not be reached.' ); + }; + add_filter( 'pre_http_request', $track ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/denied.jpg' ); + + $controller = new WP_REST_Attachments_Controller( 'attachment' ); + $method = new ReflectionMethod( $controller, 'create_item_from_url' ); + $method->setAccessible( true ); + $result = $method->invoke( $controller, $request ); + + remove_filter( 'pre_http_request', $track ); + + $this->assertWPError( $result ); + $this->assertSame( 'rest_cannot_create', $result->get_error_code() ); + $this->assertSame( 403, $result->get_error_data()['status'] ); + $this->assertFalse( $downloaded, 'No download should be attempted without upload_files.' ); + } + + /** + * Verifies that the `url` argument is registered on the creatable media route + * so requests can supply an external image URL to sideload. + * + * @covers WP_REST_Attachments_Controller::get_endpoint_args_for_item_schema + */ + public function test_url_registered_as_creatable_arg() { + $this->enable_client_side_media_processing(); + + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/wp/v2/media', $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->assertSame( 'string', $creatable['args']['url']['type'] ); + $this->assertSame( 'uri', $creatable['args']['url']['format'] ); + } } From 4de4f088a06fb7ed40009cfd89d2f314bcf1fefd Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 22 Jun 2026 15:35:40 -0700 Subject: [PATCH 2/8] Tests: Regenerate REST API client fixtures for the media `url` parameter. The sideload `url` REST parameter added to the `POST /wp/v2/media` endpoint changes the generated API schema. Update the QUnit fixture so the `test_build_wp_api_client_fixtures` git-diff check passes. --- tests/qunit/fixtures/wp-api-generated.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index fa03d9751fe99..7dcb7f7f54734 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -3160,6 +3160,12 @@ mockedApiResponse.Schema = { "default": true, "description": "Whether to convert image formats.", "required": false + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of an external image to sideload into the media library, instead of uploading a file.", + "required": false } } } From 105ea30da059fdd6f6c0ac29cddb900962f6ba9b Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 22 Jun 2026 16:03:27 -0700 Subject: [PATCH 3/8] Tests: Guard ReflectionMethod::setAccessible() for PHP 8.5 compatibility. `ReflectionMethod::setAccessible()` is deprecated as of PHP 8.5 (it has had no effect since PHP 8.1), and the test suite converts deprecations to exceptions, so the unconditional call errored on PHP 8.5. Wrap it in the established `PHP_VERSION_ID < 80100` guard used elsewhere in core tests. --- tests/phpunit/tests/rest-api/rest-attachments-controller.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 3757f26f8ec8a..8fe766928a44e 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3731,7 +3731,9 @@ public function test_create_item_from_url_requires_upload_capability() { $controller = new WP_REST_Attachments_Controller( 'attachment' ); $method = new ReflectionMethod( $controller, 'create_item_from_url' ); - $method->setAccessible( true ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } $result = $method->invoke( $controller, $request ); remove_filter( 'pre_http_request', $track ); From 93c911e3caad94c32b174e5ea9b8652feb11b71d Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 22 Jun 2026 17:57:51 -0700 Subject: [PATCH 4/8] Media: Fire rest_after_insert_attachment on the URL sideload path. media_handle_sideload() fires the standard insert hooks (including wp_after_insert_post), but not the REST-specific rest_after_insert_attachment action. Fire it on the sideload path for parity with the uploaded-file path in create_item(), so extensions hooking the documented REST action also run for attachments created from a URL. Also consolidate the duplicate `@since 7.1.0` docblock line, and add tests covering sub-size generation under the default generate_sub_sizes and the rest_after_insert_attachment firing. --- .../class-wp-rest-attachments-controller.php | 18 ++++- .../rest-api/rest-attachments-controller.php | 69 +++++++++++++++++++ 2 files changed, 84 insertions(+), 3 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 5aaea47082636..4b492d3b9af40 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 @@ -295,8 +295,7 @@ public function create_item_permissions_check( $request ) { * Creates a single attachment. * * @since 4.7.0 - * @since 7.1.0 Added `generate_sub_sizes` and `convert_format` parameters. - * @since 7.1.0 Added the `url` parameter to sideload an external image on the server. + * @since 7.1.0 Added the `generate_sub_sizes`, `convert_format`, and `url` parameters. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. @@ -497,8 +496,21 @@ protected function create_item_from_url( $request ) { return $attachment_id; } + $attachment = get_post( $attachment_id ); + $request->set_param( 'context', 'edit' ); - $response = $this->prepare_item_for_response( get_post( $attachment_id ), $request ); + + /* + * media_handle_sideload() fires the standard insert hooks (including + * wp_after_insert_post), but not the REST-specific action, so fire it + * here for parity with the uploaded-file path in create_item(). + * + * This action is documented in + * wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php + */ + do_action( 'rest_after_insert_attachment', $attachment, $request, true ); + + $response = $this->prepare_item_for_response( $attachment, $request ); $response->set_status( 201 ); $response->header( 'Location', rest_url( rest_get_route_for_post( $attachment_id ) ) ); diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 8fe766928a44e..6d3503d56b0db 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3616,6 +3616,75 @@ public function test_create_item_from_url_sideloads_without_subsizes() { $this->assertEmpty( $metadata['sizes'] ?? array(), 'Sideloaded external image should have no sub-sizes.' ); } + /** + * Verifies that, with the default generate_sub_sizes (true), sideloading an + * external image generates sub-sizes, so the filters applied in create_item() + * still govern derivative generation on the URL path. + * + * @covers WP_REST_Attachments_Controller::create_item + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_generates_subsizes_by_default() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + + // Note: generate_sub_sizes is intentionally not set, so it defaults to true. + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/full.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() ); + + $metadata = wp_get_attachment_metadata( $data['id'], true ); + $this->assertNotEmpty( $metadata['sizes'] ?? array(), 'Sub-sizes should be generated when generate_sub_sizes is true.' ); + } + + /** + * Verifies that the REST-specific rest_after_insert_attachment action fires on + * the URL sideload path, for parity with the uploaded-file path. + * + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_fires_rest_after_insert_attachment() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + $fired = array(); + $spy = static function ( $attachment, $request, $creating ) use ( &$fired ) { + $fired = array( + 'id' => $attachment->ID, + 'creating' => $creating, + ); + }; + + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + add_action( 'rest_after_insert_attachment', $spy, 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/hooked.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + $response = rest_get_server()->dispatch( $request ); + + remove_action( 'rest_after_insert_attachment', $spy, 10 ); + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( $data['id'], $fired['id'] ?? null, 'rest_after_insert_attachment should fire with the new attachment.' ); + $this->assertTrue( $fired['creating'] ?? null, 'rest_after_insert_attachment should report creating=true.' ); + } + /** * Verifies that a sideloaded external image is attached to the post passed in * the `post` parameter. From 039354ca2c82c66a1fd8f56d052ef72695718373 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 23 Jun 2026 15:39:03 -0700 Subject: [PATCH 5/8] Tests: Add @ticket 65517 annotations to the URL sideload tests. --- .../rest-api/rest-attachments-controller.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 6d3503d56b0db..4a75a65d42fe5 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3587,6 +3587,8 @@ public function mock_image_download( $response, $args, $url ) { * This is the cross-origin-isolation fallback path: the server fetches the * remote image so the browser does not have to, and only the original is kept. * + * @ticket 65517 + * * @covers WP_REST_Attachments_Controller::create_item * @covers WP_REST_Attachments_Controller::create_item_from_url */ @@ -3621,6 +3623,8 @@ public function test_create_item_from_url_sideloads_without_subsizes() { * external image generates sub-sizes, so the filters applied in create_item() * still govern derivative generation on the URL path. * + * @ticket 65517 + * * @covers WP_REST_Attachments_Controller::create_item * @covers WP_REST_Attachments_Controller::create_item_from_url */ @@ -3651,6 +3655,8 @@ public function test_create_item_from_url_generates_subsizes_by_default() { * Verifies that the REST-specific rest_after_insert_attachment action fires on * the URL sideload path, for parity with the uploaded-file path. * + * @ticket 65517 + * * @covers WP_REST_Attachments_Controller::create_item_from_url */ public function test_create_item_from_url_fires_rest_after_insert_attachment() { @@ -3689,6 +3695,8 @@ public function test_create_item_from_url_fires_rest_after_insert_attachment() { * Verifies that a sideloaded external image is attached to the post passed in * the `post` parameter. * + * @ticket 65517 + * * @covers WP_REST_Attachments_Controller::create_item * @covers WP_REST_Attachments_Controller::create_item_from_url */ @@ -3720,6 +3728,8 @@ public function test_create_item_from_url_attaches_to_post() { * Verifies that a failed download propagates the WP_Error from download_url() * rather than creating an attachment. * + * @ticket 65517 + * * @covers WP_REST_Attachments_Controller::create_item * @covers WP_REST_Attachments_Controller::create_item_from_url */ @@ -3750,6 +3760,8 @@ public function test_create_item_from_url_returns_error_on_download_failure() { * download is attempted, rather than handing an empty filename to the * sideload handler. * + * @ticket 65517 + * * @covers WP_REST_Attachments_Controller::create_item_from_url */ public function test_create_item_from_url_rejects_url_without_filename() { @@ -3781,6 +3793,8 @@ public function test_create_item_from_url_rejects_url_without_filename() { * Verifies that a user without the `upload_files` capability cannot sideload * an external image and that the request bails before any download happens. * + * @ticket 65517 + * * @covers WP_REST_Attachments_Controller::create_item_from_url */ public function test_create_item_from_url_requires_upload_capability() { @@ -3817,6 +3831,8 @@ public function test_create_item_from_url_requires_upload_capability() { * Verifies that the `url` argument is registered on the creatable media route * so requests can supply an external image URL to sideload. * + * @ticket 65517 + * * @covers WP_REST_Attachments_Controller::get_endpoint_args_for_item_schema */ public function test_url_registered_as_creatable_arg() { From f5bb404e003501a24da28d4dbd711c5d05a77bc1 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Wed, 1 Jul 2026 11:43:02 -0700 Subject: [PATCH 6/8] Update src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php Co-authored-by: Weston Ruter --- .../endpoints/class-wp-rest-attachments-controller.php | 4 +--- 1 file changed, 1 insertion(+), 3 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 4b492d3b9af40..4d6bec5a1115b 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 @@ -504,10 +504,8 @@ protected function create_item_from_url( $request ) { * media_handle_sideload() fires the standard insert hooks (including * wp_after_insert_post), but not the REST-specific action, so fire it * here for parity with the uploaded-file path in create_item(). - * - * This action is documented in - * wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php */ + /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php */ do_action( 'rest_after_insert_attachment', $attachment, $request, true ); $response = $this->prepare_item_for_response( $attachment, $request ); From f186dd0d0c6de20b5c771a89d302105a48139ce5 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 1 Jul 2026 15:28:34 -0700 Subject: [PATCH 7/8] REST API: Validate the sideload url against SSRF. Add a validate_callback to the url media argument that rejects any value wp_http_validate_url() does not accept, so requests to private, local, or non-HTTP(S) hosts are refused with a 400 before download_url() runs. This closes the SSRF vector of letting the server fetch an arbitrary URL. A test covers the safe-URL pass and the non-string, disallowed-scheme, and malformed-URL rejections. --- .../class-wp-rest-attachments-controller.php | 16 +++++++ .../rest-api/rest-attachments-controller.php | 44 +++++++++++++++++++ 2 files changed, 60 insertions(+) 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 4d6bec5a1115b..93e0a06d14b13 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 @@ -156,6 +156,22 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE '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 ) { + /* + * 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 ( ! is_string( $url ) || 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; + }, ); } diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 4a75a65d42fe5..571b7944361ec 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3854,4 +3854,48 @@ public function test_url_registered_as_creatable_arg() { $this->assertSame( 'string', $creatable['args']['url']['type'] ); $this->assertSame( 'uri', $creatable['args']['url']['format'] ); } + + /** + * Verifies that the `url` argument rejects values that are not safe to + * request server-side, guarding the sideload against SSRF. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::get_endpoint_args_for_item_schema + */ + public function test_url_arg_rejects_unsafe_urls() { + $this->enable_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( 'validate_callback', $creatable['args']['url'] ); + + $validate = $creatable['args']['url']['validate_callback']; + + // A well-formed URL on the site's own host passes validation. + $this->assertTrue( $validate( home_url( '/image.jpg' ) ), 'A safe URL should pass validation.' ); + + // A non-string, a disallowed scheme, and a malformed URL are all rejected. + $invalid_urls = array( + array( 'https://example.org/image.jpg' ), + 'ftp://example.org/image.jpg', + 'javascript:alert(1)', + 'not-a-url', + ); + + foreach ( $invalid_urls as $invalid ) { + $result = $validate( $invalid ); + $this->assertWPError( $result, 'An unsafe URL should be rejected.' ); + $this->assertSame( 'rest_invalid_url', $result->get_error_code() ); + $this->assertSame( 400, $result->get_error_data()['status'] ); + } + } } From beac747e0e7488f0d628d589fe7033205bc08b32 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 6 Jul 2026 08:38:41 -0700 Subject: [PATCH 8/8] REST API: Sync url sideload with merged Gutenberg changes. Bring the backport in line with the final merged code in Gutenberg PR 79409, which gained two changes during review: - Reject URLs whose extension does not map to an image MIME type before downloading, so files that can never be accepted (such as PHP scripts) are not fetched at all. - Re-apply rest_validate_request_arg() in the url validate_callback, since a custom callback replaces the default and would otherwise silently drop the schema's string type and uri format checks. Port the corresponding tests and update the direct-call SSRF test to the new three-argument callback signature. --- .../class-wp-rest-attachments-controller.php | 29 ++++++- .../rest-api/rest-attachments-controller.php | 83 ++++++++++++++++++- 2 files changed, 106 insertions(+), 6 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 995fd2e8afd74..0f7ff5484d541 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 @@ -250,13 +250,23 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE '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 ) { + '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 ( ! is_string( $url ) || false === wp_http_validate_url( $url ) ) { + 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.' ), @@ -598,6 +608,21 @@ protected function create_item_from_url( $request ) { ); } + /* + * Only download URLs whose extension maps to an allowed image MIME type. + * The sideload handler would reject other types anyway (via + * wp_check_filetype_and_ext()), but checking first avoids downloading + * files that can never be accepted, such as PHP scripts. + */ + $filetype = wp_check_filetype( $filename ); + if ( ! $filetype['type'] || ! str_starts_with( $filetype['type'], 'image/' ) ) { + return new WP_Error( + 'rest_invalid_url', + __( 'The provided URL does not point to a supported image file.' ), + array( 'status' => 400 ) + ); + } + /* * Download the remote file with WordPress's HTTP API, which validates * the host and blocks requests to private or local addresses. This is diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 69cd15764f231..4f864d3ab2fcb 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -4594,6 +4594,58 @@ public function test_create_item_from_url_rejects_url_without_filename() { $this->assertFalse( $downloaded, 'No download should be attempted for a URL without a filename.' ); } + /** + * Verifies that a URL pointing to a file without an allowed image extension, + * such as a PHP script, is rejected before any download is attempted. + * + * @ticket 65517 + * + * @dataProvider data_create_item_from_url_rejects_non_image_extension + * + * @covers WP_REST_Attachments_Controller::create_item_from_url + * + * @param string $url URL with a disallowed file extension. + */ + public function test_create_item_from_url_rejects_non_image_extension( $url ) { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + // Fail loudly if the guard does not bail and a download is attempted. + $downloaded = false; + $track = static function () use ( &$downloaded ) { + $downloaded = true; + return new WP_Error( 'http_request_failed', 'Should not be reached.' ); + }; + add_filter( 'pre_http_request', $track ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', $url ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', $track ); + + $this->assertSame( 'rest_invalid_url', $response->get_data()['code'] ); + $this->assertSame( 400, $response->get_status() ); + $this->assertFalse( $downloaded, 'No download should be attempted for a non-image URL.' ); + } + + /** + * Data provider for test_create_item_from_url_rejects_non_image_extension(). + * + * @return array[] + */ + public function data_create_item_from_url_rejects_non_image_extension() { + return array( + 'PHP script' => array( 'https://example.com/evil.php' ), + 'HTML document' => array( 'https://example.com/page.html' ), + 'video file' => array( 'https://example.com/clip.mp4' ), + 'no extension' => array( 'https://example.com/image' ), + 'double extension' => array( 'https://example.com/photo.jpg.php' ), + ); + } + /** * Verifies that a user without the `upload_files` capability cannot sideload * an external image and that the request bails before any download happens. @@ -4632,6 +4684,29 @@ public function test_create_item_from_url_requires_upload_capability() { $this->assertFalse( $downloaded, 'No download should be attempted without upload_files.' ); } + /** + * Verifies that schema validation still applies to the `url` argument even + * though it registers a custom `validate_callback`, which replaces the + * default rest_validate_request_arg() unless re-applied. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::get_endpoint_args_for_item_schema + */ + public function test_create_item_from_url_rejects_non_string_url() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', array( 'https://example.com/image.jpg' ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] ); + $this->assertSame( 400, $response->get_status() ); + } + /** * Verifies that the `url` argument is registered on the creatable media route * so requests can supply an external image URL to sideload. @@ -4684,20 +4759,20 @@ public function test_url_arg_rejects_unsafe_urls() { $this->assertArrayHasKey( 'validate_callback', $creatable['args']['url'] ); $validate = $creatable['args']['url']['validate_callback']; + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); // A well-formed URL on the site's own host passes validation. - $this->assertTrue( $validate( home_url( '/image.jpg' ) ), 'A safe URL should pass validation.' ); + $this->assertTrue( $validate( home_url( '/image.jpg' ), $request, 'url' ), 'A safe URL should pass validation.' ); - // A non-string, a disallowed scheme, and a malformed URL are all rejected. + // A disallowed scheme and a malformed URL are both rejected. $invalid_urls = array( - array( 'https://example.org/image.jpg' ), 'ftp://example.org/image.jpg', 'javascript:alert(1)', 'not-a-url', ); foreach ( $invalid_urls as $invalid ) { - $result = $validate( $invalid ); + $result = $validate( $invalid, $request, 'url' ); $this->assertWPError( $result, 'An unsafe URL should be rejected.' ); $this->assertSame( 'rest_invalid_url', $result->get_error_code() ); $this->assertSame( 400, $result->get_error_data()['status'] );