Skip to content

Media: Stop injecting crossorigin attributes under Document-Isolation-Policy - #13446

Closed
adamsilverstein wants to merge 9 commits into
WordPress:trunkfrom
adamsilverstein:remove/crossorigin-injection
Closed

adamsilverstein wants to merge 9 commits into
WordPress:trunkfrom
adamsilverstein:remove/crossorigin-injection

Conversation

@adamsilverstein

@adamsilverstein adamsilverstein commented Sep 8, 2026 •

Copy link
Copy Markdown
Member

Trac ticket: https://core.trac.wordpress.org/ticket/65930

I spent some time testing what isolate-and-credentialless actually blocks before deciding how to fix the bookmark bug in #13221, and it turns out the crossorigin injection is not doing anything useful under DIP. So this removes it rather than patching it. Companion Gutenberg PR: WordPress/gutenberg#82614. Safari keeps its injection through the Client-Side Media Everywhere plugin: adamsilverstein/client-side-media-everywhere#63.

Claude ran the browser tests and wrote up the change, here is the summary:

What

Send the Document-Isolation-Policy header directly and stop rewriting the editor page to add crossorigin="anonymous".

  • wp_send_document_isolation_policy_header() sends the header and returns whether it did. wp_set_up_cross_origin_isolation() calls it and now returns that bool, which is also what the tests assert on.
  • wp_start_cross_origin_isolation_output_buffer() and wp_add_crossorigin_attributes() move to deprecated.php. The first only sends the header now, the second returns the HTML unchanged.
  • wp_print_media_templates() no longer forces crossorigin="anonymous" onto the AUDIO and VIDEO tags in the Backbone templates.
  • The output buffer tests become header and no-op tests, and the media template test now checks that no media tag carries the attribute.

Why

isolate-and-credentialless loads cross-origin subresources without credentials instead of blocking them. That is the whole difference from require-corp. So a cross-origin script, stylesheet, image, audio or video element works with no crossorigin attribute at all. Adding crossorigin="anonymous" turns the load into a CORS request, which fails for any host that does not send Access-Control-Allow-Origin, such as media offloaded to a CDN. That is the bug behind #65673, and the AUDIO and VIDEO tags still had it.

Verified with a two-origin harness (localhost:4801 document, 127.0.0.1:4802 resources) in Chromium 151 and Chrome 152, with crossOriginIsolated === true on the DIP pages:

Resource, host without CORS headers No DIP DIP
img, script, stylesheet, audio, video, <source>, no attribute loads loads
same, with crossorigin="anonymous" fails, media error code 4 fails, media error code 4
img with the attribute, host sends CORS loads, canvas readback works loads, canvas readback works

DIP changes nothing in any row. The attribute only buys canvas pixel readback, and only when the host serves CORS. Nothing in core or Gutenberg reads pixels out of remote media; the client-side upload path works on local blobs.

