diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index a12b8ca06f78f..b2a597b938495 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -1387,7 +1387,7 @@ public function serialize_token(): string { break; case '#text': - $html .= htmlspecialchars( $this->get_modifiable_text(), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8' ); + $html .= self::escape_text_for_serialization( $this->get_modifiable_text() ); break; // Unlike the `<>` which is interpreted as plaintext, this is ignored entirely. @@ -1417,10 +1417,9 @@ public function serialize_token(): string { return $html; } - $tag_name = str_replace( "\x00", "\u{FFFD}", $this->get_tag() ); + $tag_name = $this->get_tag(); $in_html = 'html' === $this->get_namespace(); $qualified_name = $in_html ? strtolower( $tag_name ) : $this->get_qualified_tag_name(); - $qualified_name = str_replace( "\x00", "\u{FFFD}", $qualified_name ); if ( $this->is_tag_closer() ) { $html .= "{$qualified_name}>"; @@ -1439,7 +1438,6 @@ public function serialize_token(): string { $seen_attribute_names = array(); foreach ( $attribute_names as $attribute_name ) { $qualified_attribute_name = $this->get_qualified_attribute_name( $attribute_name ); - $qualified_attribute_name = str_replace( "\x00", "\u{FFFD}", $qualified_attribute_name ); $qualified_attribute_name = wp_scrub_utf8( $qualified_attribute_name ); /** * Spaces only appear via the foreign attribute adjustment table. @@ -1464,11 +1462,10 @@ public function serialize_token(): string { $value = $this->get_attribute( $attribute_name ); if ( is_string( $value ) ) { - $html .= '="' . htmlspecialchars( $value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5 ) . '"'; + $html .= '="' . self::escape_text_for_serialization( $value ) . '"'; } $previous_attribute_was_true = true === $value; - $html = str_replace( "\x00", "\u{FFFD}", $html ); } if ( ! $in_html && $this->has_self_closing_flag() ) { @@ -1517,7 +1514,7 @@ public function serialize_token(): string { break; default: - $text = htmlspecialchars( $text, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8' ); + $text = self::escape_text_for_serialization( $text ); } $html .= "{$text}{$qualified_name}>"; @@ -1526,6 +1523,33 @@ public function serialize_token(): string { return $html; } + /** + * Escapes decoded text for HTML serialization. + * + * Use for: + * - Attribute values. + * - Text in ordinary (data-state) elements and in the RCDATA elements + * (TITLE and TEXTAREA). + * - Text in foreign content (elements not in the HTML namespace). + * + * Do not use for text in the RAWTEXT elements (STYLE, XMP, IFRAME, + * NOEMBED, NOFRAMES), HTML SCRIPT elements, or PLAINTEXT elements, + * whose contents serialize without escaping. + * + * @since 7.1.0 + * @ignore + * + * @param string $text Decoded text to escape. + * @return string Escaped text. + */ + private static function escape_text_for_serialization( string $text ): string { + $text = htmlspecialchars( $text, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8' ); + + $text = str_replace( "\r", ' ', $text ); + + return str_replace( "\x00", "\u{FFFD}", $text ); + } + /** * Parses next element in the 'initial' insertion mode. * diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php index 587d0a4d4bd8e..1ce8855305ae9 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php @@ -749,6 +749,45 @@ public static function data_provider_normalized_fuzzer_cases_that_should_be_idem ); } + /** + * Ensures that carriage returns are correctly serialized. + * + * @ticket 65372 + * + * @dataProvider data_provider_carriage_returns + * + * @param string $input HTML input containing a decoded carriage return. + * @param string $expected Expected normalized output. + */ + public function test_normalize_serializes_decoded_carriage_returns_as_character_references( string $input, string $expected ): void { + $normalized = WP_HTML_Processor::normalize( $input ); + + $this->assertSame( $expected, $normalized, 'Should have serialized the carriage return as a character reference.' ); + $this->assertSame( + $expected, + WP_HTML_Processor::normalize( $normalized ), + 'Normalizing already-normalized HTML should not change the serialized carriage return.' + ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_provider_carriage_returns(): array { + return array( + 'Decimal character reference' => array( 'a b', 'a b' ), + 'Hex character reference' => array( 'a b', 'a b' ), + 'RCDATA title' => array( 'a b', 'a b' ), + 'Attribute value' => array( '', '' ), + 'Table text' => array( 'x ', 'x ' ), + 'Template text' => array( 'a b', 'a b' ), + 'Raw CR' => array( "", "" ), + 'Raw CRLF pair' => array( "", "" ), + ); + } + /** * Data provider. *
", "