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 88487fb068d11..a28aeb7b857bb 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
@@ -3815,7 +3815,7 @@ public function get_modifiable_text(): string {
? $this->lexical_updates['modifiable text']->text
: substr( $this->html, $this->text_starts_at, $this->text_length );
- /*
+ /**
* An enqueued processing instruction update holds normalized raw
* syntax spanning from the end of the target through the end of
* the token: a separating space, the data, and the `?>` closer.
@@ -4161,12 +4161,19 @@ static function ( $tag_match ) {
case 'TEXTAREA':
case 'TITLE':
- $plaintext_content = preg_replace_callback(
- "~(?P{$this->get_tag()})~i",
- static function ( $tag_match ) {
- return "</{$tag_match['TAG_NAME']}";
- },
- $plaintext_content
+ /**
+ * While not expressly required, escaping syntax characters in these
+ * elements will help avoid problems with downstream parser which
+ * attempt to parse tags and other markup within. {@see \DOMDocument},
+ * for example, will claim to find elements as children of a `TITLE`.
+ */
+ $plaintext_content = strtr(
+ $plaintext_content,
+ array(
+ '<' => '<',
+ '&' => '&',
+ '>' => '>',
+ )
);
/*
diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php
index 589318daf3a70..d651a07f30d7c 100644
--- a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php
+++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php
@@ -414,9 +414,10 @@ public function test_updates_basic_modifiable_text_on_supported_nodes( string $h
'Should have modified the text at the target node.'
);
- $this->assertSame(
+ $this->assertEqualHTML(
$transformed,
$processor->get_updated_html(),
+ '',
"Should have transformed the HTML as expected when modifying the target node's modifiable text."
);
}
@@ -577,6 +578,79 @@ public function test_replaces_previous_processing_instruction_data_update(): voi
);
}
+ /**
+ * Ensures that RCDATA contents are eagerly escaped, despite being
+ * optional, to protect downstream parsers from misparsing.
+ *
+ * @ticket {TICKET_NUMBER}
+ *
+ * @dataProvider data_rcdata_element_names
+ *
+ * @param 'TITLE'|'TEXTAREA' $element_name Which RCDATA element to verify.
+ */
+ public function test_escapes_rcdata_content( string $element_name ): void {
+ $text = 'the
is text';
+ $html = "<{$element_name}>{$text}{$element_name}>";
+ $processor = new WP_HTML_Tag_Processor( $html );
+
+ $this->assertTrue(
+ $processor->next_token(),
+ "Failed to advance into the {$element_name} element: check test setup."
+ );
+
+ /*
+ * While this may look like a no-op, it should change the inner text
+ * from `
` to `<img>`, which will be asserted below.
+ */
+ $this->assertTrue(
+ $processor->set_modifiable_text( $text ),
+ 'Failed to set the modifiable text: check test setup.'
+ );
+
+ $output = $processor->get_updated_html();
+
+ // The updated output must still remain equivalent to the input.
+ $this->assertEqualHTML(
+ $html,
+ $output,
+ '',
+ 'Failed to update modifiable text without changing the parsed HTML.'
+ );
+
+ // Ensure that the content is not left unescaped.
+ $this->assertStringNotContainsString(
+ '
',
+ $output,
+ 'Should have escaped the
into <img<.'
+ );
+
+ // Finally, ensure that weaker parsers still interpret this correctly.
+ if ( class_exists( '\DOMDocument' ) ) {
+ $dom = new \DOMDocument();
+ $dom->loadHTML( $output, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING );
+
+ $first_child = $dom->getElementsByTagName( strtolower( $element_name ) )->item( 0 )->childNodes[0];
+
+ $this->assertSame(
+ $first_child->nodeValue,
+ $text,
+ 'Failed to protect weaker parsers from detecting elements as RCDATA content.'
+ );
+ }
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array[]
+ */
+ public static function data_rcdata_element_names(): array {
+ return array(
+ array( 'TEXTAREA' ),
+ array( 'TITLE' ),
+ );
+ }
+
/**
* Ensures that updates with potentially-compromising values aren't accepted.
*