Opened 2 months ago
Last modified 11 days ago
#65930 assigned defect (bug)
Cross-origin isolation: crossorigin attribute is mis-scoped for <source> elements
| Reported by: | b0b3k | Owned by: | adamsilverstein |
|---|---|---|---|
| Priority: | high | Milestone: | 7.2 |
| Component: | Media | Version: | 7.1 |
| Severity: | normal | Keywords: | has-patch has-unit-tests has-test-info |
| Cc: | Focuses: | administration |
Description (last modified by )
Editing a post in the block editor dies with a PHP fatal error and a blank white
screen. The failure is inside core's own cross-origin isolation output buffer.
PHP Fatal error: WP_HTML_Tag_Processor::apply_attributes_updates(): Cannot use output buffering in output buffering display handlers in /wp-includes/html-api/class-wp-html-tag-processor.php on line 2644
The reported function name alternates between substr() (line 2639) and
WP_HTML_Tag_Processor::apply_attributes_updates() (line 2644) across requests —
both are inside the same method.
Environment
- WordPress 7.1
- PHP 8.x
- Browser: Chromium 137+ (Chrome/Edge). Does not reproduce in Firefox or Safari.
- User with
upload_filescapability memory_limit = 1024M, peak usage at time of failure ~180 MB — not a memory issue
Steps to reproduce
- Run WordPress 7.1 with client-side media processing enabled (the default).
- Open
/wp-admin/post.php?post=<id>&action=editin Chromium 137 or newer. - Ensure the rendered admin page contains at least one cross-origin resource
(
<script src>,<link href>,<audio src>,<video src|poster>,<source src>) whose URL starts with neithersite_url()nor/.
A minimal way to guarantee condition 3 in a test install:
<?php add_action( 'admin_enqueue_scripts', function () { wp_enqueue_script( 'repro-external', 'https://example.com/repro.js', array(), null, false ); } );
Expected: the editor loads.
Actual: blank page, fatal error above, no output at all.
Why it looks intermittent
Two independent gates make this bug appear random, which cost us a long
bisection through 27 plugins before we found it:
- Browser gate.
wp_start_cross_origin_isolation_output_buffer()returns early for anything below Chromium 137, so the same site is fine in Firefox. - Content gate.
wp_add_crossorigin_attributes()only callsset_attribute()for cross-origin URLs. With no such resource on the page,$lexical_updatesstays empty andapply_attributes_updates()returns at its first line — never reaching the failing statements. So one post edits fine while another, containing an embed or an external script, fatals.
Code path
Hooks —
default-filters.php:699-702:
<?php add_action( 'load-post.php', 'wp_set_up_cross_origin_isolation' ); add_action( 'load-post-new.php', 'wp_set_up_cross_origin_isolation' ); add_action( 'load-site-editor.php', 'wp_set_up_cross_origin_isolation' ); add_action( 'load-widgets.php', 'wp_set_up_cross_origin_isolation' );
media.php:6646
wp_set_up_cross_origin_isolation() gates on the feature flag, screen and
upload_files, then calls —
media.php:6701
wp_start_cross_origin_isolation_output_buffer(), which opens an output
buffering display handler:
<?php ob_start( static function ( string $output ): string { header( 'Document-Isolation-Policy: isolate-and-credentialless' ); return wp_add_crossorigin_attributes( $output ); } );
media.php:6725
wp_add_crossorigin_attributes() runs WP_HTML_Tag_Processor over the whole
buffered document from inside that handler, and for AUDIO/VIDEO/SOURCE
additionally uses bookmarks and seek().
class-wp-html-tag-processor.php:2605
apply_attributes_updates() is where PHP raises the error, at
line 2639
and
line 2644:
<?php $output_buffer .= substr( $this->html, $bytes_already_copied, $diff->start - $bytes_already_copied ); $output_buffer .= $diff->text; $bytes_already_copied = $diff->start + $diff->length; // ... $this->html = $output_buffer . substr( $this->html, $bytes_already_copied );
It is reached from
get_updated_html():4912
and also from
seek():2717,
which flushes pending updates mid-parse at
line 2746.
Note the local variable in apply_attributes_updates() is named $output_buffer
but is a plain string — the class contains zero ob_* calls (verified by grep
over the whole file). The PHP error is the output-buffer lock error surfacing
while core's own display handler is running; determining the precise trigger is
left to core, but the conflict is entirely within core code.
What was ruled out
Diagnosed on a live install with an instrumented mu-plugin that logs
ob_get_status( true ) at every hook and dumps a backtrace plus the callback list
of every in-flight hook when a non-default handler appears.
- Not a plugin. A disk scan of 39,071 PHP files across all plugins,
mu-plugins and both themes found no
ob_start()with a closure in any active plugin. Every third-party handler found uses an array or string callback, so it would appear asClass::methodinob_get_status(), notClosure::__invoke. - Not memory. 180 MB used against a 1024 MB limit at the moment of failure.
- Not a drop-in.
wp-content/contains noadvanced-cache.php,object-cache.phpordb.php. - Confirmed as core.
ob_get_status( true )reportsClosure::__invoke [level 0, flags 113], appearing between theuser_has_capandacf/validate_settinghook dispatches, inside theload-post.phpdispatch — exactly wherewp_set_up_cross_origin_isolationis registered, and it is the first callback on that hook, ahead of every plugin.
Workaround
The documented feature gate disables it:
<?php add_filter( 'wp_client_side_media_processing_enabled', '__return_false' );
This also disables client-side (WebAssembly) media processing, since that is what
the isolation exists for. Using remove_action() on wp_set_up_cross_origin_isolation
is worse: it leaves the feature enabled while removing the isolation it depends on.
Suggested direction
Any of these would remove the class of failure:
- Do the tag rewriting outside the display handler — collect the output with a
plain
ob_start()and post-process after the buffer is closed. - Send
Document-Isolation-Policyvia the normal headers path and addcrossoriginattributes throughscript_loader_tag/style_loader_tagfilters instead of rewriting the finished document. - If the buffer must stay, guard the handler so a failure inside it returns the
original
$outputunchanged rather than taking the whole screen down.
Attachments (1)
Change History (37)
This ticket was mentioned in PR #13221 on WordPress/wordpress-develop by @khokansardar.
8 weeks ago
#1
- Keywords has-patch has-unit-tests added; needs-patch removed
#2
@
8 weeks ago
The white screen is real; the traced cause isn't. The html-api does no output buffering (zero ob_* calls in src/wp-includes/html-api/), and that error only ever comes from ob_start(), named at its own call site — never from substr(). The reported steps load the editor fine on trunk (Chromium 151, upload_files user, external script enqueued).
The real fault is one frame up. wp_add_crossorigin_attributes() tracks the AUDIO/VIDEO parent of a SOURCE with a bookmark it never scopes to that element, and releases it after the first cross-origin SOURCE:
<video><source src="//cdn/a.mp4"><source src="//cdn/b.webm"></video>
The second SOURCE seeks a released bookmark, raising _doing_it_wrong() inside the output buffer's display handler. With WP_DEBUG on, trigger_error() reaches any error handler that buffers, and its ob_start() is the uncatchable fatal reported here — a plugin metabox with that markup fatals on trunk and passes with the patch. A SOURCE outside a media element also marks an unrelated one: <audio src="/local.mp3"> picks up crossorigin="anonymous".
Patch in PR 13221 — visit closing tags so the bookmark can't outlive its element, and only seek a bookmark the loop set. Three cases added to the existing data providers, all failing without it; full suite green.
Core's own pages never reach this branch (every <source> on post.php sits inside a <script type="text/html"> template), so it needs plugin or theme markup. normal severity and a summary naming the real cause would fit better.
#3
@
8 weeks ago
- Severity major → normal
- Summary Block editor fatals in Chromium 137+: cross-origin isolation output buffer conflicts with WP_HTML_Tag_Processor → Cross-origin isolation: crossorigin attribute is mis-scoped for <source> elements
Retitling and lowering severity to match comment:2.
#4
@
8 weeks ago
Thanks for the bug report and quick turnaround PR. @b0b3k could you add some steps to reproduce?
@adamsilverstein commented on PR #13221:
8 weeks ago
#5
@itzmekhokan - were you able to reproduce the bug? Are the two examples in your test enough to cause the whitescreen if present in content?
#6
follow-up:
↓ 8
@
8 weeks ago
@b0b3k A few more follow up questions:
Can you also share you active plugins and theme, this might help reproduce.
Can you reproduce the bug with all plugins disabled?
Can you confirm that the bug fix posted on the PR resolves the issue you see?
#7
@
8 weeks ago
I asked Claude to work out what on the page could be feeding this, here is the short version:
The markup that triggers this cannot come from post content. The block editor ships the content JSON-escaped inside a <script>, and WP_HTML_Tag_Processor skips script contents, so it has to be printed into the edit screen by a plugin or theme. A metabox is the likely source.
Three things have to line up, and checking them in this order should identify it quickly.
1. Find the markup. Load the same edit screen in Firefox, where wp_start_cross_origin_isolation_output_buffer() returns early and the page renders normally. View source and search for <source. Anything outside a <script type="text/html"> block is the input for this bug, and the surrounding markup should name the plugin.
Note that core's own wp_video_shortcode() and wp_audio_shortcode() emit one <source> per available format, so a single player with an mp4 and a webm fallback is enough. A plugin does not have to do anything unusual to produce this shape.
2. Check the URLs. The cross-origin test is a prefix match, ! str_starts_with( $url, site_url() ) && ! str_starts_with( $url, '/' ), so it catches more than CDN-hosted media:
https://www.example.com/...whensite_url()ishttps://example.comsite_url()stored ashttp://while pages renderhttps://, in which case every asset on the page counts as cross-origin, scripts included//cdn.example.com/a.mp4is treated as same-origin, because it starts with/
3. Find the buffering error handler. The white screen needs an error handler that itself calls ob_start(). Without one, the same bug quietly assigns crossorigin to the wrong element and logs nothing. Searching for ob_start() with a closure will not find it: search for set_error_handler instead, and note it may not be PHP source at all, since Xdebug, New Relic, Sentry and Bugsnag all install one. The Closure::__invoke [level 0, flags 113] in the ob_get_status() output is core's own isolation buffer, which is what gets taken down rather than what causes it.
---
figuring out what is being added and how will help make sure we find the correct solution for the issue.
#8
in reply to: ↑ 6
@
8 weeks ago
Replying to adamsilverstein:
@b0b3k A few more follow up questions:
Can you also share you active plugins and theme, this might help reproduce.
Can you reproduce the bug with all plugins disabled?
Can you confirm that the bug fix posted on the PR resolves the issue you see?
I ended up with mu-plugin as quick workaround on Client page by adding
<?php add_filter( 'wp_client_side_media_processing_enabled', '__return_false' );
I started to disable most heaviest plugins etc. but it bring me to nowhere, there was still a white page. I could check PR fix soonest on beginning of next week.
@khokansardar commented on PR #13221:
8 weeks ago
#9
Yes, reproduced — guard pushed as 7456c6a.
Are the two examples enough? No. The _doing_it_wrong() only becomes a white screen if an error handler is installed that itself buffers or throws. Same markup, trunk, WP_DEBUG on:
| error handler | result |
|---|---|
| none | notice discarded, page renders fine |
calls ob_start() | Fatal error: ob_start(): Cannot use output buffering in output buffering display handlers
|
throws ErrorException | Fatal error: Uncaught ErrorException: …seek was called incorrectly. Unknown bookmark name.
|
Which is probably why @b0b3k couldn't bisect it — the plugin installing the handler isn't the one printing the <source>.
Your seek finding: holds. 501 videos, trunk marks 0 sources, my PR marked 1. Fixed, with one tweak — $media_bookmark = null needs to stay *outside* the if. seek_count never resets, so once the budget is gone every remaining <source> just retries: 16 notices instead of 6. Also dropped the bookmark when a media element is marked from its own src, where it was seeking back to re-set an attribute already there.
Your test is in as written, green single + multisite. Diffed trunk against the patch over 110 markup shapes: 99 identical, 11 differ and all 11 are this fix. One behavior note — <video><audio src="/local.mp3"></audio><source src="cdn"></video> marks the audio on trunk and marks nothing now; a trailing <source> is ignored by the browser anyway, so unmarked beats wrong element.
Agree the >500 case is a separate follow-up. It's removable if it's worth it — a lookahead pass instead of seeking back, no bookmarks or seek() in the buffer at all.
@adamsilverstein commented on PR #13221:
8 weeks ago
#10
Are the two examples enough? No. The _doing_it_wrong() only becomes a white screen if an error handler is installed that itself buffers or throws.
That lowers the severity of the issue significantly although it still seems worth fixing.
#11
follow-up:
↓ 14
@
8 weeks ago
I started to disable most heaviest plugins etc. but it bring me to nowhere, there was still a white page. I could check PR fix soonest on beginning of next week.
Thanks for the updates @b0b3k - look forward to your further testing. Can you share a list of active plugins? You can get a list from Tools->Site Health->Info.
I ended up with mu-plugin as quick workaround on Client page by adding
Glad that fixed it for you for now.
@
8 weeks ago
WordPress Playground result for PR #13221: the video parent receives crossorigin=anonymous and child source elements remain unchanged.
#12
@
8 weeks ago
- Keywords has-test-info added; needs-testing removed
Test Report
Tested PR: #13221 (https://github.com/WordPress/wordpress-develop/pull/13221)
Environment: WordPress Playground PR build (https://playground.wordpress.net/?core-pr=13221), single-site installation, Chromium browser. PR head: 7456c6a7fe08dba19a22450b664c021f279c885c.
Steps:
- Loaded the PR build in WordPress Playground.
- Ran wp_add_crossorigin_attributes() against a video containing two cross-origin source elements.
- Checked the returned markup for the crossorigin attribute on the media parent and source children.
- Checked a source outside a media element and a media element with an existing crossorigin attribute.
Expected:
- Add crossorigin="anonymous" to the AUDIO or VIDEO parent.
- Do not add crossorigin to SOURCE elements.
- Preserve existing crossorigin attributes and ignore SOURCE elements outside media containers.
Results:
- PASS: the VIDEO parent received crossorigin="anonymous" exactly once.
- PASS: both SOURCE elements remained unchanged.
- PASS: SOURCE outside a media element remained unchanged.
- PASS: an existing media crossorigin attribute was not overwritten.
- The PR regression coverage also exercises the failed-seek/MAX_SEEK_OPS boundary and asserts that no SOURCE element is marked.
Review:
The patch scopes the media bookmark to the current AUDIO or VIDEO element, visits closing tags so the bookmark cannot outlive its element, and checks seek() before setting an attribute. The available GitHub checks for the tested PR head completed successfully.
Screenshot: ticket-65930-playground-result.png
Verdict: Functionally verified in WordPress Playground. The observed behavior matches the intended fix. This functional result does not replace any remaining Core maintainer review.
#13
@
8 weeks ago
- Milestone Awaiting Review → 7.1.1
Putting in the 7.1.1 milestone for consideration.
#14
in reply to: ↑ 11
@
7 weeks ago
Replying to adamsilverstein:
I started to disable most heaviest plugins etc. but it bring me to nowhere, there was still a white page. I could check PR fix soonest on beginning of next week.
Thanks for the updates @b0b3k - look forward to your further testing. Can you share a list of active plugins? You can get a list from Tools->Site Health->Info.
I ended up with mu-plugin as quick workaround on Client page by adding
Glad that fixed it for you for now.
As promissed - I checked fix from Pull request, previously disabling my mu-plugin with filter mentioned above. Result is still white screen.
Here is a list of active plugins on Client side:
Advanced Custom Fields PRO Version 6.8.8 by WP Engine | Auto-updates disabled
eTermin to FluentCRM Bridge Version 2.8 by Malte Eilenstein | Auto-updates disabled
Amelia Version 9.8 by Melograno Ventures (Latest version: 9.8.1) | Auto-updates disabled
Amelia → FluentCRM Mapping Version 1.4 by Malte Eilenstein | Auto-updates disabled
Borlabs Cookie Version 3.4.2.2 by Borlabs GmbH | Auto-updates enabled
Converter for Media Version 6.6.5 by matt plugins | Auto-updates disabled
Download Monitor Version 5.2.7 by WPChill | Auto-updates disabled
Duplicator Pro Version 4.6.9 by Duplicator | Auto-updates disabled
Eye-Able WordPress Plugin Version 2.18 by Tobias Greiner - Web Inclusion GmbH | Auto-updates disabled
FluentCRM - Marketing Automation For WordPress Version 3.1.10 by WP Email Newsletter Team - FluentCRM (Latest version: 3.1.13) | Auto-updates disabled
FluentCRM Pro Version 3.1.11 by Fluent CRM (Latest version: 3.1.13) | Auto-updates disabled
Fluent Forms Version 6.2.12 by Contact Form - WPManageNinja LLC (Latest version: 6.2.13) | Auto-updates disabled
Fluent Forms Pro Add On Pack Version 6.2.12 by Fluent Forms (Latest version: 6.2.13) | Auto-updates disabled
FluentSMTP Version 2.3.1 by FluentSMTP & WPManageNinja Team | Auto-updates disabled
Folders Version 3.1.9 by Premio | Auto-updates disabled
Kadence Security Pro Version 9.0.5 by Kadence | Auto-updates disabled
Password Protected Version 2.8.4 by Password Protected | Auto-updates disabled
Redirect Redirection Version 1.3.0 by Inisev | Auto-updates disabled
Site Kit by Google Version 1.185.0 by Google (Latest version: 1.186.0) | Auto-updates disabled
Solid Backups – Legacy Version 9.1.20 by SolidWP | Auto-updates disabled
User Switching Version 1.12.1 by John Blackbourn | Auto-updates disabled
WP Migrate Version 2.7.11 by WP Engine | Auto-updates disabled
Yoast Duplicate Post Version 4.7 by Enrico Battocchi & Team Yoast | Auto-updates disabled
Yoast SEO Version 28.3 by Team Yoast | Auto-updates disabled
Yoast SEO Premium Version 28.3 by Team Yoast | Auto-updates disabled
This ticket was mentioned in Slack in #core by adrianduffell. View the logs.
6 weeks ago
@adamsilverstein commented on PR #13221:
6 weeks ago
#17
cc: @dmsnell for feedback on the HTML Processor code, double checking this isn't an API issue.
@adamsilverstein commented on PR #13221:
6 weeks ago
#18
Reviewing this again, I hjave a suspicion we don't need this crossorigin="anonymous" code _at all_. This looks like a remnane from our previous use of COOP/COEP headers, before we switched to DIP. So I think we can remove it from Gutenberg and Core (and add it to the client-side-media-everywhere plugin that enables the feature for Safari where this would still be needed).
I'm going to open some separate PRs proposing that and if they test well we can eliminate this entire class of errors.
cc: @swissspidy
This ticket was mentioned in PR #13446 on WordPress/wordpress-develop by @adamsilverstein.
6 weeks ago
#19
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: GB_PR. Safari keeps its injection through the Client-Side Media Everywhere plugin: PLUGIN_PR.
_Claude ran the browser tests and wrote up the change, here is the summary:_
## What
Send the
Document-Isolation-Policyheader directly and stop rewriting the editor page to addcrossorigin="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()andwp_add_crossorigin_attributes()move todeprecated.php. The first only sends the header now, the second returns the HTML unchanged.wp_print_media_templates()no longer forcescrossorigin="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-credentiallessloads cross-origin subresources without credentials instead of blocking them. That is the whole difference fromrequire-corp. So a cross-origin script, stylesheet, image, audio or video element works with nocrossoriginattribute at all. Addingcrossorigin="anonymous"turns the load into a CORS request, which fails for any host that does not sendAccess-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:4801document,127.0.0.1:4802resources) in Chromium 151 and Chrome 152, withcrossOriginIsolated === trueon the DIP pages:
| Resource, host without CORS headers | No DIP | DIP |
| --- | --- | --- |
| img, script, stylesheet, audio, video,<source>, no attribute | loads | loads |
| same, withcrossorigin="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-corpera 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_Processorover the finished document inside anob_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
- 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 carriesDocument-Isolation-Policy: isolate-and-credentialless,window.crossOriginIsolatedistrue, and the script tag has nocrossoriginattribute.- Upload an image and confirm client-side processing still runs (the
sideloadrequests in the Network panel).- 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.
npm run test:php -- --filter Tests_Media_wpCrossOriginIsolationpasses: 20 tests.
## AI Use
Code and description both written with 🤖 Claude Code. I will review and test.
#20
follow-up:
↓ 24
@
6 weeks ago
Thanks for the patch and the careful analysis @khokansardar. Before deciding how to fix the bookmark scoping I wanted to know whether the injection is needed at all under DIP, and it turns out it is not. I have put up https://github.com/WordPress/wordpress-develop/pull/13446 which removes it, and I think that is the better fix here. @b0b3k could you try that build? Playground: https://playground.wordpress.net/?core-pr=13446
I asked Claude to test this in the browser and dig through the history, here is what it found:
isolate-and-credentiallessloads cross-origin subresources without credentials instead of blocking them. A cross-origin script, stylesheet, image, audio or video element works with nocrossoriginattribute. Addingcrossorigin="anonymous"turns the load into a CORS request, which fails for any host withoutAccess-Control-Allow-Origin, such as media offloaded to a CDN. That is exactly the bug behind #65673, and the AUDIO and VIDEO tags still carry it, including in the media modal templates.
Verified in Chromium 151 and Chrome 152 with a two-origin harness and
crossOriginIsolated === true: every resource without the attribute loaded identically with and without the DIP header, and every resource withcrossorigin="anonymous"from a host without CORS failed identically with and without it. The attribute only buys canvas pixel readback, which nothing in core or Gutenberg does with remote media.
The injection dates from the
require-corpera of the Gutenberg experiment, where it was the only way for anything cross-origin to load, and it came along unchanged with the switch to DIP in [61844]. The question of why AUDIO and VIDEO still needed it was asked on the IMG carve-out (https://github.com/WordPress/wordpress-develop/pull/11291) and never answered.
On this ticket specifically: the reporter still gets a white screen with the PR 13221 fix applied (14), so the mis-scoped bookmark is not the root cause. Running the tag processor over the finished document inside an
ob_start()display handler is the class of failure, and removing the buffer removes all of it. Two smaller problems in the current code go away with it as well: the tag processor cannot see an implicitly closed<video>, so the bookmark fix still mis-parents a later<source>, and the seek budget floods_doing_it_wrong()notices past 500 media elements.
Companion Gutenberg PR: https://github.com/WordPress/gutenberg/pull/82614. Safari users on the Client-Side Media Everywhere plugin keep the injection there, since require-corp genuinely needs it: https://github.com/adamsilverstein/client-side-media-everywhere/pull/63.
What do you think @khokansardar?
#21
@
6 weeks ago
Agreed @adamsilverstein — removing it is the right call, and there's a precedent that makes the
case stronger than the comment argues.
[62048] (#64886) already removed IMG and LINK imagesrcset from this list for exactly this reason: credentialless loads them fine, and forcing CORS breaks any host without Access-Control-Allow-Origin. AUDIO, VIDEO, SOURCE, SCRIPT src and LINK href are what that pass left behind. Six months of the IMG carve-out in the wild, no canvas regression reported.
The SCRIPT/LINK half may be a bigger user-facing win than the white screen: any plugin enqueuing a script or stylesheet from a CDN without ACAO breaks in the editor today on Chromium 137+.
It also removes a failure class rather than a bug. Running WP_HTML_Tag_Processor over the finished document inside an ob_start() display handler means no _doing_it_wrong() in that path can be caught. The bookmark was one instance; the implicitly closed <video> and the seek budget were two more. 13446 drops the second injection buffer in wp_print_media_templates() as well.
This ticket was mentioned in Slack in #core by adamsilverstein. View the logs.
6 weeks ago
@adamsilverstein commented on PR #13446:
6 weeks ago
#23
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.
#24
in reply to: ↑ 20
@
5 weeks ago
I have checked it on the page where it was occurring with fixes from PR - all fine, before I removed of course my mu-plugin which was filtering it before.
Replying to adamsilverstein:
Thanks for the patch and the careful analysis @khokansardar. Before deciding how to fix the bookmark scoping I wanted to know whether the injection is needed at all under DIP, and it turns out it is not. I have put up https://github.com/WordPress/wordpress-develop/pull/13446 which removes it, and I think that is the better fix here. @b0b3k could you try that build? Playground: https://playground.wordpress.net/?core-pr=13446
I asked Claude to test this in the browser and dig through the history, here is what it found:
isolate-and-credentiallessloads cross-origin subresources without credentials instead of blocking them. A cross-origin script, stylesheet, image, audio or video element works with nocrossoriginattribute. Addingcrossorigin="anonymous"turns the load into a CORS request, which fails for any host withoutAccess-Control-Allow-Origin, such as media offloaded to a CDN. That is exactly the bug behind #65673, and the AUDIO and VIDEO tags still carry it, including in the media modal templates.
Verified in Chromium 151 and Chrome 152 with a two-origin harness and
crossOriginIsolated === true: every resource without the attribute loaded identically with and without the DIP header, and every resource withcrossorigin="anonymous"from a host without CORS failed identically with and without it. The attribute only buys canvas pixel readback, which nothing in core or Gutenberg does with remote media.
The injection dates from the
require-corpera of the Gutenberg experiment, where it was the only way for anything cross-origin to load, and it came along unchanged with the switch to DIP in [61844]. The question of why AUDIO and VIDEO still needed it was asked on the IMG carve-out (https://github.com/WordPress/wordpress-develop/pull/11291) and never answered.
On this ticket specifically: the reporter still gets a white screen with the PR 13221 fix applied (14), so the mis-scoped bookmark is not the root cause. Running the tag processor over the finished document inside an
ob_start()display handler is the class of failure, and removing the buffer removes all of it. Two smaller problems in the current code go away with it as well: the tag processor cannot see an implicitly closed<video>, so the bookmark fix still mis-parents a later<source>, and the seek budget floods_doing_it_wrong()notices past 500 media elements.
Companion Gutenberg PR: https://github.com/WordPress/gutenberg/pull/82614. Safari users on the Client-Side Media Everywhere plugin keep the injection there, since
require-corpgenuinely needs it: https://github.com/adamsilverstein/client-side-media-everywhere/pull/63.
What do you think @khokansardar?
This ticket was mentioned in Slack in #core by adrianduffell. View the logs.
5 weeks ago
#26
@
5 weeks ago
@b0b3k and others here: can you give https://github.com/WordPress/gutenberg/pull/82614 a test to see if that resolves your issue. I realized we can remove the problematic code entirely, we don't need it since switching to DIP as far as I can tell.
@adamsilverstein commented on PR #13221:
5 weeks ago
#27
@itzmekhokan I think we can remove this processing entirely, can you check if this PR resolves the issue? If so, please close out this PR in favor of that one:
#28
follow-up:
↓ 29
@
5 weeks ago
With 7.1.1 RC tomorrow and given we are still exploring the best fix for this issue which is also somewhat hard to run into in the first place, I'm likely going to punt this to 7.1.2 so we can continue to refine it, but still plan to backport to 7.1.
#29
in reply to: ↑ 28
@
5 weeks ago
- Description modified (diff)
Replying to adamsilverstein:
With 7.1.1 RC tomorrow and given we are still exploring the best fix for this issue which is also somewhat hard to run into in the first place, I'm likely going to punt this to 7.1.2 so we can continue to refine it, but still plan to backport to 7.1.
Also fine with this PR
#30
@
5 weeks ago
- Milestone 7.1.1 → 7.1.2
Thanks for confirming @b0b3k.
7.1.1 RC is in a couple of hours - punting this to 7.1.2 for continued work.
@adamsilverstein commented on PR #13221:
5 weeks ago
#31
Closing in favor of
@adamsilverstein commented on PR #13221:
5 weeks ago
#32
@itzmekhokan I went ahead and closed this to avoid confusion - I don't think we need to patch this here at all, we can remove it. I'm working on re-adding it for where its needed by Safari/Firefox in:
I'm going to apply the latest feedback from Dennis there, please have a look if you can.
This ticket was mentioned in Slack in #core by adrianduffell. View the logs.
5 weeks ago
@algorithmsunlocks commented on PR #13446:
11 days ago
#35
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():
- Mis-scoping onto an unrelated element. A cross-origin
<source>inside<picture>, preceded anywhere in the document by a<video>, makesseek( '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>…
_doing_it_wrong()from inside the output buffer. A cross-origin<source>with no<audio>/<video>anywhere emitsWP_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:5725that is also present on trunk. - No internal callers of the two deprecated functions remain;
@expectedDeprecatedtests cover both shims. - Gating end to end: Chromium 137 + block editor +
upload_files+ localhost returnstrueandob_get_level()stays0.
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.
#36
@
11 days ago
- Milestone 7.1.3 → 7.2
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?
Moving this to 7.2 for now because the since documentation uses that. If some part should be committed sooner, the ticket can go into another 7.1.x milestone.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)
A
<source>element could handcrossorigin="anonymous"to the wrong media element, or raise_doing_it_wrong()from inside the cross-origin isolation output buffer.wp_add_crossorigin_attributes()tracks the AUDIO or VIDEO parent of a SOURCE with anaudio-video-parentbookmark that is never scoped to the element it was set on, and releases it after the first cross-origin SOURCE. A second SOURCE then seeks a bookmark that no longer exists, and a SOURCE outside any media element walks back to an unrelated one. This visits closing tags so the bookmark cannot outlive its element, keeps it across sibling sources, and only seeks a bookmark the loop set.The accepted set only narrows, and the loop now performs fewer
seek()calls than before.Trac ticket: https://core.trac.wordpress.org/ticket/65930
## Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: diagnosing the bookmark scoping fault, implementing the fix, and extending the existing data providers. All changes were reviewed and validated by me.