Make WordPress Core

Changeset 60919


Ignore:
Timestamp:
10/09/2025 11:36:10 PM (5 weeks ago)
Author:
dmsnell
Message:

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 https://github.com/WordPress/wordpress-develop/pull/10143
Discussed in https://core.trac.wordpress.org/ticket/64054

Props dmsnell, jonsurrell, westonruter.
Fixes #64054.

Location:
trunk
Files:
4 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/html-api/class-wp-html-processor.php

    r60887 r60919  
    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.
  • trunk/src/wp-includes/html-api/class-wp-html-tag-processor.php

    r60887 r60919  
    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     */
     
    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
     
    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.
     
    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.
  • trunk/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php

    r60727 r60919  
    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            ),
     
    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            ),
     
    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            ),
     
    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            ),
     
    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            ),
  • trunk/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php

    r60887 r60919  
    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();
     
    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
     
    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    }
     
    904907            'get_attribute() (called after get_updated_html()) did not return attribute added via set_attribute()'
    905908        );
     909    }
     910
     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() );
    906924    }
    907925
     
    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        );
     
    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(
Note: See TracChangeset for help on using the changeset viewer.