Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
226429e
Image: sideload external images on the server when uploading to the l…
adamsilverstein Jun 22, 2026
81dd959
Image: add tests for external URL sideload
adamsilverstein Jun 22, 2026
c0ef781
Update CHANGELOG references to the upstream PR
adamsilverstein Jun 22, 2026
dfb6a9c
Image: require upload capability before sideloading an external URL
adamsilverstein Jun 22, 2026
9f5d1be
Image: validate sideload filename and set Location header
adamsilverstein Jun 22, 2026
5d59c87
Merge branch 'trunk' into fix/external-image-server-sideload
adamsilverstein Jun 22, 2026
6153573
Add backport changelog link to core PR 12268
adamsilverstein Jun 22, 2026
e0f4961
Merge branch 'trunk' into fix/external-image-server-sideload
adamsilverstein Jun 24, 2026
7d6c3e0
Merge remote-tracking branch 'origin/trunk' into fix/external-image-s…
adamsilverstein Jun 24, 2026
d0617fe
Merge branch 'trunk' into fix/external-image-server-sideload
adamsilverstein Jun 24, 2026
7ae78e6
Tests: Guard ReflectionMethod::setAccessible() for PHP 8.5
adamsilverstein Jun 24, 2026
ccae925
Image: register mediaSideloadFromUrl as a private block editor setting
adamsilverstein Jun 25, 2026
3c2213e
Merge remote-tracking branch 'origin/trunk' into fix/external-image-s…
adamsilverstein Jun 25, 2026
bbeaf2e
Image: Fire rest_after_insert_attachment on the URL sideload path.
adamsilverstein Jun 27, 2026
300cf54
Image: Validate the sideload url against SSRF.
adamsilverstein Jul 1, 2026
e488275
Update lib/media/class-gutenberg-rest-attachments-controller.php
adamsilverstein Jul 2, 2026
c38f8d4
Re-apply schema validation in the url validate_callback
adamsilverstein Jul 2, 2026
1739120
Merge remote-tracking branch 'origin/trunk' into fix/external-image-s…
adamsilverstein Jul 2, 2026
a969a3a
Merge branch 'trunk' into fix/external-image-server-sideload
adamsilverstein Jul 2, 2026
81d7825
Apply suggestions from code review
adamsilverstein Jul 6, 2026
6242718
Reject non-image URL extensions before downloading a sideload
adamsilverstein Jul 6, 2026
ebb6f65
Generate sub-sizes by default when sideloading external images
adamsilverstein Jul 6, 2026
d2ffa0b
Merge branch 'trunk' into fix/external-image-server-sideload
adamsilverstein Jul 6, 2026
9abd293
Add e2e coverage for button-gated external image upload
adamsilverstein Jul 6, 2026
15012f9
Merge remote-tracking branch 'origin/fix/external-image-server-sidelo…
adamsilverstein Jul 6, 2026
1570c0c
Specify value types in get_endpoint_args_for_item_schema() return doc…
adamsilverstein Jul 6, 2026
72e385e
Merge branch 'trunk' into fix/external-image-server-sideload
adamsilverstein Jul 6, 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
3 changes: 3 additions & 0 deletions backport-changelog/7.1/12268.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
https://github.com/WordPress/wordpress-develop/pull/12268

* https://github.com/WordPress/gutenberg/pull/79409
147 changes: 145 additions & 2 deletions lib/media/class-gutenberg-rest-attachments-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ public function create_item_permissions_check( $request ) {
* @param string $method Optional. HTTP method of the request. The arguments for `CREATABLE` requests are
* checked for required values and may fall-back to a given default, this is not done
* on `EDITABLE` requests. Default WP_REST_Server::CREATABLE.
* @return array Endpoint arguments.
* @return array<string, array<string, mixed>> Endpoint arguments keyed by argument name.
*/
public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) {
$args = rest_get_endpoint_args_for_schema( $this->get_item_schema(), $method );
Expand All @@ -284,6 +284,39 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE
'default' => true,
'description' => __( 'Whether to convert image formats.', 'gutenberg' ),
);
$args['url'] = array(
'type' => 'string',
'format' => 'uri',
'description' => __( 'URL of an external image to sideload into the media library, instead of uploading a file.', 'gutenberg' ),
'sanitize_callback' => 'sanitize_url',
Comment thread
westonruter marked this conversation as resolved.
'validate_callback' => static function ( $url, WP_REST_Request $request, string $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;
}
Comment thread
adamsilverstein marked this conversation as resolved.
/** @var non-empty-string $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 ( 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.', 'gutenberg' ),
array( 'status' => 400 )
);
}

return true;
},
);
}

return $args;
Expand Down Expand Up @@ -553,7 +586,17 @@ public function create_item( $request ) {
add_filter( 'image_editor_output_format', '__return_empty_array', 100 );
}

$response = parent::create_item( $request );
/*
* 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 );
} else {
$response = parent::create_item( $request );
}
Comment on lines +589 to +599

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Also makes sense to me. Great idea.

My first thought was, "url appears a lot in media-related args, could we qualify it somehow", but I couldn't really come up with a better alternative other that target_url and the schema is self documenting anyway.


remove_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 );
remove_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 );
Expand Down Expand Up @@ -593,6 +636,106 @@ 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().
*
* @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.
*/
private function create_item_from_url( WP_REST_Request $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.', 'gutenberg' ),
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.', 'gutenberg' ),
array( 'status' => 400 )
);
}

/*
* 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.', 'gutenberg' ),
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;
}

$attachment = get_post( $attachment_id );

$request->set_param( 'context', 'edit' );

/*
* 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 ) ) );

return $response;
}

/**
* Finalizes an attachment after client-side media processing.
*
Expand Down
2 changes: 2 additions & 0 deletions packages/block-editor/src/private-apis.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
isNavigationOverlayContextKey,
isNavigationPostEditorKey,
mediaUploadOnSuccessKey,
mediaSideloadFromUrlKey,
openMediaEditorModalKey,
} from './store/private-keys';
import { requiresWrapperOnCopy } from './components/writing-flow/utils';
Expand Down Expand Up @@ -145,6 +146,7 @@ lock( privateApis, {
isNavigationOverlayContextKey,
isNavigationPostEditorKey,
mediaUploadOnSuccessKey,
mediaSideloadFromUrlKey,
openMediaEditorModalKey,
useBlockElement,
useBlockElementRef,
Expand Down
1 change: 1 addition & 0 deletions packages/block-editor/src/store/private-keys.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ export const isNavigationOverlayContextKey = Symbol(
);
export const isNavigationPostEditorKey = Symbol( 'isNavigationPostEditor' );
export const mediaUploadOnSuccessKey = Symbol( 'mediaUploadOnSuccess' );
export const mediaSideloadFromUrlKey = Symbol( 'mediaSideloadFromUrl' );
export const openMediaEditorModalKey = Symbol( 'openMediaEditorModal' );
4 changes: 4 additions & 0 deletions packages/block-library/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
- Search: Expose an HTML element selector in the Advanced inspector panel that can render the block in the semantic HTML `<search>` landmark element instead of `<form role="search">`. Defers to `add_theme_support( 'html5', array( 'search-element' ) )` when the per-block value is left at "Default".
- Icon Block: Insert with a default icon instead of an empty placeholder ([#79111](https://github.com/WordPress/gutenberg/pull/79111)).

### Bug Fixes

- Image: external images inserted by URL are now sideloaded on the server when uploaded to the media library, so the upload works when the editor is cross-origin isolated ([#79409](https://github.com/WordPress/gutenberg/pull/79409)).

## 9.48.1 (2026-06-16)

## 9.48.0 (2026-06-10)
Expand Down
69 changes: 20 additions & 49 deletions packages/block-library/src/image/image.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/**
* WordPress dependencies
*/
import { isBlobURL } from '@wordpress/blob';
import {
ExternalLink,
FocalPointPicker,
Expand Down Expand Up @@ -84,6 +83,7 @@ const {
isDefaultBlockStyleState,
ResolutionTool,
mediaEditKey,
mediaSideloadFromUrlKey,
} = unlock( blockEditorPrivateApis );

const scaleOptions = [
Expand Down Expand Up @@ -416,7 +416,6 @@ export default function Image( {
{ loadedNaturalWidth, loadedNaturalHeight },
setLoadedNaturalSize,
] = useState( {} );
const [ externalBlob, setExternalBlob ] = useState();
const [ hasImageErrored, setHasImageErrored ] = useState( false );
const hasNonContentControls = blockEditingMode === 'default';
const isContentOnlyMode = blockEditingMode === 'contentOnly';
Expand Down Expand Up @@ -463,31 +462,16 @@ export default function Image( {
__unstableMarkNextChangeAsNotPersistent,
] );

// If an image is externally hosted, try to fetch the image data. This may
// fail if the image host doesn't allow CORS with the domain. If it works,
// we can enable a button in the toolbar to upload the image.
useEffect( () => {
if (
! isExternalImage( id, url ) ||
! isSingleSelected ||
! getSettings().mediaUpload
) {
setExternalBlob();
return;
}

if ( externalBlob ) {
return;
}

window
// Avoid cache, which seems to help avoid CORS problems.
.fetch( url.includes( '?' ) ? url : url + '?' )
.then( ( response ) => response.blob() )
.then( ( blob ) => setExternalBlob( blob ) )
// Do nothing, cannot upload.
.catch( () => {} );
}, [ id, url, isSingleSelected, externalBlob, getSettings ] );
/*
* Externally hosted images can be uploaded to the media library. The
* server sideloads the URL (see mediaSideloadFromUrl), so this works even
* when the editor is cross-origin isolated and the browser cannot read the
* cross-origin image's bytes itself.
*/
const canUploadExternalImage =
isSingleSelected &&
isExternalImage( id, url ) &&
!! getSettings()[ mediaSideloadFromUrlKey ];

// Get naturalWidth and naturalHeight from image, and fall back to loaded natural
// width and height. This resolves an issue in Safari where the loaded natural
Expand Down Expand Up @@ -604,31 +588,18 @@ export default function Image( {
}

function uploadExternal() {
const { mediaUpload } = getSettings();
if ( ! mediaUpload ) {
const mediaSideloadFromUrl = getSettings()[ mediaSideloadFromUrlKey ];
if ( ! mediaSideloadFromUrl ) {
return;
}
let notified = false;
mediaUpload( {
filesList: [ externalBlob ],
onFileChange( [ img ] ) {
mediaSideloadFromUrl( {
url,
onSuccess( img ) {
onSelectImage( img );

if ( isBlobURL( img.url ) ) {
return;
}

// With client-side media processing, onFileChange fires
// for each generated sub-size. Only show the notice once.
if ( ! notified ) {
notified = true;
setExternalBlob();
createSuccessNotice( __( 'Image uploaded.' ), {
type: 'snackbar',
} );
}
createSuccessNotice( __( 'Image uploaded.' ), {
type: 'snackbar',
} );
},
allowedTypes: ALLOWED_MEDIA_TYPES,
onError( message ) {
createErrorNotice( message, { type: 'snackbar' } );
},
Expand Down Expand Up @@ -941,7 +912,7 @@ export default function Image( {
) }
</BlockControls>
) }
{ isSingleSelected && externalBlob && (
{ canUploadExternalImage && (
<BlockControls>
<ToolbarGroup>
<ToolbarButton
Expand Down
4 changes: 4 additions & 0 deletions packages/editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

- Add an "Attachments" source to the block inserter's Media tab, listing images attached to the current post with the ability to attach and detach them ([#79336](https://github.com/WordPress/gutenberg/pull/79336)).

### Bug Fixes

- External images are now sideloaded on the server when uploaded to the media library, via a new `mediaSideloadFromUrl` block editor setting, so the upload works when the editor is cross-origin isolated (e.g. with client-side media processing enabled) ([#79409](https://github.com/WordPress/gutenberg/pull/79409)).

## 14.50.0 (2026-07-01)

## 14.49.0 (2026-06-24)
Expand Down
Loading
Loading