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 e41e1120550b5..dac804954ca55 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 7.1.0 + * + * @var bool + */ + private $has_self_closing_flag = false; + /** * Byte offset in input document where current tag name starts. * @@ -1074,11 +1083,12 @@ private function base_class_next_token(): bool { * the closing tag to point to the opening of the special atomic * tag sequence. */ - $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; - $attributes = $this->attributes; - $duplicate_attributes = $this->duplicate_attributes; + $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; + $has_self_closing_flag = $this->has_self_closing_flag; + $attributes = $this->attributes; + $duplicate_attributes = $this->duplicate_attributes; // Find the closing tag if necessary. switch ( $tag_name ) { @@ -1128,14 +1138,15 @@ private function base_class_next_token(): bool { * functions that skip the contents have moved all the internal cursors past * the inner content of the tag. */ - $this->token_starts_at = $was_at; - $this->token_length = $this->bytes_already_parsed - $this->token_starts_at; - $this->text_starts_at = $tag_ends_at; - $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->attributes = $attributes; - $this->duplicate_attributes = $duplicate_attributes; + $this->token_starts_at = $was_at; + $this->token_length = $this->bytes_already_parsed - $this->token_starts_at; + $this->text_starts_at = $tag_ends_at; + $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->has_self_closing_flag = $has_self_closing_flag; + $this->attributes = $attributes; + $this->duplicate_attributes = $duplicate_attributes; return true; } @@ -2134,19 +2145,41 @@ private function parse_next_tag(): bool { * @since 6.2.0 * @ignore * - * @return bool Whether an attribute was found before the end of the document. + * @return bool True to indicate attribute parsing should continue. False if a stop condition + * was reached. */ 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_length = strspn( $this->html, " \t\f\r\n/", $this->bytes_already_parsed ); + $this->bytes_already_parsed += $skipped_length; if ( $this->bytes_already_parsed >= $doc_length ) { $this->parser_state = self::STATE_INCOMPLETE_INPUT; return false; } + /** + * This block serves two purposes: + * + * - A fast path for common tag-ending `>`. + * - A check for the self-closing flag which must appear as `/>`. + * + * In a tag like ``, `/` is the attribute value, not a self-closing + * flag. When it appears in this form, the parser has already consumed the + * attribute value, `$skipped_length` is 0, and this checks below correctly + * identify whether there is a self-closing flag. + * + * Note: Both start and end tags may have the self-closing flag. + */ + if ( '>' === $this->html[ $this->bytes_already_parsed ] ) { + if ( $skipped_length > 0 && '/' === $this->html[ $this->bytes_already_parsed - 1 ] ) { + $this->has_self_closing_flag = true; + } + return false; + } + /* * Treat the equal sign as a part of the attribute * name if it is the first encountered byte. @@ -2324,6 +2357,7 @@ private function after_tag(): void { $this->token_starts_at = null; $this->token_length = null; + $this->has_self_closing_flag = false; $this->tag_name_starts_at = null; $this->tag_name_length = null; $this->text_starts_at = 0; @@ -3332,15 +3366,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 ">". - */ - return '/' === $this->html[ $this->token_starts_at + $this->token_length - 2 ]; + return $this->has_self_closing_flag; } /** diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index bb18629563493..cfa88345953ba 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -583,6 +583,29 @@ public function test_expects_closer_foreign_content_self_closing() { $this->assertTrue( $processor->expects_closer() ); } + /** + * Ensures a slash-only unquoted attribute value does not close foreign content. + * + * @ticket 65372 + */ + public function test_unquoted_slash_attribute_does_not_self_close_foreign_content(): void { + $processor = WP_HTML_Processor::create_fragment( 'math:mi is not self-closing, it has [a="/"] attribute.' ); + + $this->assertTrue( $processor->next_tag( 'MI' ), 'Failed to find the MI tag: check test setup.' ); + $this->assertSame( '/', $processor->get_attribute( 'a' ), 'Failed to treat the slash as the unquoted attribute value.' ); + $this->assertFalse( + $processor->has_self_closing_flag(), + 'Failed to avoid interpreting the slash-only unquoted attribute value as a self-closing flag.' + ); + + $this->assertTrue( $processor->next_token(), 'Failed to find text following the MI tag: check test setup.' ); + $this->assertSame( + array( 'HTML', 'BODY', 'MATH', 'MI', '#text' ), + $processor->get_breadcrumbs(), + 'Failed to keep text following the MI tag 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 185d93b7a652c..dfb9b16442045 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -111,10 +111,13 @@ 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 ), + 'Slash only unquoted attribute value' => array( '
', false ), + 'Attribute "=" with 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 ), + 'Ignored "/" and whitespace' => array( '
', false ), ); }