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( - '~style)~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 <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: " & "; }', '' ), + '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 <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' ), ); }