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..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
@@ -3851,20 +3851,11 @@ public function set_modifiable_text( string $plaintext_content ): bool {
return true;
case 'STYLE':
- $plaintext_content = preg_replace_callback(
- '~(?Pstyle)~i',
- static function ( $tag_match ) {
- return "\\3c\\2f{$tag_match['TAG_NAME']}";
- },
- $plaintext_content
- );
-
$this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement(
$this->text_starts_at,
$this->text_length,
- $plaintext_content
+ self::escape_style_contents( $plaintext_content )
);
-
return true;
case 'TEXTAREA':
@@ -4240,6 +4231,60 @@ 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.
+ *
+ * @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 static function escape_style_contents( string $text ): string {
+ $at = 0;
+ $was_at = 0;
+ $end = strlen( $text );
+ $escaped = '';
+
+ /*
+ * Replace all instances of the ASCII case-insensitive match of "", 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;
+ }
+
/**
* 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 9e0d94aecd17e..a58a905e693d4 100644
--- a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php
+++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php
@@ -426,18 +426,22 @@ 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 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: "
& "; }', '' ),
+ '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' ),
+ 'TITLE tag' => array( 'ahas no need to escapeb', 2, "so it ", "aso it 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' ),
);
}