From 80775021f948571931e99c20cf3add535bd7c93c Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 29 Dec 2025 20:38:45 +0100 Subject: [PATCH 01/14] Update style contents escaping to match script escaping See https://github.com/WordPress/wordpress-develop/pull/10635 --- .../html-api/class-wp-html-tag-processor.php | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 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 69e3e5d2c7557..095f1488080ff 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 @@ -3851,13 +3851,9 @@ public function set_modifiable_text( string $plaintext_content ): bool { return true; case 'STYLE': - $plaintext_content = preg_replace_callback( - '~style)~i', - static function ( $tag_match ) { - return "\\3c\\2f{$tag_match['TAG_NAME']}"; - }, - $plaintext_content - ); + if ( false !== stripos( $plaintext_content, 'escape_style_contents( $plaintext_content ); + } $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( $this->text_starts_at, @@ -4240,6 +4236,25 @@ private static function escape_javascript_script_contents( string $sourcecode ): return $escaped; } + /** + * Escape style tag contents. + * + * Prevent CSS text from modifying the HTML structure of a document and + * ensure that it's contained within its enclosing STYLE tag as intended. + */ + private function escape_style_contents( string $text ): string { + return preg_replace_callback( + '~s)(?Ptyle[ \\t\\f\\r\\n/>])~i', + static function ( $matches ) { + $escaped_s_char = 's' === $matches['S_CHAR'] + ? '\\73' + : '\\53'; + return " Date: Mon, 29 Dec 2025 20:38:54 +0100 Subject: [PATCH 02/14] Udpate and add additional tests --- .../wpHtmlTagProcessorModifiableText.php | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php index 9e0d94aecd17e..817f01dac7890 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php @@ -426,18 +426,21 @@ public function test_updates_basic_modifiable_text_on_supported_nodes( string $h */ public static function data_tokens_with_basic_modifiable_text_updates() { return array( - 'Text node (start)' => array( 'Text', 1, 'Blubber', 'Blubber' ), - 'Text node (middle)' => array( 'Bold move', 2, 'yo', 'yo' ), - 'Text node (end)' => array( 'of a dog', 2, 'of a cat', 'of a cat' ), - 'Encoded text node' => array( '
birds and dogs
', 2, ' & ', '
<birds> & <dogs>
' ), - 'SCRIPT tag' => array( 'beforeafter', 2, 'const img = " &
";', 'beforeafter' ), - 'STYLE tag' => array( '', 1, 'p::before { content: " & "; }', '' ), - 'TEXTAREA tag' => array( 'ab', 2, "so it ", "ab" ), - 'TEXTAREA (escape)' => array( 'ab', 2, 'but it does for ', 'ab' ), - 'TEXTAREA (escape+attrs)' => array( 'ab', 2, 'but it does for ', 'ab' ), - 'TITLE tag' => array( 'ahas no need to escapeb', 2, "so it ", "aso it <doesn't>b" ), - 'TITLE (escape)' => array( 'ahas no need to escapeb', 2, 'but it does for ', 'abut it does for </title>b' ), - 'TITLE (escape+attrs)' => array( 'ahas no need to escapeb', 2, 'but it does for ', 'abut it does for </title not an="attribute">b' ), + 'Text node (start)' => array( 'Text', 1, 'Blubber', 'Blubber' ), + 'Text node (middle)' => array( 'Bold move', 2, 'yo', 'yo' ), + 'Text node (end)' => array( 'of a dog', 2, 'of a cat', 'of a cat' ), + 'Encoded text node' => array( '
birds and dogs
', 2, ' & ', '
<birds> & <dogs>
' ), + 'SCRIPT tag' => array( 'beforeafter', 2, 'const img = " &
";', 'beforeafter' ), + 'STYLE tag' => array( '', 1, 'p::before { content: " & "; }', '' ), + 'STYLE tag (mixed casing)' => array( '', 1, 'p::before { content: " & "; }', '' ), + 'STYLE tag (trailing characters)' => array( '', 1, "p::before { content: \" & \"; }", "" ), + 'STYLE tag (non-closing tag)' => array( '', 1, 'p::before { content: " & "; }', '' ), + 'TEXTAREA tag' => array( 'ab', 2, "so it ", "ab" ), + 'TEXTAREA (escape)' => array( 'ab', 2, 'but it does for ', 'ab' ), + 'TEXTAREA (escape+attrs)' => array( 'ab', 2, 'but it does for ', 'ab' ), + 'TITLE tag' => array( 'ahas no need to escapeb', 2, "so it ", "aso it <doesn't>b" ), + 'TITLE (escape)' => array( 'ahas no need to escapeb', 2, 'but it does for ', 'abut it does for </title>b' ), + 'TITLE (escape+attrs)' => array( 'ahas no need to escapeb', 2, 'but it does for ', 'abut it does for </title not an="attribute">b' ), ); } From 9f2f45b47a519cb1b85fbaf545951c309996d690 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 14 Jan 2026 17:20:10 +0100 Subject: [PATCH 03/14] Add test with tricky repeats --- .../html-api/class-wp-html-tag-processor.php | 50 +++++++++++++++---- .../wpHtmlTagProcessorModifiableText.php | 1 + 2 files changed, 41 insertions(+), 10 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 095f1488080ff..36c5968adf80a 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 @@ -4243,16 +4243,46 @@ private static function escape_javascript_script_contents( string $sourcecode ): * ensure that it's contained within its enclosing STYLE tag as intended. */ private function escape_style_contents( string $text ): string { - return preg_replace_callback( - '~s)(?Ptyle[ \\t\\f\\r\\n/>])~i', - static function ( $matches ) { - $escaped_s_char = 's' === $matches['S_CHAR'] - ? '\\73' - : '\\53'; - return "", by using a CSS Unicode + * escape sequence for the "s" (or the "S"). + * + * CSS Unicode escape sequences will terminate at the first non-hexadecimal, + * so the `t` character in `style` ensures that a Unicode escape sequence + * like `\73t` is correctly interpreted as `st`. + */ + while ( $at < $end ) { + $tag_at = stripos( $text, '", $tag_at + 7, 1 ) ) { + $at = $tag_at + 7; + continue; + } + + $escaped .= substr( $text, $was_at, $tag_at - $was_at + 2 ); + $escaped .= 's' === $text[ $tag_at + 2 ] ? '\73' : '\53'; + $was_at = $tag_at + 3; + $at = $tag_at + 8; + } + + if ( '' === $escaped ) { + return $text; + } + + if ( $was_at < $end ) { + $escaped .= substr( $text, $was_at ); + } + + return $escaped; } /** diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php index 817f01dac7890..a58a905e693d4 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php @@ -435,6 +435,7 @@ public static function data_tokens_with_basic_modifiable_text_updates() { 'STYLE tag (mixed casing)' => array( '', 1, 'p::before { content: " & "; }', '' ), 'STYLE tag (trailing characters)' => array( '', 1, "p::before { content: \" & \"; }", "" ), 'STYLE tag (non-closing tag)' => array( '', 1, 'p::before { content: " & "; }', '' ), + 'STYLE tag (repeats)' => array( '', 1, '*{ content: "" }', '' ), 'TEXTAREA tag' => array( 'ab', 2, "so it ", "ab" ), 'TEXTAREA (escape)' => array( 'ab', 2, 'but it does for ', 'ab' ), 'TEXTAREA (escape+attrs)' => array( 'ab', 2, 'but it does for ', 'ab' ), From c34ece192ad2eb4803bfbbf7f168a4a0a9870f23 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 14 Jan 2026 17:24:57 +0100 Subject: [PATCH 04/14] Always set the escaped contents --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 7 +------ 1 file changed, 1 insertion(+), 6 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 36c5968adf80a..7d797a212c38a 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 @@ -3851,16 +3851,11 @@ public function set_modifiable_text( string $plaintext_content ): bool { return true; case 'STYLE': - if ( false !== stripos( $plaintext_content, 'escape_style_contents( $plaintext_content ); - } - $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( $this->text_starts_at, $this->text_length, - $plaintext_content + $this->escape_style_contents( $plaintext_content ) ); - return true; case 'TEXTAREA': From c75b17054f653f040d4eb0ec1a5de7e46c0e3587 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 14 Jan 2026 17:25:14 +0100 Subject: [PATCH 05/14] Properly document the method --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 5 +++++ 1 file changed, 5 insertions(+) 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 7d797a212c38a..9c758d73100f5 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 @@ -4236,6 +4236,11 @@ private static function escape_javascript_script_contents( string $sourcecode ): * * Prevent CSS text from modifying the HTML structure of a document and * ensure that it's contained within its enclosing STYLE tag as intended. + * + * @since 7.0.0 + * + * @param string $text Raw contents intended to be serialized into an HTML STYLE element. + * @return string Escaped form of input contents which will not lead to premature closing of the containing STYLE element. */ private function escape_style_contents( string $text ): string { $at = 0; From 5b8eb3f4fa98f3c66df2b3bdbf4c6c256c96d3d8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 14 Jan 2026 17:25:37 +0100 Subject: [PATCH 06/14] The method should be static --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 4 ++-- 1 file changed, 2 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 9c758d73100f5..9d1830ad37623 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 @@ -3854,7 +3854,7 @@ public function set_modifiable_text( string $plaintext_content ): bool { $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( $this->text_starts_at, $this->text_length, - $this->escape_style_contents( $plaintext_content ) + self::escape_style_contents( $plaintext_content ) ); return true; @@ -4242,7 +4242,7 @@ private static function escape_javascript_script_contents( string $sourcecode ): * @param string $text Raw contents intended to be serialized into an HTML STYLE element. * @return string Escaped form of input contents which will not lead to premature closing of the containing STYLE element. */ - private function escape_style_contents( string $text ): string { + private static function escape_style_contents( string $text ): string { $at = 0; $was_at = 0; $end = strlen( $text ); From 56d8529162aa24e5da980ee030603f59a9c06eeb Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 15 Oct 2026 15:20:14 +0400 Subject: [PATCH 07/14] Update custom CSS test for the new STYLE escape --- tests/phpunit/tests/customize/custom-css-setting.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/customize/custom-css-setting.php b/tests/phpunit/tests/customize/custom-css-setting.php index 06f77075d0ba8..e89adc95eba3c 100644 --- a/tests/phpunit/tests/customize/custom-css-setting.php +++ b/tests/phpunit/tests/customize/custom-css-setting.php @@ -392,7 +392,7 @@ public function test_wp_custom_css_cb_escapes_dangerous_html() { $expected = <<<'HTML' HTML; From 9e8cf167f972ace7f5db50dc8091ee05527e588a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 15 Oct 2026 15:20:23 +0400 Subject: [PATCH 08/14] Correct the method's since version --- 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 6064991000bd8..c2114e42ce3eb 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 @@ -4569,7 +4569,7 @@ private static function escape_javascript_script_contents( string $sourcecode ): * Prevent CSS text from modifying the HTML structure of a document and * ensure that it's contained within its enclosing STYLE tag as intended. * - * @since 7.0.0 + * @since 7.2.0 * * @param string $text Raw contents intended to be serialized into an HTML STYLE element. * @return string Escaped form of input contents which will not lead to premature closing of the containing STYLE element. From 0e9a4fd492eabee1642cdec792255b70fd2a45b0 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 15 Oct 2026 15:20:27 +0400 Subject: [PATCH 09/14] Mark the private method as ignored --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 1 + 1 file changed, 1 insertion(+) 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 c2114e42ce3eb..f6a7adf041e63 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 @@ -4570,6 +4570,7 @@ private static function escape_javascript_script_contents( string $sourcecode ): * ensure that it's contained within its enclosing STYLE tag as intended. * * @since 7.2.0 + * @ignore * * @param string $text Raw contents intended to be serialized into an HTML STYLE element. * @return string Escaped form of input contents which will not lead to premature closing of the containing STYLE element. From fe59b48ed4d77922105f339e879a46cef5ba6d8b Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 19 Oct 2026 09:09:23 +0400 Subject: [PATCH 10/14] Escape style contents with one regex pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loop built its result with `.=` on `substr()` spans, which copies the text about twice when a closer is escaped. One preg_replace_callback() pass writes it once. On a PCRE error the escaper returns null and set_modifiable_text() returns false without enqueuing an update. Escape step alone against trunk (e182141667), tracing JIT: within 1% with no `` near misses, which only trunk escapes. End to end with no closer: within 0.2% of trunk. Output is byte-identical to the loop's on 200,000 random inputs and 171 CSS, HTML and JavaScript files, with PCRE JIT on and off, on PHP 7.4, 8.4 and 8.5. New test rows cover every tag name terminator and the STYLE start tag. --- .../html-api/class-wp-html-tag-processor.php | 53 +++++++------------ .../wpHtmlTagProcessorModifiableText.php | 2 + 2 files changed, 21 insertions(+), 34 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 f6a7adf041e63..b3fc220c1e9df 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 @@ -4158,10 +4158,15 @@ public function set_modifiable_text( string $plaintext_content ): bool { return true; case 'STYLE': + $escaped = self::escape_style_contents( $plaintext_content ); + if ( null === $escaped ) { + return false; + } + $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( $this->text_starts_at, $this->text_length, - self::escape_style_contents( $plaintext_content ) + $escaped ); return true; @@ -4573,14 +4578,10 @@ private static function escape_javascript_script_contents( string $sourcecode ): * @ignore * * @param string $text Raw contents intended to be serialized into an HTML STYLE element. - * @return string Escaped form of input contents which will not lead to premature closing of the containing STYLE element. + * @return string|null Escaped form of input contents which will not lead to premature closing of the containing STYLE element, + * or null if the regular expression engine fails. */ - private static function escape_style_contents( string $text ): string { - $at = 0; - $was_at = 0; - $end = strlen( $text ); - $escaped = ''; - + private static function escape_style_contents( string $text ): ?string { /* * Replace all instances of the ASCII case-insensitive match of "", by using a CSS Unicode @@ -4589,33 +4590,17 @@ private static function escape_style_contents( string $text ): string { * CSS Unicode escape sequences will terminate at the first non-hexadecimal, * so the `t` character in `style` ensures that a Unicode escape sequence * like `\73t` is correctly interpreted as `st`. + * + * The match is only the "s": `\K` leaves "", $tag_at + 7, 1 ) ) { - $at = $tag_at + 7; - continue; - } - - $escaped .= substr( $text, $was_at, $tag_at - $was_at + 2 ); - $escaped .= 's' === $text[ $tag_at + 2 ] ? '\73' : '\53'; - $was_at = $tag_at + 3; - $at = $tag_at + 8; - } - - if ( '' === $escaped ) { - return $text; - } - - if ( $was_at < $end ) { - $escaped .= substr( $text, $was_at ); - } - - return $escaped; + return preg_replace_callback( + '~])~i', + static function ( array $matches ): string { + return 's' === $matches[0] ? '\73' : '\53'; + }, + $text + ); } /** diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php index c089fdb44aee1..b052e4cf48ed0 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php @@ -445,6 +445,8 @@ public static function data_tokens_with_basic_modifiable_text_updates() { 'STYLE tag (trailing characters)' => array( '', 1, "p::before { content: \" & \"; }", "" ), 'STYLE tag (non-closing tag)' => array( '', 1, 'p::before { content: " & "; }', '' ), 'STYLE tag (repeats)' => array( '', 1, '*{ content: "" }', '' ), + 'STYLE tag (terminators)' => array( '', 1, "*{ content: \"*{ content: \"" ), + 'STYLE tag (start tag)' => array( '', 1, '*{ content: "' ), 'TEXTAREA tag' => array( 'ab', 2, "so it ", "ab" ), 'TEXTAREA (escape)' => array( 'ab', 2, 'but it does for ', 'ab' ), 'TEXTAREA (escape+attrs)' => array( 'ab', 2, 'but it does for ', 'ab' ), From 62d091a91c500bd9c620033b21202470f286a4ce Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 21 Oct 2026 10:12:18 +0400 Subject: [PATCH 11/14] Find raw text tag names with one helper escape_javascript_script_contents() searched for SCRIPT tag names with its own loop. find_tag_names() returns the byte offset of each tag name which the tokenizer would read as a tag of a given name: after ``. The SCRIPT escape builds its result from those offsets, and the STYLE escape and the closing tag checks in set_modifiable_text() can use the same search. Output equals trunk's on 200,000 random inputs, 180 files (mostly JavaScript and CSS) as they are and with inserted tags, and 2 MB runs of partial tags. Collecting the offsets first costs a little. Escape step alone against trunk, geometric mean over 11 core scripts and JSON documents, opcache tracing JIT / JIT off: - PHP 8.5.11, macOS, PCRE2 10.49: +8% / +10% with no tag, +5% / +4% with one tag. - PHP 8.4.25, Linux, PCRE2 10.44: +8% / +10% with no tag, +7% / +5% with one tag. - PHP 7.4.33, Linux, PCRE2 10.35, no JIT: +11% with no tag, +7% with one tag. On core's small JSON with no `<` the step takes 26 ns against 19 ns to 20 ns on PHP 8.4 and 8.5, and 63 ns against 46 ns on PHP 7.4. End to end (new processor, next_token(), set_modifiable_text(), get_updated_html()) every group is within 1.3% of trunk. Each variant ran in its own process: 8 rounds on PHP 8.5, 3 on PHP 8.4 and 7.4. A new test row covers every tag name terminator. --- .../html-api/class-wp-html-tag-processor.php | 84 ++++++++++++------- .../wpHtmlTagProcessorModifiableText.php | 1 + 2 files changed, 53 insertions(+), 32 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 63b8f24477f0c..20478ad436ea7 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 @@ -4512,50 +4512,25 @@ private function get_script_content_type(): ?string { * @return string Escaped form of input contents which will not lead to premature closing of the containing SCRIPT element. */ private static function escape_javascript_script_contents( string $sourcecode ): string { - $at = 0; - $was_at = 0; - $end = strlen( $sourcecode ); $escaped = ''; + $was_at = 0; /* * Replace all instances of the ASCII case-insensitive match of "", by using a * character replacement for the "s" (or the "S"). */ - while ( $at < $end ) { - $tag_at = strpos( $sourcecode, '<', $at ); - if ( false === $tag_at ) { - break; - } - - $tag_name_at = $tag_at + 1; - $has_closing_slash = $tag_name_at < $end && '/' === $sourcecode[ $tag_name_at ]; - $tag_name_at += $has_closing_slash ? 1 : 0; - - if ( 0 !== substr_compare( $sourcecode, 'script', $tag_name_at, 6, true ) ) { - $at = $tag_at + 1; - continue; - } - - if ( 1 !== strspn( $sourcecode, " \t\f\r\n/>", $tag_name_at + 6, 1 ) ) { - $at = $tag_name_at + 6; - continue; - } - - $escaped .= substr( $sourcecode, $was_at, $tag_name_at - $was_at ); - $escaped .= 's' === $sourcecode[ $tag_name_at ] ? '\u0073' : '\u0053'; - $was_at = $tag_name_at + 1; - $at = $tag_name_at + 7; + foreach ( self::find_tag_names( $sourcecode, 'script', true ) as $at ) { + $escaped .= substr( $sourcecode, $was_at, $at - $was_at ); + $escaped .= 's' === $sourcecode[ $at ] ? '\u0073' : '\u0053'; + $was_at = $at + 1; } - if ( '' === $escaped ) { + if ( 0 === $was_at ) { return $sourcecode; } - if ( $was_at < $end ) { - $escaped .= substr( $sourcecode, $was_at ); - } - + $escaped .= substr( $sourcecode, $was_at ); return $escaped; } @@ -4594,6 +4569,51 @@ static function ( array $matches ): string { ); } + /** + * Finds each tag name in raw text which the tokenizer would read as a tag + * of the given name. + * + * A tag name matches after "" follows it. + * + * Example: + * + * array( 1, 10 ) === self::find_tag_names( '', 'script', true ); + * array( 10 ) === self::find_tag_names( '', 'script', false ); + * array() === self::find_tag_names( '', 'script', false ); + * + * @since 7.2.0 + * @ignore + * + * @param string $text Raw text. + * @param string $tag_name Tag name, in any case. + * @param bool $start_tags_too Whether a start tag matches as well as an end tag. + * @return int[] Byte offset of the first letter of each matching tag name. + */ + private static function find_tag_names( string $text, string $tag_name, bool $start_tags_too ): array { + $found = array(); + $opener = $start_tags_too ? '<' : '", $at + $length, 1 ) + ) { + $found[] = $at; + } + } + + return $found; + } + /** * Updates or creates a new attribute on the currently matched tag with the passed value. * diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php index b052e4cf48ed0..966ccaf725973 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php @@ -820,6 +820,7 @@ public static function data_script_tag_text_updates(): array { 'Tricky script open tag with \r\n' => array( '', "" or "--!>", or at ">" or "->" right after "' => array( '', 'Comments end in -->' ), 'Comment with --!>' => array( '', 'Invalid but legitimate comments end in --!>' ), + 'Comment starting with >' => array( '', '> ends an empty comment' ), + 'Comment starting with ->' => array( '', '-> ends an empty comment' ), 'IFRAME with ' => array( '', 'Just a ' ), 'NOEMBED with ' => array( 'Replace me', 'Just a ' ), 'NOFRAMES with ' => array( 'Replace me', 'beforeafter' ), @@ -727,6 +729,46 @@ public static function data_unallowed_modifiable_text_updates() { ); } + /** + * Ensures that comment text which resembles a comment closer, but cannot + * end the comment, is allowed. + * + * @dataProvider data_comment_text_resembling_a_closer + * + * @param string $update Text resembling, but not forming, a comment closer. + */ + public function test_allows_comment_text_which_cannot_close_the_comment( string $update ): void { + $processor = new WP_HTML_Tag_Processor( '' ); + $processor->next_token(); + + $this->assertTrue( + $processor->set_modifiable_text( $update ), + 'Should have allowed text which cannot close the comment.' + ); + + $this->assertSame( + "", + $processor->get_updated_html(), + 'Should have updated the comment.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_comment_text_resembling_a_closer(): array { + return array( + '> after text' => array( 'a>' ), + '-> after text' => array( 'a->' ), + '-!> at the start' => array( '-!>' ), + '--! before text' => array( '--!x>' ), + 'Spaced dashes' => array( '- ->' ), + 'Dashes at the end' => array( 'a--' ), + ); + } + /** * Ensures that raw text which resembles a closing tag, but cannot close its * element, is allowed as modifiable text. From a004e739563c3fff3d90377d7841152c782ba079 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 21 Oct 2026 11:35:00 +0400 Subject: [PATCH 14/14] Compare raw text tag names with strncasecmp() HTML tag names match in any ASCII case. find_tag_names() now compares each candidate with strncasecmp(), which compares ASCII letters without regard to case, in place of substr_compare( ..., true ). strncasecmp() takes no offset, so the name is copied out with substr(). The first letter is compared before the copy, which keeps most `<` to one call. Time per candidate tag name, PHP 8.5 with JIT / PHP 8.4 / PHP 7.4: - First letter differs: 13 / 13 / 14 ns before, 11 / 13 / 14 ns after. - First letter matches: 18 / 17 / 18 ns before, 30 / 33 / 36 ns after. --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 5 ++++- 1 file changed, 4 insertions(+), 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 f3fbf7aad713b..91f192e9a5364 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 @@ -4631,8 +4631,11 @@ private static function find_tag_names( string $text, string $tag_name, bool $st ++$at; } + // Tag names match ASCII case-insensitively. The first letter is compared before the name is copied. if ( - 0 === substr_compare( $text, $tag_name, $at, $length, true ) && + $at < $end && + 0 === strncasecmp( $text[ $at ], $tag_name, 1 ) && + 0 === strncasecmp( substr( $text, $at, $length ), $tag_name, $length ) && 1 === strspn( $text, " \t\f\r\n/>", $at + $length, 1 ) ) { $found[] = $at;