From bd4e9637c203ac704a77092d1e347e2ad589aab6 Mon Sep 17 00:00:00 2001 From: Khokan Sardar Date: Fri, 21 Aug 2026 16:43:38 +0530 Subject: [PATCH 1/2] Media: Scope the crossorigin bookmark to its media element. `wp_add_crossorigin_attributes()` tracked the AUDIO or VIDEO element a SOURCE belongs to with a bookmark that was never scoped to the element it was set on, and released it after the first cross-origin SOURCE. A second SOURCE then sought a bookmark that no longer existed, raising `_doing_it_wrong()` from inside the output buffer's display handler, while a SOURCE outside any media element walked back to an unrelated one and marked that instead. Visit closing tags so the bookmark cannot outlive its element, keep it across sibling SOURCE elements, and only seek a bookmark this loop set. Fixes #65930. --- src/wp-includes/media.php | 44 ++++++++++++------- .../tests/media/wpCrossOriginIsolation.php | 11 +++++ 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 075ca04700489..e751a075471b5 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -6736,24 +6736,37 @@ function wp_add_crossorigin_attributes( string $html ): string { 'SOURCE' => array( 'src' ), ); - while ( $processor->next_tag() ) { + /* + * Bookmark of the AUDIO or VIDEO element the cursor is inside of. Closing tags + * are visited so it cannot outlive its element and be mistaken for the parent + * of a later SOURCE. + */ + $media_bookmark = null; + + while ( $processor->next_tag( array( 'tag_closers' => 'visit' ) ) ) { $tag = $processor->get_tag(); if ( ! isset( $cross_origin_tag_attributes[ $tag ] ) ) { continue; } - $crossorigin = $processor->get_attribute( 'crossorigin' ); - if ( null !== $crossorigin ) { - continue; - } if ( 'AUDIO' === $tag || 'VIDEO' === $tag ) { - $processor->set_bookmark( 'audio-video-parent' ); + $media_bookmark = null; + + if ( ! $processor->is_tag_closer() && null === $processor->get_attribute( 'crossorigin' ) ) { + $media_bookmark = 'audio-video-parent'; + $processor->set_bookmark( $media_bookmark ); + } } - $processor->set_bookmark( 'resume' ); + if ( $processor->is_tag_closer() ) { + continue; + } - $sought = false; + $crossorigin = $processor->get_attribute( 'crossorigin' ); + if ( null !== $crossorigin ) { + continue; + } $is_cross_origin = false; @@ -6770,19 +6783,18 @@ function wp_add_crossorigin_attributes( string $html ): string { if ( $is_cross_origin ) { if ( 'SOURCE' === $tag ) { - $sought = $processor->seek( 'audio-video-parent' ); - - if ( $sought ) { + if ( null !== $media_bookmark ) { + $processor->set_bookmark( 'resume' ); + $processor->seek( $media_bookmark ); $processor->set_attribute( 'crossorigin', 'anonymous' ); + $processor->seek( 'resume' ); + + // The element is marked, so further SOURCE children need nothing. + $media_bookmark = null; } } else { $processor->set_attribute( 'crossorigin', 'anonymous' ); } - - if ( $sought ) { - $processor->seek( 'resume' ); - $processor->release_bookmark( 'audio-video-parent' ); - } } } diff --git a/tests/phpunit/tests/media/wpCrossOriginIsolation.php b/tests/phpunit/tests/media/wpCrossOriginIsolation.php index b1c40e8596780..e89d0407f6cb2 100644 --- a/tests/phpunit/tests/media/wpCrossOriginIsolation.php +++ b/tests/phpunit/tests/media/wpCrossOriginIsolation.php @@ -365,6 +365,7 @@ public function test_set_flag_preserves_upload_media_module_dependencies() { * Verifies that cross-origin elements get crossorigin="anonymous" added. * * @ticket 64766 + * @ticket 65930 * * @runInSeparateProcess * @preserveGlobalState disabled @@ -409,6 +410,9 @@ public function data_elements_that_should_get_crossorigin() { 'cross-origin source inside video' => array( '', ), + 'multiple cross-origin sources' => array( + '', + ), ); } @@ -420,6 +424,7 @@ public function data_elements_that_should_get_crossorigin() { * in credentialless mode without needing explicit CORS headers. * * @ticket 64766 + * @ticket 65930 * * @runInSeparateProcess * @preserveGlobalState disabled @@ -461,6 +466,12 @@ public function data_elements_that_should_not_get_crossorigin() { 'relative URL script' => array( '', ), + 'source outside a media element' => array( + '', + ), + 'source in a video with crossorigin' => array( + '', + ), ); } From 7456c6a7fe08dba19a22450b664c021f279c885c Mon Sep 17 00:00:00 2001 From: Khokan Sardar Date: Sat, 22 Aug 2026 19:35:46 +0530 Subject: [PATCH 2/2] Media: Guard the seek back to a SOURCE element's media parent. `WP_HTML_Tag_Processor::seek()` returns false without moving the cursor once `MAX_SEEK_OPS` is exceeded, so marking the parent without checking the return value puts `crossorigin` on the SOURCE, where it does nothing for CORS, and leaves the media element unmarked. The bookmark is dropped whether or not the seek lands, since the budget never comes back and every remaining SOURCE of that element would only raise the same notice again. A media element marked from its own attribute drops it as well, rather than having its SOURCE children seek back to re-set an attribute that is already there. See #65930. --- src/wp-includes/media.php | 30 ++++++++++---- .../tests/media/wpCrossOriginIsolation.php | 41 +++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index e751a075471b5..cb867a4523c35 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -6737,9 +6737,10 @@ function wp_add_crossorigin_attributes( string $html ): string { ); /* - * Bookmark of the AUDIO or VIDEO element the cursor is inside of. Closing tags - * are visited so it cannot outlive its element and be mistaken for the parent - * of a later SOURCE. + * Name of the bookmark on the AUDIO or VIDEO element the cursor is inside of, + * for as long as that element still needs the attribute. Closing tags are + * visited so the bookmark cannot outlive its element and be mistaken for the + * parent of a later SOURCE. */ $media_bookmark = null; @@ -6785,15 +6786,30 @@ function wp_add_crossorigin_attributes( string $html ): string { if ( 'SOURCE' === $tag ) { if ( null !== $media_bookmark ) { $processor->set_bookmark( 'resume' ); - $processor->seek( $media_bookmark ); - $processor->set_attribute( 'crossorigin', 'anonymous' ); - $processor->seek( 'resume' ); - // The element is marked, so further SOURCE children need nothing. + /* + * A seek past MAX_SEEK_OPS leaves the cursor where it is, so mark the + * parent only once the cursor has reached it. Marking a SOURCE does + * nothing for CORS and would hide the media element that was missed. + */ + if ( $processor->seek( $media_bookmark ) ) { + $processor->set_attribute( 'crossorigin', 'anonymous' ); + $processor->seek( 'resume' ); + } + + /* + * The element is either marked or past the seek budget, which does not + * come back, so its remaining SOURCE children need nothing. + */ $media_bookmark = null; } } else { $processor->set_attribute( 'crossorigin', 'anonymous' ); + + // A marked AUDIO or VIDEO needs nothing from its SOURCE children. + if ( 'AUDIO' === $tag || 'VIDEO' === $tag ) { + $media_bookmark = null; + } } } } diff --git a/tests/phpunit/tests/media/wpCrossOriginIsolation.php b/tests/phpunit/tests/media/wpCrossOriginIsolation.php index e89d0407f6cb2..8701ed381fa39 100644 --- a/tests/phpunit/tests/media/wpCrossOriginIsolation.php +++ b/tests/phpunit/tests/media/wpCrossOriginIsolation.php @@ -447,6 +447,47 @@ public function test_output_buffer_does_not_add_crossorigin( $html ) { $this->assertStringNotContainsString( 'crossorigin="anonymous"', $output ); } + /** + * A failed seek() must not leave the crossorigin attribute on the SOURCE element. + * + * WP_HTML_Tag_Processor::seek() returns false without moving the cursor once + * MAX_SEEK_OPS is exceeded. The cursor is then still on the SOURCE, so marking + * the element without checking the return value adds the attribute where it does + * nothing for CORS and leaves the parent media element unmarked. + * + * @ticket 65930 + * + * @covers ::wp_add_crossorigin_attributes + */ + public function test_source_is_not_marked_when_seeking_the_parent_fails() { + $this->setExpectedIncorrectUsage( 'WP_HTML_Tag_Processor::seek' ); + + /* + * Marking a media element costs two seeks, one back to the parent and one + * forward to resume, so one more than half the budget outlasts it. + */ + $media_elements = intdiv( WP_HTML_Tag_Processor::MAX_SEEK_OPS, 2 ) + 1; + + $output = wp_add_crossorigin_attributes( + str_repeat( + '', + $media_elements + ) + ); + + $this->assertSame( + 0, + preg_match_all( '/]*\bcrossorigin\b/i', $output ), + 'A SOURCE element must never receive the crossorigin attribute.' + ); + + $this->assertSame( + $media_elements - 1, + substr_count( $output, 'crossorigin="anonymous"' ), + 'Every media element reachable within the seek budget should have been marked.' + ); + } + /** * Data provider for elements that should not receive crossorigin="anonymous". *