Skip to content

Commit 4892d46

Browse files
committed
HTML API: Escape all submitted HTML character references.
The HTML API has relied on `esc_attr()` and `esc_html()` when setting string attribute values or the contents of modifiable text. This leads to unexpected behavior when those functions attempt to prevent double-escaping of existing character references, and it can make certain contents impossible to represent. After this change, the HTML API will reliably escape all submitted plaintext such that it appears in the browser the way it was submitted to the HTML API, with all character references escaped. This does not change the behavior of how URL attributes are escaped. Developed in #10143 Discussed in https://core.trac.wordpress.org/ticket/64054 Props dmsnell, jonsurrell, westonruter. Fixes #64054. git-svn-id: https://develop.svn.wordpress.org/trunk@60919 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 05324dd commit 4892d46

File tree

4 files changed

+115
-30
lines changed

4 files changed

+115
-30
lines changed

src/wp-includes/html-api/class-wp-html-processor.php

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5290,13 +5290,30 @@ public function get_attribute( $name ) {
52905290
/**
52915291
* Updates or creates a new attribute on the currently matched tag with the passed value.
52925292
*
5293-
* For boolean attributes special handling is provided:
5293+
* This function handles all necessary HTML encoding. Provide normal, unescaped string values.
5294+
* The HTML API will encode the strings appropriately so that the browser will interpret them
5295+
* as the intended value.
5296+
*
5297+
* Example:
5298+
*
5299+
* // Renders “Eggs & Milk” in a browser, encoded as `<abbr title="Eggs &amp; Milk">`.
5300+
* $processor->set_attribute( 'title', 'Eggs & Milk' );
5301+
*
5302+
* // Renders “Eggs &amp; Milk” in a browser, encoded as `<abbr title="Eggs &amp;amp; Milk">`.
5303+
* $processor->set_attribute( 'title', 'Eggs &amp; Milk' );
5304+
*
5305+
* // Renders `true` as `<abbr title>`.
5306+
* $processor->set_attribute( 'title', true );
5307+
*
5308+
* // Renders without the attribute for `false` as `<abbr>`.
5309+
* $processor->set_attribute( 'title', false );
5310+
*
5311+
* Special handling is provided for boolean attribute values:
52945312
* - When `true` is passed as the value, then only the attribute name is added to the tag.
52955313
* - When `false` is passed, the attribute gets removed if it existed before.
52965314
*
5297-
* For string attributes, the value is escaped using the `esc_attr` function.
5298-
*
52995315
* @since 6.6.0 Subclassed for the HTML Processor.
5316+
* @since 6.9.0 Escapes all character references instead of trying to avoid double-escaping.
53005317
*
53015318
* @param string $name The attribute name to target.
53025319
* @param string|bool $value The new attribute value.

src/wp-includes/html-api/class-wp-html-tag-processor.php

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3746,18 +3746,39 @@ public function get_modifiable_text(): string {
37463746
* $processor->set_modifiable_text( str_replace( ':)', '🙂', $chunk ) );
37473747
* }
37483748
*
3749+
* This function handles all necessary HTML encoding. Provide normal, unescaped string values.
3750+
* The HTML API will encode the strings appropriately so that the browser will interpret them
3751+
* as the intended value.
3752+
*
3753+
* Example:
3754+
*
3755+
* // Renders as “Eggs & Milk” in a browser, encoded as `<p>Eggs &amp; Milk</p>`.
3756+
* $processor->set_modifiable_text( 'Eggs & Milk' );
3757+
*
3758+
* // Renders as “Eggs &amp; Milk” in a browser, encoded as `<p>Eggs &amp;amp; Milk</p>`.
3759+
* $processor->set_modifiable_text( 'Eggs &amp; Milk' );
3760+
*
37493761
* @since 6.7.0
3762+
* @since 6.9.0 Escapes all character references instead of trying to avoid double-escaping.
37503763
*
37513764
* @param string $plaintext_content New text content to represent in the matched token.
3752-
*
37533765
* @return bool Whether the text was able to update.
37543766
*/
37553767
public function set_modifiable_text( string $plaintext_content ): bool {
37563768
if ( self::STATE_TEXT_NODE === $this->parser_state ) {
37573769
$this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement(
37583770
$this->text_starts_at,
37593771
$this->text_length,
3760-
htmlspecialchars( $plaintext_content, ENT_QUOTES | ENT_HTML5 )
3772+
strtr(
3773+
$plaintext_content,
3774+
array(
3775+
'<' => '&lt;',
3776+
'>' => '&gt;',
3777+
'&' => '&amp;',
3778+
'"' => '&quot;',
3779+
"'" => '&apos;',
3780+
)
3781+
)
37613782
);
37623783

37633784
return true;
@@ -3871,14 +3892,31 @@ static function ( $tag_match ) {
38713892
/**
38723893
* Updates or creates a new attribute on the currently matched tag with the passed value.
38733894
*
3874-
* For boolean attributes special handling is provided:
3895+
* This function handles all necessary HTML encoding. Provide normal, unescaped string values.
3896+
* The HTML API will encode the strings appropriately so that the browser will interpret them
3897+
* as the intended value.
3898+
*
3899+
* Example:
3900+
*
3901+
* // Renders “Eggs & Milk” in a browser, encoded as `<abbr title="Eggs &amp; Milk">`.
3902+
* $processor->set_attribute( 'title', 'Eggs & Milk' );
3903+
*
3904+
* // Renders “Eggs &amp; Milk” in a browser, encoded as `<abbr title="Eggs &amp;amp; Milk">`.
3905+
* $processor->set_attribute( 'title', 'Eggs &amp; Milk' );
3906+
*
3907+
* // Renders `true` as `<abbr title>`.
3908+
* $processor->set_attribute( 'title', true );
3909+
*
3910+
* // Renders without the attribute for `false` as `<abbr>`.
3911+
* $processor->set_attribute( 'title', false );
3912+
*
3913+
* Special handling is provided for boolean attribute values:
38753914
* - When `true` is passed as the value, then only the attribute name is added to the tag.
38763915
* - When `false` is passed, the attribute gets removed if it existed before.
38773916
*
3878-
* For string attributes, the value is escaped using the `esc_attr` function.
3879-
*
38803917
* @since 6.2.0
38813918
* @since 6.2.1 Fix: Only create a single update for multiple calls with case-variant attribute names.
3919+
* @since 6.9.0 Escapes all character references instead of trying to avoid double-escaping.
38823920
*
38833921
* @param string $name The attribute name to target.
38843922
* @param string|bool $value The new attribute value.
@@ -3950,12 +3988,23 @@ public function set_attribute( $name, $value ): bool {
39503988
} else {
39513989
$comparable_name = strtolower( $name );
39523990

3953-
/*
3954-
* Escape URL attributes.
3991+
/**
3992+
* Escape attribute values appropriately.
39553993
*
39563994
* @see https://html.spec.whatwg.org/#attributes-3
39573995
*/
3958-
$escaped_new_value = in_array( $comparable_name, wp_kses_uri_attributes(), true ) ? esc_url( $value ) : esc_attr( $value );
3996+
$escaped_new_value = in_array( $comparable_name, wp_kses_uri_attributes(), true )
3997+
? esc_url( $value )
3998+
: strtr(
3999+
$value,
4000+
array(
4001+
'<' => '&lt;',
4002+
'>' => '&gt;',
4003+
'&' => '&amp;',
4004+
'"' => '&quot;',
4005+
"'" => '&apos;',
4006+
)
4007+
);
39594008

39604009
// If the escaping functions wiped out the update, reject it and indicate it was rejected.
39614010
if ( '' === $escaped_new_value && '' !== $value ) {

tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ public function data_background_block_support() {
138138
'url' => 'https://example.com/image.jpg',
139139
),
140140
),
141-
'expected_wrapper' => '<div class="has-background" style="background-image:url(&#039;https://example.com/image.jpg&#039;);background-size:cover;">Content</div>',
141+
'expected_wrapper' => '<div class="has-background" style="background-image:url(&apos;https://example.com/image.jpg&apos;);background-size:cover;">Content</div>',
142142
'wrapper' => '<div>Content</div>',
143143
),
144144
'background image style with contain, position, attachment, and repeat is applied' => array(
@@ -155,7 +155,7 @@ public function data_background_block_support() {
155155
'backgroundSize' => 'contain',
156156
'backgroundAttachment' => 'fixed',
157157
),
158-
'expected_wrapper' => '<div class="has-background" style="background-image:url(&#039;https://example.com/image.jpg&#039;);background-position:50% 50%;background-repeat:no-repeat;background-size:contain;background-attachment:fixed;">Content</div>',
158+
'expected_wrapper' => '<div class="has-background" style="background-image:url(&apos;https://example.com/image.jpg&apos;);background-position:50% 50%;background-repeat:no-repeat;background-size:contain;background-attachment:fixed;">Content</div>',
159159
'wrapper' => '<div>Content</div>',
160160
),
161161
'background image style is appended if a style attribute already exists' => array(
@@ -169,7 +169,7 @@ public function data_background_block_support() {
169169
'url' => 'https://example.com/image.jpg',
170170
),
171171
),
172-
'expected_wrapper' => '<div class="wp-block-test has-background" style="color: red;background-image:url(&#039;https://example.com/image.jpg&#039;);background-size:cover;">Content</div>',
172+
'expected_wrapper' => '<div class="wp-block-test has-background" style="color: red;background-image:url(&apos;https://example.com/image.jpg&apos;);background-size:cover;">Content</div>',
173173
'wrapper' => '<div class="wp-block-test" style="color: red">Content</div>',
174174
),
175175
'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() {
183183
'url' => 'https://example.com/image.jpg',
184184
),
185185
),
186-
'expected_wrapper' => '<div class="wp-block-test has-background" style="color: red;font-size: 15px;background-image:url(&#039;https://example.com/image.jpg&#039;);background-size:cover;">Content</div>',
186+
'expected_wrapper' => '<div class="wp-block-test has-background" style="color: red;font-size: 15px;background-image:url(&apos;https://example.com/image.jpg&apos;);background-size:cover;">Content</div>',
187187
'wrapper' => '<div class="wp-block-test" style="color: red;font-size: 15px;">Content</div>',
188188
),
189189
'background image style is appended if a boolean style attribute already exists' => array(
@@ -198,7 +198,7 @@ public function data_background_block_support() {
198198
'source' => 'file',
199199
),
200200
),
201-
'expected_wrapper' => '<div class="has-background" classname="wp-block-test" style="background-image:url(&#039;https://example.com/image.jpg&#039;);background-size:cover;">Content</div>',
201+
'expected_wrapper' => '<div class="has-background" classname="wp-block-test" style="background-image:url(&apos;https://example.com/image.jpg&apos;);background-size:cover;">Content</div>',
202202
'wrapper' => '<div classname="wp-block-test" style>Content</div>',
203203
),
204204
'background image style is not applied if the block does not support background image' => array(

tests/phpunit/tests/html-api/wpHtmlTagProcessor.php

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -841,7 +841,7 @@ public function test_attribute_ops_on_tag_closer_do_not_change_the_markup() {
841841
*
842842
* @param string $attribute_value A value with potential XSS exploit.
843843
*/
844-
public function test_set_attribute_prevents_xss( $attribute_value ) {
844+
public function test_set_attribute_prevents_xss( $attribute_value, $escaped_attribute_value = null ) {
845845
$processor = new WP_HTML_Tag_Processor( '<div></div>' );
846846
$processor->next_tag();
847847
$processor->set_attribute( 'test', $attribute_value );
@@ -861,7 +861,7 @@ public function test_set_attribute_prevents_xss( $attribute_value ) {
861861
preg_match( '~^<div test=(.*)></div>$~', $processor->get_updated_html(), $match );
862862
list( , $actual_value ) = $match;
863863

864-
$this->assertSame( '"' . esc_attr( $attribute_value ) . '"', $actual_value, 'Entities were not properly escaped in the attribute value' );
864+
$this->assertSame( '"' . $escaped_attribute_value . '"', $actual_value, 'Entities were not properly escaped in the attribute value' );
865865
}
866866

867867
/**
@@ -871,15 +871,18 @@ public function test_set_attribute_prevents_xss( $attribute_value ) {
871871
*/
872872
public static function data_set_attribute_prevents_xss() {
873873
return array(
874-
array( '"' ),
875-
array( '&quot;' ),
876-
array( '&' ),
877-
array( '&amp;' ),
878-
array( '&euro;' ),
879-
array( "'" ),
880-
array( '<>' ),
881-
array( '&quot";' ),
882-
array( '" onclick="alert(\'1\');"><span onclick=""></span><script>alert("1")</script>' ),
874+
array( '"', '&quot;' ),
875+
array( '&quot;', '&amp;quot;' ),
876+
array( '&', '&amp;' ),
877+
array( '&amp;', '&amp;amp;' ),
878+
array( '&euro;', '&amp;euro;' ),
879+
array( "'", '&apos;' ),
880+
array( '<>', '&lt;&gt;' ),
881+
array( '&quot";', '&amp;quot&quot;;' ),
882+
array(
883+
'" onclick="alert(\'1\');"><span onclick=""></span><script>alert("1")</script>',
884+
'&quot; onclick=&quot;alert(&apos;1&apos;);&quot;&gt;&lt;span onclick=&quot;&quot;&gt;&lt;/span&gt;&lt;script&gt;alert(&quot;1&quot;)&lt;/script&gt;',
885+
),
883886
);
884887
}
885888

@@ -905,6 +908,21 @@ public function test_set_attribute_with_a_non_existing_attribute_adds_a_new_attr
905908
);
906909
}
907910

911+
/**
912+
* Ensure that attribute values that appear to contain HTML character references are correctly
913+
* encoded and preserve the original value.
914+
*
915+
* @ticket 64054
916+
*/
917+
public function test_set_attribute_encodes_html_character_references() {
918+
$original = 'HTML character references: &lt; &gt; &amp;';
919+
$processor = new WP_HTML_Tag_Processor( '<span>' );
920+
$processor->next_tag();
921+
$processor->set_attribute( 'data-attr', $original );
922+
$this->assertSame( $original, $processor->get_attribute( 'data-attr' ) );
923+
$this->assertEqualHTML( '<span data-attr="HTML character references: &amp;lt; &amp;gt; &amp;amp;">', $processor->get_updated_html() );
924+
}
925+
908926
/**
909927
* @ticket 56299
910928
*
@@ -2786,9 +2804,10 @@ public function test_updating_attributes_in_malformed_html( $html, $expected ) {
27862804
$processor->next_tag();
27872805
$processor->add_class( 'secondTag' );
27882806

2789-
$this->assertSame(
2807+
$this->assertEqualHTML(
27902808
$expected,
27912809
$processor->get_updated_html(),
2810+
'<body>',
27922811
'Did not properly update attributes and classnames given malformed input'
27932812
);
27942813
}
@@ -2806,11 +2825,11 @@ public static function data_updating_attributes_in_malformed_html() {
28062825
),
28072826
'HTML tag opening inside attribute value' => array(
28082827
'input' => '<pre id="<code" class="wp-block-code <code is poetry&gt;"><code>This &lt;is> a &lt;strong is="true">thing.</code></pre><span>test</span>',
2809-
'expected' => '<pre foo="bar" id="<code" class="wp-block-code &lt;code is poetry&gt; firstTag"><code class="secondTag">This &lt;is> a &lt;strong is="true">thing.</code></pre><span>test</span>',
2828+
'expected' => '<pre foo="bar" id="<code" class="wp-block-code &lt;code is poetry&amp;gt; firstTag"><code class="secondTag">This &lt;is> a &lt;strong is="true">thing.</code></pre><span>test</span>',
28102829
),
28112830
'HTML tag brackets in attribute values and data markup' => array(
28122831
'input' => '<pre id="<code-&gt;-block-&gt;" class="wp-block-code <code is poetry&gt;"><code>This &lt;is> a &lt;strong is="true">thing.</code></pre><span>test</span>',
2813-
'expected' => '<pre foo="bar" id="<code-&gt;-block-&gt;" class="wp-block-code &lt;code is poetry&gt; firstTag"><code class="secondTag">This &lt;is> a &lt;strong is="true">thing.</code></pre><span>test</span>',
2832+
'expected' => '<pre foo="bar" id="<code-&gt;-block-&gt;" class="wp-block-code &lt;code is poetry&amp;gt; firstTag"><code class="secondTag">This &lt;is> a &lt;strong is="true">thing.</code></pre><span>test</span>',
28142833
),
28152834
'Single and double quotes in attribute value' => array(
28162835
'input' => '<p title="Demonstrating how to use single quote (\') and double quote (&quot;)"><span>test</span>',

0 commit comments

Comments
 (0)