diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index ecae45083a6ca..95c5acdf6e35d 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -660,13 +660,33 @@ function get_html_split_regex() { . $cdata . ')'; + // Keep in sync with the element pattern in _get_wptexturize_split_regex(). + $element = + '(?(?=[a-zA-Z])' // Does a tag name follow? + . '(?:' // Unroll the loop: Consume everything until > is found. + . '[^>=]++' // Consume anything but > and =. + . '|' + . '=[ \t\f\r\n]*' // Attribute value follows. + . '(?:' + . '"[^"]*"' // Double-quoted value, which may contain >. + . '|' + . '\'[^\']*\'' // Single-quoted value, which may contain >. + . '|' + . '[^> \t\f\r\n]*+' // Unquoted value. + . ')' + . ')*+' // Loop possessively. + . '|' // ...else not a tag, so quotes are not special. + . '[^>]*+' + . ')' + . '>?'; // End of element. If not found, match all input. + $regex = '/(' // Capture the entire match. . '<' // Find start of element. . '(?' // Conditional expression follows. . $escaped // Find end of escaped element. . '|' // ...else... - . '[^>]*>?' // Find end of normal element. + . $element // Find end of element, allowing > inside quoted attribute values. . ')' . ')/'; // phpcs:enable @@ -699,12 +719,32 @@ function _get_wptexturize_split_regex( $shortcode_regex = '' ) { . ')*+' // Loop possessively. . '(?:-->)?'; // End of comment. If not found, match all input. + // Keep in sync with the element pattern in get_html_split_regex(). + $element = + '(?(?=[a-zA-Z])' // Does a tag name follow? + . '(?:' // Unroll the loop: Consume everything until > is found. + . '[^>=]++' // Consume anything but > and =. + . '|' + . '=[ \t\f\r\n]*' // Attribute value follows. + . '(?:' + . '"[^"]*"' // Double-quoted value, which may contain >. + . '|' + . '\'[^\']*\'' // Single-quoted value, which may contain >. + . '|' + . '[^> \t\f\r\n]*+' // Unquoted value. + . ')' + . ')*+' // Loop possessively. + . '|' // ...else not a tag, so quotes are not special. + . '[^>]*+' + . ')' + . '>?'; // End of element. If not found, match all input. + $html_regex = // Needs replaced with wp_html_split() per Shortcode API Roadmap. '<' // Find start of element. . '(?(?=!--)' // Is this a comment? . $comment_regex // Find end of comment. . '|' - . '[^>]*>?' // Find end of element. If not found, match all input. + . $element // Find end of element. . ')'; // phpcs:enable } diff --git a/tests/phpunit/tests/formatting/wpHtmlSplit.php b/tests/phpunit/tests/formatting/wpHtmlSplit.php index 5635b3a9a3b27..8adb03adb163c 100644 --- a/tests/phpunit/tests/formatting/wpHtmlSplit.php +++ b/tests/phpunit/tests/formatting/wpHtmlSplit.php @@ -37,6 +37,138 @@ public static function data_basic_features() { ); } + /** + * Ensure that > inside quoted attribute values does not cause premature + * tag splitting in wp_html_split(). + * + * @ticket 63997 + * @ticket 57381 + * @ticket 63426 + * + * @dataProvider data_gt_in_quoted_attribute_values + * + * @param non-falsy-string $input Input HTML. + * @param list $output Expected split. + */ + public function test_gt_in_quoted_attribute_values( string $input, array $output ): void { + $this->assertSame( $output, wp_html_split( $input ) ); + } + + /** + * Data provider for {@see self::test_gt_in_quoted_attribute_values()}. + * + * @return list }> + */ + public static function data_gt_in_quoted_attribute_values(): array { + return array( + array( + '
content
', + array( '', '
', 'content', '
', '' ), + ), + array( + '
b\'>content
', + array( '', '
b\'>', 'content', '
', '' ), + ), + array( + '
content
', + array( '', '
', 'content', '
', '' ), + ), + array( + '
Content
', + array( '', '
', 'Content', '
', '' ), + ), + array( + '', + array( '', '', '' ), + ), + array( + '
content
', + array( '', '
', 'content', '
', '' ), + ), + ); + } + + /** + * Ensure that a quote in an unquoted attribute value does not pair with a + * quote after the tag. + * + * @ticket 63997 + */ + public function test_quote_in_unquoted_attribute_value_does_not_pair_across_tag(): void { + $input = "

Hi

\nIt's here"; + + $this->assertSame( + array( '', '

', 'Hi', '

', "\n", '', 'It\'s here', '', '' ), + wp_html_split( $input ) + ); + } + + /** + * Ensure that an unclosed tag matches the remaining input. + * + * @ticket 63997 + */ + public function test_unclosed_tag_matches_remaining_input(): void { + $input = "

Truncated assertSame( + array( '', '

', 'Truncated ', '', ' id="quote">' ), + wp_html_split( '

id="quote">' ) + ); + } + + /** + * Ensure that a quote-named attribute splits the same as before. + * + * @ticket 63997 + */ + public function test_quote_named_attribute_is_one_element(): void { + $this->assertSame( + array( '', '
', 'x' ), + wp_html_split( '
x' ) + ); + } + + /** + * Ensure that a quote after a < that does not start a tag does not pair + * with a quote after the next >. + * + * @ticket 63997 + */ + public function test_quote_after_non_tag_lt_does_not_pair_across_gt(): void { + $this->assertSame( + array( '', '

', 'x', "<=' then

", '', '

', "don't", '

', '' ), + wp_html_split( "

x<=' then

don't

" ) + ); + } + + /** + * Ensure that a CDATA section containing > is a single element. + * + * @ticket 63997 + */ + public function test_cdata_with_gt_is_one_element(): void { + $input = ''; + + $this->assertSame( + array( '', '', '' ), + wp_html_split( $input ) + ); + } + /** * Automated performance testing of the main regex. * diff --git a/tests/phpunit/tests/formatting/wpTexturize.php b/tests/phpunit/tests/formatting/wpTexturize.php index 61b8b46c46a26..91a7bc4e7ed68 100644 --- a/tests/phpunit/tests/formatting/wpTexturize.php +++ b/tests/phpunit/tests/formatting/wpTexturize.php @@ -2111,6 +2111,123 @@ public function test_trailing_less_than() { $this->assertSame( 'F–oo<', wptexturize( 'F--oo<', true ) ); } + /** + * Ensure that > inside quoted attribute values does not cause premature + * tag splitting, which would leave quotes exposed to texturization. + * + * @ticket 63997 + * @ticket 57381 + * @ticket 63426 + * + * @dataProvider data_gt_in_quoted_attribute_values + * + * @param string $input Input HTML. + * @param string $output Expected output. + */ + public function test_gt_in_quoted_attribute_values( string $input, string $output ): void { + $this->assertSame( $output, wptexturize( $input ) ); + } + + /** + * Data provider for {@see self::test_gt_in_quoted_attribute_values()}. + * + * @return list + */ + public static function data_gt_in_quoted_attribute_values(): array { + return array( + array( + '
0 ? "yes" : "no"\'>content
', + '
0 ? "yes" : "no"\'>content
', + ), + array( + "
0 ? 'yes' : 'no'\">content
", + "
0 ? 'yes' : 'no'\">content
", + ), + array( + '
content
', + '
content
', + ), + array( + '
content
', + '
content
', + ), + array( + '
Content
', + '
Content
', + ), + array( + '', + '', + ), + array( + '
content
', + '
content
', + ), + ); + } + + /** + * Ensure that an ampersand in a quoted attribute value is still encoded. + * + * @ticket 57381 + */ + public function test_ampersand_in_quoted_attribute_value_is_encoded(): void { + $this->assertSame( + '
x', + wptexturize( 'x' ) + ); + } + + /** + * Ensure that a quote in an unquoted attribute value does not pair with a + * quote after the tag and swallow the following text. + * + * @ticket 63997 + */ + public function test_quote_in_unquoted_attribute_value_does_not_pair_across_tag(): void { + $input = "

Hi

\nIt's here"; + + $this->assertSame( + "

Hi

\nIt’s here", + wptexturize( $input ) + ); + } + + /** + * Ensure that a quote after a < that does not start a tag does not pair + * with a quote in the following text. + * + * @ticket 63997 + */ + public function test_quote_after_non_tag_lt_does_not_pair_across_gt(): void { + $this->assertSame( + "

x<=' then

don’t

", + wptexturize( "

x<=' then

don't

" ) + ); + } + + /** + * Ensure that an unclosed tag is not texturized. + * + * @ticket 63997 + */ + public function test_unclosed_tag_is_not_texturized(): void { + $input = "

Truncated assertSame( $input, wptexturize( $input ) ); + } + + /** + * Ensure that an ampersand inside a CDATA section is not encoded. + * + * @ticket 63997 + */ + public function test_ampersand_in_cdata_is_not_encoded(): void { + $input = ''; + + $this->assertSame( $input, wptexturize( $input ) ); + } + public static function data_whole_posts() { require_once DIR_TESTDATA . '/formatting/whole-posts.php'; return data_whole_posts();