From 3beee24b4e5c08db8193cb8422d3f6983e989c4f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 9 Jun 2026 21:46:14 +0200 Subject: [PATCH 1/4] 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 * From 43f6865712522920f7a6a1bee873a4fc82a88528 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 16 Jun 2026 20:46:58 +0200 Subject: [PATCH 2/4] HTML API: Track self-closing flag during tokenization --- .../html-api/class-wp-html-tag-processor.php | 63 ++++++++----------- .../tests/html-api/wpHtmlTagProcessor.php | 39 ++++++++++++ 2 files changed, 65 insertions(+), 37 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 c58e5768c3b07..e63b42f1ca142 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 @@ -627,6 +627,15 @@ class WP_HTML_Tag_Processor { */ private $token_length; + /** + * Whether the current tag token has the self-closing flag. + * + * @since 6.9.0 + * + * @var bool + */ + private $self_closing_flag = false; + /** * Byte offset in input document where current tag name starts. * @@ -1079,6 +1088,7 @@ private function base_class_next_token(): bool { $tag_name_starts_at = $this->tag_name_starts_at; $tag_name_length = $this->tag_name_length; $tag_ends_at = $this->token_starts_at + $this->token_length; + $self_closing_flag = $this->self_closing_flag; $attributes = $this->attributes; $duplicate_attributes = $this->duplicate_attributes; @@ -1136,6 +1146,7 @@ private function base_class_next_token(): bool { $this->text_length = $this->tag_name_starts_at - $this->text_starts_at; $this->tag_name_starts_at = $tag_name_starts_at; $this->tag_name_length = $tag_name_length; + $this->self_closing_flag = $self_closing_flag; $this->attributes = $attributes; $this->duplicate_attributes = $duplicate_attributes; @@ -2143,7 +2154,19 @@ private function parse_next_attribute(): bool { $doc_length = strlen( $this->html ); // Skip whitespace and slashes. - $this->bytes_already_parsed += strspn( $this->html, " \t\f\r\n/", $this->bytes_already_parsed ); + $skipped_start = $this->bytes_already_parsed; + $this->bytes_already_parsed += strspn( $this->html, " \t\f\r\n/", $skipped_start ); + + // A slash inside an unquoted attribute value will not have been skipped here. + if ( + $this->bytes_already_parsed < $doc_length && + $this->bytes_already_parsed > $skipped_start && + '/' === $this->html[ $this->bytes_already_parsed - 1 ] && + '>' === $this->html[ $this->bytes_already_parsed ] + ) { + $this->self_closing_flag = true; + } + if ( $this->bytes_already_parsed >= $doc_length ) { $this->parser_state = self::STATE_INCOMPLETE_INPUT; @@ -2327,6 +2350,7 @@ private function after_tag(): void { $this->token_starts_at = null; $this->token_length = null; + $this->self_closing_flag = false; $this->tag_name_starts_at = null; $this->tag_name_length = null; $this->text_starts_at = 0; @@ -3335,42 +3359,7 @@ public function has_self_closing_flag(): bool { return false; } - /* - * The self-closing flag is the solidus at the _end_ of the tag, not the beginning. - * - * Example: - * - *
- * ^ this appears one character before the end of the closing ">". - */ - $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; + return $this->self_closing_flag; } /** diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index a6e1844a332c2..1fc7f77fe836a 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -119,6 +119,45 @@ public static function data_has_self_closing_flag() { ); } + /** + * Ensures internally consumed special-element closers do not affect the opener's + * self-closing flag. + * + * @ticket 61576 + * + * @covers WP_HTML_Tag_Processor::has_self_closing_flag + * + * @dataProvider data_special_atomic_self_closing_flags + * + * @param string $html Input HTML whose first tag might contain the self-closing flag `/`. + * @param bool $flag_is_set Whether the input HTML's first tag contains the self-closing flag. + */ + public function test_special_atomic_elements_report_opening_tag_self_closing_flag( string $html, bool $flag_is_set ) { + $processor = new WP_HTML_Tag_Processor( $html ); + + $this->assertTrue( $processor->next_token(), 'Expected to find complete special atomic tag.' ); + + if ( $flag_is_set ) { + $this->assertTrue( $processor->has_self_closing_flag(), 'Did not find the self-closing flag on the opening tag.' ); + } else { + $this->assertFalse( $processor->has_self_closing_flag(), 'Reported the internally consumed closing tag self-closing flag on the opening tag.' ); + } + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_special_atomic_self_closing_flags() { + return array( + 'SCRIPT closer self-closing flag' => array( '', false ), + 'STYLE closer self-closing flag' => array( '', false ), + 'TITLE closer self-closing flag' => array( 'x', false ), + 'TITLE opener self-closing flag' => array( 'x', true ), + ); + } + /** * Ensures a trailing slash in an unquoted attribute value is part of the value. * From 463c8832b6a1a54539f52a015d2e523f73fe76d7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 16 Jun 2026 20:52:04 +0200 Subject: [PATCH 3/4] HTML API: Correct self-closing flag since tag --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e63b42f1ca142..c7a545f8b2003 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 @@ -630,7 +630,7 @@ class WP_HTML_Tag_Processor { /** * Whether the current tag token has the self-closing flag. * - * @since 6.9.0 + * @since 7.1.0 * * @var bool */ From d1d4d55f0b8a61f2eb3d28aa80d6b7b8d1ef44de Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 16 Jun 2026 21:03:59 +0200 Subject: [PATCH 4/4] HTML API: Simplify self-closing flag tests --- .../tests/html-api/wpHtmlProcessor.php | 21 ++---- .../tests/html-api/wpHtmlTagProcessor.php | 64 +------------------ 2 files changed, 7 insertions(+), 78 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index ad979eb2e8278..30de6d406468e 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -589,29 +589,20 @@ public function test_expects_closer_foreign_content_self_closing() { * @ticket 61576 */ public function test_trailing_slash_in_unquoted_attribute_value_does_not_self_close_foreign_content() { - $processor = WP_HTML_Processor::create_fragment( 'text' ); + $processor = WP_HTML_Processor::create_fragment( 'This mtext tag is not self-closing, it has [a="b/"] attribute.' ); - $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->assertTrue( $processor->next_tag( 'MTEXT' ), 'Could not find MTEXT tag: check test setup.' ); + $this->assertSame( 'b/', $processor->get_attribute( 'a' ), '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->assertTrue( $processor->next_token(), 'Could not find text following MTEXT tag: check test setup.' ); $this->assertSame( - array( 'HTML', 'BODY', 'MATH', 'MI', '#text' ), + array( 'HTML', 'BODY', 'MATH', 'MTEXT', '#text' ), $processor->get_breadcrumbs(), - 'Text following the MI tag should remain inside the MI element.' + 'Text following the MTEXT tag should remain inside the MTEXT element.' ); } diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 1fc7f77fe836a..949cfa1b2a62d 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -116,69 +116,7 @@ public static function data_has_self_closing_flag() { '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 internally consumed special-element closers do not affect the opener's - * self-closing flag. - * - * @ticket 61576 - * - * @covers WP_HTML_Tag_Processor::has_self_closing_flag - * - * @dataProvider data_special_atomic_self_closing_flags - * - * @param string $html Input HTML whose first tag might contain the self-closing flag `/`. - * @param bool $flag_is_set Whether the input HTML's first tag contains the self-closing flag. - */ - public function test_special_atomic_elements_report_opening_tag_self_closing_flag( string $html, bool $flag_is_set ) { - $processor = new WP_HTML_Tag_Processor( $html ); - - $this->assertTrue( $processor->next_token(), 'Expected to find complete special atomic tag.' ); - - if ( $flag_is_set ) { - $this->assertTrue( $processor->has_self_closing_flag(), 'Did not find the self-closing flag on the opening tag.' ); - } else { - $this->assertFalse( $processor->has_self_closing_flag(), 'Reported the internally consumed closing tag self-closing flag on the opening tag.' ); - } - } - - /** - * Data provider. - * - * @return array[] - */ - public static function data_special_atomic_self_closing_flags() { - return array( - 'SCRIPT closer self-closing flag' => array( '', false ), - 'STYLE closer self-closing flag' => array( '', false ), - 'TITLE closer self-closing flag' => array( 'x', false ), - 'TITLE opener self-closing flag' => array( 'x', true ), - ); - } - - /** - * 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.' + 'Self-closing flag on internally consumed special element closer' => array( 'x', false ), ); }