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 336c83949c0dc..55f955f2c1a9a 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -5290,13 +5290,30 @@ public function get_attribute( $name ) { /** * Updates or creates a new attribute on the currently matched tag with the passed value. * - * For boolean attributes special handling is provided: + * This function handles all necessary HTML encoding. Provide normal, unescaped string values. + * The HTML API will encode the strings appropriately so that the browser will interpret them + * as the intended value. + * + * Example: + * + * // Renders “Eggs & Milk” in a browser, encoded as ``. + * $processor->set_attribute( 'title', 'Eggs & Milk' ); + * + * // Renders “Eggs & Milk” in a browser, encoded as ``. + * $processor->set_attribute( 'title', 'Eggs & Milk' ); + * + * // Renders `true` as ``. + * $processor->set_attribute( 'title', true ); + * + * // Renders without the attribute for `false` as ``. + * $processor->set_attribute( 'title', false ); + * + * Special handling is provided for boolean attribute values: * - When `true` is passed as the value, then only the attribute name is added to the tag. * - When `false` is passed, the attribute gets removed if it existed before. * - * For string attributes, the value is escaped using the `esc_attr` function. - * * @since 6.6.0 Subclassed for the HTML Processor. + * @since 6.9.0 Escapes all character references instead of trying to avoid double-escaping. * * @param string $name The attribute name to target. * @param string|bool $value The new attribute value. 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 3d66f7e57d01b..83b71b81ecaa4 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 @@ -3746,10 +3746,22 @@ public function get_modifiable_text(): string { * $processor->set_modifiable_text( str_replace( ':)', '🙂', $chunk ) ); * } * + * This function handles all necessary HTML encoding. Provide normal, unescaped string values. + * The HTML API will encode the strings appropriately so that the browser will interpret them + * as the intended value. + * + * Example: + * + * // Renders as “Eggs & Milk” in a browser, encoded as `

Eggs & Milk

`. + * $processor->set_modifiable_text( 'Eggs & Milk' ); + * + * // Renders as “Eggs & Milk” in a browser, encoded as `

Eggs & Milk

`. + * $processor->set_modifiable_text( 'Eggs & Milk' ); + * * @since 6.7.0 + * @since 6.9.0 Escapes all character references instead of trying to avoid double-escaping. * * @param string $plaintext_content New text content to represent in the matched token. - * * @return bool Whether the text was able to update. */ public function set_modifiable_text( string $plaintext_content ): bool { @@ -3757,7 +3769,16 @@ public function set_modifiable_text( string $plaintext_content ): bool { $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement( $this->text_starts_at, $this->text_length, - htmlspecialchars( $plaintext_content, ENT_QUOTES | ENT_HTML5 ) + strtr( + $plaintext_content, + array( + '<' => '<', + '>' => '>', + '&' => '&', + '"' => '"', + "'" => ''', + ) + ) ); return true; @@ -3871,14 +3892,31 @@ static function ( $tag_match ) { /** * Updates or creates a new attribute on the currently matched tag with the passed value. * - * For boolean attributes special handling is provided: + * This function handles all necessary HTML encoding. Provide normal, unescaped string values. + * The HTML API will encode the strings appropriately so that the browser will interpret them + * as the intended value. + * + * Example: + * + * // Renders “Eggs & Milk” in a browser, encoded as ``. + * $processor->set_attribute( 'title', 'Eggs & Milk' ); + * + * // Renders “Eggs & Milk” in a browser, encoded as ``. + * $processor->set_attribute( 'title', 'Eggs & Milk' ); + * + * // Renders `true` as ``. + * $processor->set_attribute( 'title', true ); + * + * // Renders without the attribute for `false` as ``. + * $processor->set_attribute( 'title', false ); + * + * Special handling is provided for boolean attribute values: * - When `true` is passed as the value, then only the attribute name is added to the tag. * - When `false` is passed, the attribute gets removed if it existed before. * - * For string attributes, the value is escaped using the `esc_attr` function. - * * @since 6.2.0 * @since 6.2.1 Fix: Only create a single update for multiple calls with case-variant attribute names. + * @since 6.9.0 Escapes all character references instead of trying to avoid double-escaping. * * @param string $name The attribute name to target. * @param string|bool $value The new attribute value. @@ -3950,12 +3988,23 @@ public function set_attribute( $name, $value ): bool { } else { $comparable_name = strtolower( $name ); - /* - * Escape URL attributes. + /** + * Escape attribute values appropriately. * * @see https://html.spec.whatwg.org/#attributes-3 */ - $escaped_new_value = in_array( $comparable_name, wp_kses_uri_attributes(), true ) ? esc_url( $value ) : esc_attr( $value ); + $escaped_new_value = in_array( $comparable_name, wp_kses_uri_attributes(), true ) + ? esc_url( $value ) + : strtr( + $value, + array( + '<' => '<', + '>' => '>', + '&' => '&', + '"' => '"', + "'" => ''', + ) + ); // If the escaping functions wiped out the update, reject it and indicate it was rejected. if ( '' === $escaped_new_value && '' !== $value ) { diff --git a/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php b/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php index 5c8bf03b64606..07e1b39a441aa 100644 --- a/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php +++ b/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php @@ -138,7 +138,7 @@ public function data_background_block_support() { 'url' => 'https://example.com/image.jpg', ), ), - 'expected_wrapper' => '
Content
', + 'expected_wrapper' => '
Content
', 'wrapper' => '
Content
', ), 'background image style with contain, position, attachment, and repeat is applied' => array( @@ -155,7 +155,7 @@ public function data_background_block_support() { 'backgroundSize' => 'contain', 'backgroundAttachment' => 'fixed', ), - 'expected_wrapper' => '
Content
', + 'expected_wrapper' => '
Content
', 'wrapper' => '
Content
', ), 'background image style is appended if a style attribute already exists' => array( @@ -169,7 +169,7 @@ public function data_background_block_support() { 'url' => 'https://example.com/image.jpg', ), ), - 'expected_wrapper' => '
Content
', + 'expected_wrapper' => '
Content
', 'wrapper' => '
Content
', ), 'background image style is appended if a style attribute containing multiple styles already exists' => array( @@ -183,7 +183,7 @@ public function data_background_block_support() { 'url' => 'https://example.com/image.jpg', ), ), - 'expected_wrapper' => '
Content
', + 'expected_wrapper' => '
Content
', 'wrapper' => '
Content
', ), 'background image style is appended if a boolean style attribute already exists' => array( @@ -198,7 +198,7 @@ public function data_background_block_support() { 'source' => 'file', ), ), - 'expected_wrapper' => '
Content
', + 'expected_wrapper' => '
Content
', 'wrapper' => '
Content
', ), 'background image style is not applied if the block does not support background image' => array( diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 53bba243a99a0..9806caeca66ae 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -841,7 +841,7 @@ public function test_attribute_ops_on_tag_closer_do_not_change_the_markup() { * * @param string $attribute_value A value with potential XSS exploit. */ - public function test_set_attribute_prevents_xss( $attribute_value ) { + public function test_set_attribute_prevents_xss( $attribute_value, $escaped_attribute_value = null ) { $processor = new WP_HTML_Tag_Processor( '
' ); $processor->next_tag(); $processor->set_attribute( 'test', $attribute_value ); @@ -861,7 +861,7 @@ public function test_set_attribute_prevents_xss( $attribute_value ) { preg_match( '~^
$~', $processor->get_updated_html(), $match ); list( , $actual_value ) = $match; - $this->assertSame( '"' . esc_attr( $attribute_value ) . '"', $actual_value, 'Entities were not properly escaped in the attribute value' ); + $this->assertSame( '"' . $escaped_attribute_value . '"', $actual_value, 'Entities were not properly escaped in the attribute value' ); } /** @@ -871,15 +871,18 @@ public function test_set_attribute_prevents_xss( $attribute_value ) { */ public static function data_set_attribute_prevents_xss() { return array( - array( '"' ), - array( '"' ), - array( '&' ), - array( '&' ), - array( '€' ), - array( "'" ), - array( '<>' ), - array( '"";' ), - array( '" onclick="alert(\'1\');">' ), + array( '"', '"' ), + array( '"', '&quot;' ), + array( '&', '&' ), + array( '&', '&amp;' ), + array( '€', '&euro;' ), + array( "'", ''' ), + array( '<>', '<>' ), + array( '"";', '&quot";' ), + array( + '" onclick="alert(\'1\');">', + '" onclick="alert('1');"><span onclick=""></span><script>alert("1")</script>', + ), ); } @@ -905,6 +908,21 @@ public function test_set_attribute_with_a_non_existing_attribute_adds_a_new_attr ); } + /** + * Ensure that attribute values that appear to contain HTML character references are correctly + * encoded and preserve the original value. + * + * @ticket 64054 + */ + public function test_set_attribute_encodes_html_character_references() { + $original = 'HTML character references: < > &'; + $processor = new WP_HTML_Tag_Processor( '' ); + $processor->next_tag(); + $processor->set_attribute( 'data-attr', $original ); + $this->assertSame( $original, $processor->get_attribute( 'data-attr' ) ); + $this->assertEqualHTML( '', $processor->get_updated_html() ); + } + /** * @ticket 56299 * @@ -2786,9 +2804,10 @@ public function test_updating_attributes_in_malformed_html( $html, $expected ) { $processor->next_tag(); $processor->add_class( 'secondTag' ); - $this->assertSame( + $this->assertEqualHTML( $expected, $processor->get_updated_html(), + '', 'Did not properly update attributes and classnames given malformed input' ); } @@ -2806,11 +2825,11 @@ public static function data_updating_attributes_in_malformed_html() { ), 'HTML tag opening inside attribute value' => array( 'input' => '
This <is> a <strong is="true">thing.
test', - 'expected' => '
This <is> a <strong is="true">thing.
test', + 'expected' => '
This <is> a <strong is="true">thing.
test', ), 'HTML tag brackets in attribute values and data markup' => array( 'input' => '
This <is> a <strong is="true">thing.
test', - 'expected' => '
This <is> a <strong is="true">thing.
test', + 'expected' => '
This <is> a <strong is="true">thing.
test', ), 'Single and double quotes in attribute value' => array( 'input' => '

test',