The injection dates from the require-corp era of the client-side media experiment, where it was the only way for anything cross-origin to load. It came along unchanged when the editor switched to DIP in [61844], IMG was carved out in [62048] and [62819] after CDN images broke, and the question of why AUDIO and VIDEO still needed it (asked on #11291) never got an answer.

Removing it also retires the whole class of failure in #65930: running WP_HTML_Tag_Processor over the finished document inside an ob_start() display handler. The reporter there still gets a white screen with the bookmark fix from #13221 applied, so that fix was not reaching the real cause anyway.

Testing

  1. On Chrome 137 or newer, enqueue a cross-origin script on the editor screen, for example wp_enqueue_script( 'x', 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js' ), and open a post. The response still carries Document-Isolation-Policy: isolate-and-credentialless, window.crossOriginIsolated is true, and the script tag has no crossorigin attribute.
  2. Upload an image and confirm client-side processing still runs (the sideload requests in the Network panel).
  3. Point a media library audio or video at a host without CORS headers (the gist in #65673 works) and open it in the media modal. It plays.
  4. npm run test:php -- --filter Tests_Media_wpCrossOriginIsolation passes: 20 tests.

Testing Instructions

Test in WordPress Playground

Commit message

Claude assembled this message from the PR, the Trac discussion and the props bot:

Media: stop adding crossorigin attributes in the editor.

Fix an issue where editing a post in Chromium 137+ could end in a fatal error and a blank screen when the page loaded any cross-origin resource. `wp_add_crossorigin_attributes()` ran `WP_HTML_Tag_Processor` over the whole document inside an `ob_start()` display handler, where errors cannot be caught. The injected `crossorigin="anonymous"` was not needed in the first place: `Document-Isolation-Policy: isolate-and-credentialless` loads cross-origin scripts, stylesheets, audio and video without credentials instead of blocking them, and forcing a CORS request broke any host that does not send `Access-Control-Allow-Origin`, such as media offloaded to a CDN.

The header is now sent directly by the new `wp_send_document_isolation_policy_header()`, and the editor page is no longer buffered or rewritten. `wp_start_cross_origin_isolation_output_buffer()` and `wp_add_crossorigin_attributes()` are deprecated; the first only sends the header and the second returns the HTML unchanged. `wp_print_media_templates()` no longer adds the attribute to AUDIO and VIDEO tags. Cross-origin isolation and client-side media processing are unaffected.

Follow-up to [61844], [62048], [62819].

Props b0b3k, khokansardar, arkaprabhachowdhury, westonruter, andrewserong, algorithmsunlocks, sabernhardt, swissspidy, adamsilverstein.
Fixes #65930. See #65673.

AI Use

Code and description both written with 🤖 Claude Code. I will review and test.

…-Policy.

`isolate-and-credentialless` loads cross-origin subresources without
credentials instead of blocking them, so scripts, styles, images, audio,
and video from other origins work without a `crossorigin` attribute.
Forcing `crossorigin="anonymous"` turns each load into a CORS request,
which fails for any host that does not send `Access-Control-Allow-Origin`,
such as media offloaded to a CDN.

The injection dates from the `require-corp` era of the Gutenberg
experiment, where it was needed for anything cross-origin to load. It was
carried over unchanged when the editor switched to DIP, and has since
caused two regressions (IMG previews, #65673) and a white screen from the
tag processor running inside an output buffer display handler (#65930).

Send the header directly, deprecate the output buffer helper and the
attribute injector, and drop the matching injection from the media
templates.

Props khokansardar, b0b3k, ianmjones, westonruter, andrewserong.
Fixes #65930. See #65673, #64766.
@github-actions

github-actions Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props adamsilverstein, westonruter, andrewserong, algorithmsunlocks.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@adamsilverstein

Copy link
Copy Markdown
Member Author

Please note the RC for 7.1.1 is September 10th if we want it included in that version. Otherwise it is fine to punt to 7.1.2.

adamsilverstein and others added 2 commits September 11, 2026 15:02
Bring the branch up to date with the current PHPUnit matrix workflows so a
re-run uses the trimmed routine matrix rather than the week-old shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XymJGmRuNKwf8srrtsrcLR
Comment thread src/wp-includes/media.php Outdated
* Sends the Document-Isolation-Policy header for cross-origin isolation.
*
* Uses an output buffer to add crossorigin="anonymous" where needed.
* `isolate-and-credentialless` loads cross-origin subresources without

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.

Starting the sentence with this identifier makes it harder to start reading, IMO.

Maybe start with "The isolate-and-credentialless directive loads…", if that's what this thing is called.

Comment thread src/wp-includes/media.php Outdated
* @since 7.1.0
* @since 7.2.0
*
* @return bool Whether the header was sent.

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.

Technically, if headers_sent() returns true then the header() would not get sent either. Should this function return false if headers_sent() returns true?

Comment thread tests/phpunit/tests/media/wpCrossOriginIsolation.php Outdated
Comment thread tests/phpunit/tests/media/wpCrossOriginIsolation.php Outdated
Comment thread tests/phpunit/tests/media/wpCrossOriginIsolation.php Outdated
adamsilverstein and others added 2 commits September 25, 2026 17:03
Co-authored-by: Weston Ruter <westonruter@gmail.com>
Resolve deprecated.php, where trunk appended _truncate_post_slug() at the
same spot the branch appended the deprecated cross-origin helpers. Keep both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqepyjmFSryBt88j6f6ubY
header() is a no-op after output starts, so reporting true in that case
misleads callers relying on the new return value. Also reword the
docblock so it no longer opens with a bare directive identifier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014d6nhnaeaDimhTNiwB2wwq

@andrewserong andrewserong left a comment

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.

This is testing well for me with client-side media processing continuing to work as expected, and PDF embed previews, etc are working in the block editor.

Image

Code-wise this looks like a good companion to WordPress/gutenberg#82614 to me, and the addition of the bool return types helps get the functions here under test (and the test coverage looks thorough).

LGTM! 🚀

@RumanCodes

Copy link
Copy Markdown

Test report

Environment — WordPress 7.2-alpha (trunk 2bf0714531), PHP 8.3, nginx, single + multisite,
Chrome 154, local wordpress-develop env on localhost.

Reproduced on trunk

Two distinct failures in wp_add_crossorigin_attributes():

  1. Mis-scoping onto an unrelated element. A cross-origin <source> inside <picture>,
    preceded anywhere in the document by a <video>, makes seek( 'audio-video-parent' ) jump
    back to that unrelated <video> and set the attribute on it — even though it is same-origin:

    in : <video src="/local.mp4"></video><p>text</p><picture><source src="https://cdn.example.com/a.webp"><img src="/local.jpg"></picture>
    out: <video crossorigin="anonymous" src="/local.mp4"></video><p>text</p><picture>…
    
  2. _doing_it_wrong() from inside the output buffer. A cross-origin <source> with no
    <audio>/<video> anywhere emits WP_HTML_Tag_Processor::seek: Unknown bookmark name. —
    the notice that becomes a white screen under an error handler that itself buffers or throws,
    as established in comment:9 on the ticket.

With this PR

Both are gone, and all four of my crafted cases pass through unmodified. On a real
authenticated block-editor request:

trunk this PR
Document-Isolation-Policy isolate-and-credentialless isolate-and-credentialless
crossorigin= in response 6 0
status 200 200

The 6 on trunk all land on Underscore.js placeholders inside <script type="text/html">,
added unconditionally rather than per resolved URL — which matches the argument in the PR
description for removing it outright.

Automated

  • --group media: 855 pass on the PR head; 861 pass merged with current trunk (clean
    merge — the branch is 132 commits behind).
  • --group 64766,65930: 22 pass, 29 assertions.
  • Multisite --group 64766,65930,media: 861 pass.
  • PHPCS clean on all four changed files, apart from a pre-existing warning at media.php:5725
    that is also present on trunk.
  • No internal callers of the two deprecated functions remain; @expectedDeprecated tests cover
    both shims.
  • Gating end to end: Chromium 137 + block editor + upload_files + localhost returns true
    and ob_get_level() stays 0.

Not tested

The original white screen end to end (it needs an error handler that buffers or throws; I did
not install one, so I can only say the preceding notice is gone), the Gutenberg companion
PR 82614, Firefox/Safari in a browser, and Chromium 137 exactly — I tested on Chrome 154.

One question

The ticket is milestoned 7.1.3, a minor release, but this PR also adds a new public function
and deprecates two existing ones. Should the 7.1.3 backport carry the whole change, or only the
injection removal, with the rename and deprecations left for 7.2?


AI assistance: Yes (Claude Code), used for the repro cases, running the suites and drafting this
report; reviewed and tested by me.

@github-actions

Copy link
Copy Markdown

A commit was made that fixes the Trac ticket referenced in the description of this pull request.

SVN changeset: 64489
GitHub commit: 7ed4f03

This PR will be closed, but please confirm the accuracy of this and reopen if there is more work to be done.

@github-actions github-actions Bot closed this Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants