From 80775021f948571931e99c20cf3add535bd7c93c Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 29 Dec 2025 20:38:45 +0100 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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 );