From 3beee24b4e5c08db8193cb8422d3f6983e989c4f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 9 Jun 2026 21:46:14 +0200 Subject: [PATCH] HTML API: Ignore slash inside unquoted attribute values --- .../html-api/class-wp-html-tag-processor.php | 29 ++++++++++++++++- .../tests/html-api/wpHtmlProcessor.php | 32 +++++++++++++++++++ .../tests/html-api/wpHtmlTagProcessor.php | 27 +++++++++++++++- 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index 77c1a471db5b1..f094f1a2adc09 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -3337,7 +3337,34 @@ public function has_self_closing_flag(): bool { *
* ^ this appears one character before the end of the closing ">". */ - return '/' === $this->html[ $this->token_starts_at + $this->token_length - 2 ]; + $self_closing_flag_at = $this->token_starts_at + $this->token_length - 2; + if ( '/' !== $this->html[ $self_closing_flag_at ] ) { + return false; + } + + foreach ( $this->attributes as $attribute ) { + $attribute_ends_at = $attribute->start + $attribute->length; + if ( + $self_closing_flag_at >= $attribute->start && + $self_closing_flag_at < $attribute_ends_at + ) { + return false; + } + } + + foreach ( $this->duplicate_attributes ?? array() as $duplicate_attributes ) { + foreach ( $duplicate_attributes as $attribute ) { + $attribute_ends_at = $attribute->start + $attribute->length; + if ( + $self_closing_flag_at >= $attribute->start && + $self_closing_flag_at < $attribute_ends_at + ) { + return false; + } + } + } + + return true; } /** diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index a89014282df73..ad979eb2e8278 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -583,6 +583,38 @@ public function test_expects_closer_foreign_content_self_closing() { $this->assertTrue( $processor->expects_closer() ); } + /** + * Ensures a trailing slash in an unquoted attribute value does not close foreign content. + * + * @ticket 61576 + */ + public function test_trailing_slash_in_unquoted_attribute_value_does_not_self_close_foreign_content() { + $processor = WP_HTML_Processor::create_fragment( 'text' ); + + $this->assertTrue( $processor->next_tag( 'MI' ), 'Could not find MI tag: check test setup.' ); + $this->assertSame( + 'abc/', + $processor->get_attribute( 'disabled' ), + 'Trailing slash in unquoted attribute value should belong to the attribute value.' + ); + $this->assertFalse( + $processor->has_self_closing_flag(), + 'Trailing slash in unquoted attribute value should not be interpreted as a self-closing flag.' + ); + $this->assertTrue( + $processor->expects_closer(), + 'MI with a trailing slash in an unquoted attribute value should still expect a closer.' + ); + + $this->assertTrue( $processor->next_token(), 'Could not find text following MI tag: check test setup.' ); + $this->assertSame( '#text', $processor->get_token_name(), 'Should have found the text node following the MI tag.' ); + $this->assertSame( + array( 'HTML', 'BODY', 'MATH', 'MI', '#text' ), + $processor->get_breadcrumbs(), + 'Text following the MI tag should remain inside the MI element.' + ); + } + /** * Ensures that expects_closer works for void-like elements in foreign content. * diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 22ace3890f469..a6e1844a332c2 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -111,13 +111,38 @@ public static function data_has_self_closing_flag() { 'No self-closing flag on a foreign element' => array( '', false ), // These involve syntax peculiarities. 'Self-closing flag after extra spaces' => array( '
', true ), - 'Self-closing flag after attribute' => array( '
', true ), + 'Self-closing flag after attribute' => array( '
', true ), + 'Slash inside unquoted attribute value' => array( '
', false ), 'Self-closing flag after quoted attribute' => array( '
', true ), 'Self-closing flag after boolean attribute' => array( '
', true ), 'Boolean attribute that looks like a self-closer' => array( '
', false ), ); } + /** + * Ensures a trailing slash in an unquoted attribute value is part of the value. + * + * @ticket 61576 + * + * @covers WP_HTML_Tag_Processor::get_attribute + * @covers WP_HTML_Tag_Processor::has_self_closing_flag + */ + public function test_trailing_slash_in_unquoted_attribute_value_is_not_self_closing_flag() { + $processor = new WP_HTML_Tag_Processor( 'text' ); + $this->assertTrue( $processor->next_tag(), 'Could not find MI tag: check test setup.' ); + + $this->assertSame( + 'abc/', + $processor->get_attribute( 'disabled' ), + 'Trailing slash in unquoted attribute value should belong to the attribute value.' + ); + + $this->assertFalse( + $processor->has_self_closing_flag(), + 'Trailing slash in unquoted attribute value should not be interpreted as a self-closing flag.' + ); + } + /** * @ticket 56299 *