Skip to content

HTML API: Escape syntax characters in RCDATA - #13327

Draft
dmsnell wants to merge 1 commit into
WordPress:trunkfrom
dmsnell:html-api/escape-rcdata
Draft

HTML API: Escape syntax characters in RCDATA#13327
dmsnell wants to merge 1 commit into
WordPress:trunkfrom
dmsnell:html-api/escape-rcdata

Conversation

@dmsnell

@dmsnell dmsnell commented Aug 31, 2026

Copy link
Copy Markdown
Member

Status

  • Add some tests to demonstrate behavior.

Description

Characters in TITLE and TEXTAREA aren’t required to be escaped, but doing so might prevent downstream parsers from mis-parsing content inside of these elements as markup, when in fact they are plaintext.

This patch escapes the content by default to prevent such a scenario.

@dmsnell
dmsnell force-pushed the html-api/escape-rcdata branch from c563b48 to c3de662 Compare August 31, 2026 05:01
@dmsnell
dmsnell force-pushed the html-api/escape-rcdata branch from c3de662 to 686dc5e Compare August 31, 2026 05:02
@dmsnell
dmsnell requested review from sirreal and westonruter August 31, 2026 05:03
@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Comment on lines +4164 to +4171
$plaintext_content = strtr(
$plaintext_content,
array(
'<' => '&lt;',
'&' => '&amp;',
'>' => '&gt;',
)
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks right: https://3v4l.org/0Kf28#veol

<title>
&lt;&amp;&gt;: &amp;lt;&amp;amp;&amp;gt;
</title>

Parsed as:

<&>: &lt;&amp;&gt;


case 'TEXTAREA':
case 'TITLE':
$plaintext_content = strtr(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment is probably warranted here to say that these replacements are not required for a spec-complaint HTML parser. But they are done for the sake of other parsers, including DOMDocument.

I'm surprised to see that DOMDocument parses elements as children of TITLE! Happily this is fixed in Dom\HTMLDocument: https://3v4l.org/oDUI3#veol

Test code
<?php
$html = '
  <!DOCTYPE html>
  <html>
   <head>
    <meta charset="utf-8">  
    <title>We <em>love</em> HTML!</title>
   </head>
   <body>
   </body>
  </html>
';

function run_tests( $document ) {
    $title = $document->getElementsByTagName( 'title' )->item( 0 );
    if ( $title->childNodes->length !== 1 ) {
        echo "FAIL: Title tag does not have the the expected single text child. Has {$title->childNodes->length} children.\n";
    } else {
        echo "PASS: Title tag only has a single child.\n";
    }
    if ( $title->getElementsByTagName( 'em' )->length !== 0 ) {
      echo "FAIL: Title tag unexpectedly has a parsed EM child.\n";
    } else {
      echo "PASS: Title tag has no child EM element.\n";  
    }
    if ( str_contains( $title->textContent, '<em>' ) ) {
        echo "PASS: HTML tag <em> was not parsed. Text content: {$title->textContent}\n";
    } else {
        echo "FAIL: HTML tag <em> was parsed. Text content: {$title->textContent}\n";
    }
}

echo "# DOMDocument:\n";
$old_document = new DOMDocument();
$old_document->loadHTML( $html );
run_tests( $old_document );

echo "\n";
echo "# Dom\HTMLDocument:\n";
if ( class_exists( Dom\HTMLDocument::class ) ) {
    $new_document = Dom\HTMLDocument::createFromString( $html );
    run_tests( $new_document );
} else {
    echo "(Not available)\n";
}

@sirreal sirreal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably an improvement overall for anything inspecting HTML downstream.

Assume HTML like: <title><foo>&lt;bar&gt;</title>

There are two obvious ways to mis-parse this:

  • <foo> treated as a start tag (it's the text <foo>)
  • &lt;bar&gt; not decoded (it's the text <bar>).

This change would eliminate the first category <foo>, while leaving the second category &lt;bar&gt; exactly the same. The tradeoff here is that naive parsers are more likely to confuse the text <foo> for an element, and if they don't correctly handle decoding… well that would already be broken.

I'm in favor of this, it's perfectly valid and correct HTML and simplifies the surface area of HTML trivia that needs to be correctly implemented downstream.


In some related CSS work, I made a similar decision. It's best to remove possibly confusing syntax characters entirely where escaping is possible.

Comment on lines 4173 to 4179
$plaintext_content = preg_replace_callback(
"~</(?P<TAG_NAME>{$this->get_tag()})~i",
static function ( $tag_match ) {
return "&lt;/{$tag_match['TAG_NAME']}";
},
$plaintext_content
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This becomes redundant if we're already escaping <>& syntax characters.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants