Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f19689b
Add Document-Isolation-Policy support for cross-origin isolation
adamsilverstein Feb 27, 2026
d61fae0
Remove COEP/COOP headers for non-Chrome browsers
adamsilverstein Feb 27, 2026
24178f3
Fix embed breakage on non-DIP browsers
adamsilverstein Feb 27, 2026
79c1cf7
Skip cross-origin isolation for third-party editors
adamsilverstein Feb 27, 2026
731741f
Remove core COEP/COOP hooks in favor of DIP
adamsilverstein Feb 27, 2026
29c3770
Skip COEP/COOP E2E tests on Chrome 137+ where DIP is used
adamsilverstein Feb 27, 2026
71ea0a7
Fix cover focal point E2E test race condition
adamsilverstein Feb 28, 2026
4acfb3a
Remove COEP/COOP and credentialless code
adamsilverstein Mar 1, 2026
dd1fb9e
Update tests for DIP-only approach
adamsilverstein Mar 1, 2026
a7a1cf2
Update docs and comments for DIP-only
adamsilverstein Mar 1, 2026
8504d84
Add backport changelog for WP 7.0
adamsilverstein Mar 1, 2026
79a2ebf
Rename Chrome references to Chromium
adamsilverstein Mar 5, 2026
7f5dca0
Remove media processing exception from classic block test
adamsilverstein Mar 5, 2026
63ae358
Remove media processing exception from meta box test
adamsilverstein Mar 5, 2026
16a87dc
Remove media processing exception from preview test
adamsilverstein Mar 5, 2026
b38ce3b
Remove media processing exception from template mode test
adamsilverstein Mar 5, 2026
d763736
Revert "Remove media processing exception from template mode test"
adamsilverstein Mar 5, 2026
6cacd99
Remove media processing exception from plugin and iframed block tests
adamsilverstein Mar 5, 2026
54e1806
Update DIP exception comments in E2E tests
adamsilverstein Mar 5, 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.0/11098.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
https://github.com/WordPress/wordpress-develop/pull/11098

* https://github.com/WordPress/gutenberg/pull/75991
18 changes: 9 additions & 9 deletions lib/media/docs/client-side-media-docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ Client-side media processing requires the following browser capabilities:
|---------|----------|---------|
| WebAssembly | Yes | Runs the wasm-vips image processing library |
| SharedArrayBuffer | Yes | Enables multi-threaded WASM execution |
| Cross-origin isolation | Yes | Required for SharedArrayBuffer in modern browsers |
| Document-Isolation-Policy | Yes | Required for SharedArrayBuffer in modern browsers |
| CSP `blob:` workers | Yes | The WASM worker is loaded via a blob URL |

### Browser Support Matrix

| Browser | Minimum Version | Notes |
|---------|-----------------|-------|
| Chrome | 92+ | Full support |
| Firefox | 79+ | Full support except embed previews |
| Safari | 15.2+ | Requires `require-corp` instead of `credentialless` |
| Edge | 92+ | Full support |
| Chromium | 137+ | Full support via Document-Isolation-Policy |
| Edge | 137+ | Full support via Document-Isolation-Policy |
| Firefox | Not supported | Does not support Document-Isolation-Policy |
| Safari | Not supported | Does not support Document-Isolation-Policy |

### Automatic Fallback

Expand All @@ -27,22 +27,23 @@ When client-side media processing is unavailable, the system automatically falls
The fallback occurs when any of the following conditions are detected:
- WebAssembly is not supported in the browser
- SharedArrayBuffer is not available
- Cross-origin isolation is not enabled (missing required headers)
- Document-Isolation-Policy is not supported by the browser
- The site's Content Security Policy (CSP) blocks blob URL workers

## Cross-origin isolation / `SharedArrayBuffer`

WASM-based image optimization requires `SharedArrayBuffer` support, which in turn requires [cross-origin isolation](https://web.dev/articles/cross-origin-isolation-guide).

Once the page is served with these headers, `SharedArrayBuffer` will be available in the browser, and WASM-based image optimization will work as expected. However, all embedded resources (e.g., images, iframes, scripts) must also be served with appropriate CORS headers (or iframe with `iframe-credentialless` for supporting browsers) to ensure cross-origin isolation is maintained. For third party embeds (for example a YouTube video), the plugin uses [iframe `credentialless` attribute](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/IFrame_credentialless) to help with this. For browsers that do not support this attribute, embeds will show an information pane instead of a live preview.
This is achieved using the [`Document-Isolation-Policy`](https://github.com/nicolo-ribaudo/tc39-proposal-structs/blob/main/test262-filtering/isolation-explainer.md) header, which provides per-document cross-origin isolation without affecting other iframes on the page. This avoids the breakage that the older `Cross-Origin-Embedder-Policy` / `Cross-Origin-Opener-Policy` headers caused for third-party plugins and embeds.
Copy link
Contributor

Choose a reason for hiding this comment

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


Once the page is served with this header, `SharedArrayBuffer` will be available in the browser, and WASM-based image optimization will work as expected. All embedded resources (e.g., images, scripts) are served with `crossorigin="anonymous"` to ensure cross-origin isolation is maintained.

### Troubleshooting

If client-side media processing is not working, check the browser console for messages. Common issues include:

1. **"SharedArrayBuffer is not available"**: The server is not sending the required cross-origin isolation headers.
2. **"Cross-origin isolation is not enabled"**: The headers are present but cross-origin isolation is not active. This can happen if:
- Third-party resources are blocking isolation
- Headers are being stripped by a proxy or CDN
- The page is being served over HTTP instead of HTTPS

Expand All @@ -54,4 +55,3 @@ If client-side media processing is not working, check the browser console for me
- If the CSP header is set at the server level (e.g., in `.htaccess`, Nginx config, or a CDN), update it there

Check out [this tracking issue](https://github.com/WordPress/gutenberg/issues/74464) for more details and further resources.

70 changes: 57 additions & 13 deletions lib/media/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -219,13 +219,29 @@ function gutenberg_filter_mod_rewrite_rules( string $rules ): string {

add_filter( 'mod_rewrite_rules', 'gutenberg_filter_mod_rewrite_rules' );

/**
* Returns the major Chromium version from the current request's User-Agent.
*
* Matches all Chromium-based browsers (Chrome, Edge, Opera, Brave).
*
* @return int|null The major Chromium version, or null if not a Chromium browser.
*/
function gutenberg_get_chromium_major_version(): ?int {
if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) {
return null;
}
if ( preg_match( '/Chrome\/(\d+)/', $_SERVER['HTTP_USER_AGENT'], $matches ) ) {
return (int) $matches[1];
}
return null;
}

/**
* Enables cross-origin isolation in the block editor.
*
* Required for enabling SharedArrayBuffer for WebAssembly-based
* media processing in the editor.
*
* @link https://web.dev/coop-coep/
* media processing in the editor. Uses Document-Isolation-Policy
* on supported browsers (Chromium 137+).
*/
function gutenberg_set_up_cross_origin_isolation() {
// Re-check the filter at action time, since other plugins (loaded after Gutenberg)
Expand All @@ -244,6 +260,14 @@ function gutenberg_set_up_cross_origin_isolation() {
return;
}

// Skip when a third-party page builder overrides the block editor.
// DIP isolates the document into its own agent cluster,
// which blocks same-origin iframe access that these editors rely on.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['action'] ) && 'edit' !== $_GET['action'] ) {
return;
}

$user_id = get_current_user_id();
if ( ! $user_id ) {
return;
Expand All @@ -262,24 +286,44 @@ function gutenberg_set_up_cross_origin_isolation() {
add_action( 'load-site-editor.php', 'gutenberg_set_up_cross_origin_isolation' );
add_action( 'load-widgets.php', 'gutenberg_set_up_cross_origin_isolation' );

// Remove core's COEP/COOP-based cross-origin isolation in favor of
// Gutenberg's DIP-based approach, which also skips third-party editors.
remove_action( 'load-post.php', 'wp_set_up_cross_origin_isolation' );
remove_action( 'load-post-new.php', 'wp_set_up_cross_origin_isolation' );
remove_action( 'load-site-editor.php', 'wp_set_up_cross_origin_isolation' );
remove_action( 'load-widgets.php', 'wp_set_up_cross_origin_isolation' );

/**
* Sends headers for cross-origin isolation.
* Sends the Document-Isolation-Policy header for cross-origin isolation.
*
* Uses an output buffer to add crossorigin="anonymous" where needed.
*
* @link https://web.dev/coop-coep/
*
* @global bool $is_safari
*/
function gutenberg_start_cross_origin_isolation_output_buffer(): void {
global $is_safari;
$chromium_version = gutenberg_get_chromium_major_version();

/**
* Filters whether to use Document-Isolation-Policy for cross-origin isolation.
*
* Document-Isolation-Policy provides per-document cross-origin isolation
* without affecting other iframes on the page, avoiding breakage of plugins
* whose iframes lose credentials/DOM access.
*
* @since 21.8.0
*
* @param bool $use_dip Whether DIP is supported and should be used.
*/
$use_dip = apply_filters(
'gutenberg_use_document_isolation_policy',
null !== $chromium_version && $chromium_version >= 137
);

$coep = $is_safari ? 'require-corp' : 'credentialless';
if ( ! $use_dip ) {
return;
}

ob_start(
function ( string $output ) use ( $coep ): string {
header( 'Cross-Origin-Opener-Policy: same-origin' );
header( "Cross-Origin-Embedder-Policy: $coep" );
function ( string $output ): string {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
function ( string $output ): string {
static function ( string $output ): string {

I think, based on coding guidelines, closures should be static.

Copy link
Member

Choose a reason for hiding this comment

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

We can fix this CS in a follow-up.

header( 'Document-Isolation-Policy: isolate-and-credentialless' );

return gutenberg_add_crossorigin_attributes( $output );
}
Expand Down
115 changes: 8 additions & 107 deletions packages/block-editor/src/hooks/cross-origin-isolation.js
Original file line number Diff line number Diff line change
@@ -1,34 +1,12 @@
/**
* WordPress dependencies
*/
import { addFilter } from '@wordpress/hooks';
import { createHigherOrderComponent } from '@wordpress/compose';

/**
* Adds crossorigin and credentialless attributes to elements as needed.
* Adds crossorigin="anonymous" to an element if missing.
*
* @param {Element} el The element to modify.
*/
function addCrossOriginAttributes( el ) {
// Add the crossorigin attribute if missing.
function addCrossOriginAttribute( el ) {
if ( ! el.hasAttribute( 'crossorigin' ) ) {
el.setAttribute( 'crossorigin', 'anonymous' );
}

// For iframes, add the credentialless attribute.
if ( el.nodeName === 'IFRAME' && ! el.hasAttribute( 'credentialless' ) ) {
// Do not modify the iframed editor canvas.
if ( el.getAttribute( 'src' )?.startsWith( 'blob:' ) ) {
return;
}

el.setAttribute( 'credentialless', '' );

// Reload the iframe to ensure the new attribute is taken into account.
const origSrc = el.getAttribute( 'src' ) || '';
el.setAttribute( 'src', '' );
el.setAttribute( 'src', origSrc );
}
}

// Only add the mutation observer if the site is cross-origin isolated.
Expand All @@ -50,58 +28,17 @@ if ( window.crossOriginIsolated ) {
}

el.querySelectorAll(
'img,source,script,video,link,iframe'
'img,source,script,video,link'
).forEach( ( v ) => {
addCrossOriginAttributes( v );
addCrossOriginAttribute( v );
} );

if ( el.nodeName === 'IFRAME' ) {
const iframeNode = el;

/*
* Sandboxed iframes should not get modified. For example embedding a tweet served in a sandboxed
* iframe, the tweet itself would not be modified.
*/
const isEmbedSandboxIframe =
iframeNode.classList.contains(
'components-sandbox'
);

if ( ! isEmbedSandboxIframe ) {
iframeNode.addEventListener( 'load', () => {
try {
if (
iframeNode.contentDocument &&
iframeNode.contentDocument.body
) {
observer.observe(
iframeNode.contentDocument,
{
childList: true,
attributes: true,
subtree: true,
}
);
}
} catch ( e ) {
// Iframe may be cross-origin or otherwise inaccessible.
// Silently ignore these cases.
}
} );
}
}

if (
[
'IMG',
'SOURCE',
'SCRIPT',
'VIDEO',
'LINK',
'IFRAME',
].includes( el.nodeName )
[ 'IMG', 'SOURCE', 'SCRIPT', 'VIDEO', 'LINK' ].includes(
el.nodeName
)
) {
addCrossOriginAttributes( el );
addCrossOriginAttribute( el );
}
} );
} );
Expand Down Expand Up @@ -134,39 +71,3 @@ if ( window.crossOriginIsolated ) {

startObservingBody();
}

// Only apply the embed preview filter when cross-origin isolated.
if ( window.crossOriginIsolated ) {
const supportsCredentialless =
'credentialless' in window.HTMLIFrameElement.prototype;

const disableEmbedPreviews = createHigherOrderComponent(
( BlockEdit ) =>
function DisableEmbedPreviews( props ) {
if ( 'core/embed' !== props.name ) {
return <BlockEdit { ...props } />;
}

// List of embeds that do not support a preview is from packages/block-library/src/embed/variations.js.
const previewable =
supportsCredentialless &&
! [ 'facebook', 'smugmug' ].includes(
props.attributes.providerNameSlug
);

return (
<BlockEdit
{ ...props }
attributes={ { ...props.attributes, previewable } }
/>
);
},
'withDisabledEmbedPreview'
);

addFilter(
'editor.BlockEdit',
'media-experiments/disable-embed-previews',
disableEmbedPreviews
);
}
53 changes: 11 additions & 42 deletions packages/block-editor/src/hooks/test/cross-origin-isolation.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,60 +152,29 @@ describe( 'cross-origin-isolation', () => {
expect( observeSpy ).not.toHaveBeenCalled();
} );

it( 'should handle iframe contentDocument errors gracefully', () => {
it( 'should add crossorigin="anonymous" to images', async () => {
Object.defineProperty( window, 'crossOriginIsolated', {
value: true,
writable: true,
configurable: true,
} );

// Re-import the module
// Re-import the module to trigger the side effects
jest.isolateModules( () => {
require( '../cross-origin-isolation' );
} );

// Create an iframe that throws when accessing contentDocument
const iframe = document.createElement( 'iframe' );
Object.defineProperty( iframe, 'contentDocument', {
get() {
throw new Error( 'Cross-origin access denied' );
},
} );

// This should not throw an error
expect( () => {
document.body.appendChild( iframe );
iframe.dispatchEvent( new Event( 'load' ) );
} ).not.toThrow();
} );

it( 'should register embed preview filter when cross-origin isolated', () => {
Object.defineProperty( window, 'crossOriginIsolated', {
value: true,
writable: true,
configurable: true,
} );

const hasFilter = jest.spyOn(
require( '@wordpress/hooks' ),
'hasFilter'
);
// Create an image and add it to the DOM
const img = document.createElement( 'img' );
img.setAttribute( 'src', 'https://example.com/image.jpg' );
document.body.appendChild( img );

// Re-import the module to register filters
jest.isolateModules( () => {
require( '../cross-origin-isolation' );
} );
// Wait for MutationObserver callback to fire (async microtask).
await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );

// The module should register a filter when cross-origin isolated
// We can't easily test the filter itself without a full React environment,
// but we can verify the module loads without errors
expect( () => {
require( '@wordpress/hooks' ).hasFilter(
'editor.BlockEdit',
'media-experiments/disable-embed-previews'
);
} ).not.toThrow();
// The image should get the crossorigin attribute
expect( img ).toHaveAttribute( 'crossorigin', 'anonymous' );

hasFilter.mockRestore();
document.body.removeChild( img );
} );
} );
Loading
Loading