<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[58418] trunk: KSES: Preserve some additional invalid HTML comment syntaxes.</title>
</head>
<body>

<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt;  }
#msg dl a { font-weight: bold}
#msg dl a:link    { color:#fc3; }
#msg dl a:active  { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { white-space: pre-line; overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff  {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta" style="font-size: 105%">
<dt style="float: left; width: 6em; font-weight: bold">Revision</dt> <dd><a style="font-weight: bold" href="https://core.trac.wordpress.org/changeset/58418">58418</a><script type="application/ld+json">{"@context":"http://schema.org","@type":"EmailMessage","description":"Review this Commit","action":{"@type":"ViewAction","url":"https://core.trac.wordpress.org/changeset/58418","name":"Review Commit"}}</script></dd>
<dt style="float: left; width: 6em; font-weight: bold">Author</dt> <dd>dmsnell</dd>
<dt style="float: left; width: 6em; font-weight: bold">Date</dt> <dd>2024-06-15 06:31:24 +0000 (Sat, 15 Jun 2024)</dd>
</dl>

<pre style='padding-left: 1em; margin: 2em 0; border-left: 2px solid #ccc; line-height: 1.25; font-size: 105%; font-family: sans-serif'>KSES: Preserve some additional invalid HTML comment syntaxes.

When `wp_kses_split` processes a document it attempts to leave HTML comments
alone. It makes minor adjustments, but leaves the comments in the document in
its output. Unfortunately it only recognizes one kind of HTML comment and
rejects many others.

This patch makes a minor adjustment to the algorithm in `wp_kses_split` to
recognize and preserve an additional kind of HTML comment: closing tags with
an invalid tag name, e.g. `</%dolly>`.

These invalid closing tags must be interpreted as comments by a browser.
This bug fix aligns the implementation of `wp_kses_split()` more closely
with its stated goal of leaving HTML comments as comments.

It doesn't attempt to fully fix the mis-parsed comments, but it does propose a
minor fix that hopefully won't break any existing code or projects.

Developed in https://github.com/WordPress/wordpress-develop/pull/6395
Discussed in https://core.trac.wordpress.org/ticket/61009

Props ellatrix, dmsnell, joemcgill, jorbin, westonruter, zieladam.
See <a href="https://core.trac.wordpress.org/ticket/61009">#61009</a>.</pre>

<h3>Modified Paths</h3>
<ul>
<li><a href="#trunksrcwpincludesksesphp">trunk/src/wp-includes/kses.php</a></li>
<li><a href="#trunktestsphpunittestsksesphp">trunk/tests/phpunit/tests/kses.php</a></li>
</ul>

</div>
<div id="patch">
<h3>Diff</h3>
<a id="trunksrcwpincludesksesphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/src/wp-includes/kses.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/kses.php    2024-06-14 15:24:32 UTC (rev 58417)
+++ trunk/src/wp-includes/kses.php      2024-06-15 06:31:24 UTC (rev 58418)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -963,6 +963,7 @@
</span><span class="cx" style="display: block; padding: 0 10px">  * It also matches stray `>` characters.
</span><span class="cx" style="display: block; padding: 0 10px">  *
</span><span class="cx" style="display: block; padding: 0 10px">  * @since 1.0.0
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+ * @since 6.6.0 Recognize additional forms of invalid HTML which convert into comments.
</ins><span class="cx" style="display: block; padding: 0 10px">  *
</span><span class="cx" style="display: block; padding: 0 10px">  * @global array[]|string $pass_allowed_html      An array of allowed HTML elements and attributes,
</span><span class="cx" style="display: block; padding: 0 10px">  *                                                or a context name such as 'post'.
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -981,7 +982,18 @@
</span><span class="cx" style="display: block; padding: 0 10px">        $pass_allowed_html      = $allowed_html;
</span><span class="cx" style="display: block; padding: 0 10px">        $pass_allowed_protocols = $allowed_protocols;
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-        return preg_replace_callback( '%(<!--.*?(-->|$))|(<[^>]*(>|$)|>)%', '_wp_kses_split_callback', $content );
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+ $token_pattern = <<<REGEX
+~
+       (                      # Detect comments of various flavors before attempting to find tags.
+               (<!--.*?(-->|$))   #  - Normative HTML comments.
+               |
+               </[^a-zA-Z][^>]*>  #  - Closing tags with invalid tag names.
+       )
+       |
+       (<[^>]*(>|$)|>)        # Tag-like spans of text.
+~x
+REGEX;
+       return preg_replace_callback( $token_pattern, '_wp_kses_split_callback', $content );
</ins><span class="cx" style="display: block; padding: 0 10px"> }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px"> /**
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -1069,6 +1081,7 @@
</span><span class="cx" style="display: block; padding: 0 10px">  * @access private
</span><span class="cx" style="display: block; padding: 0 10px">  * @ignore
</span><span class="cx" style="display: block; padding: 0 10px">  * @since 1.0.0
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+ * @since 6.6.0 Recognize additional forms of invalid HTML which convert into comments.
</ins><span class="cx" style="display: block; padding: 0 10px">  *
</span><span class="cx" style="display: block; padding: 0 10px">  * @param string         $content           Content to filter.
</span><span class="cx" style="display: block; padding: 0 10px">  * @param array[]|string $allowed_html      An array of allowed HTML elements and attributes,
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -1075,17 +1088,54 @@
</span><span class="cx" style="display: block; padding: 0 10px">  *                                          or a context name such as 'post'. See wp_kses_allowed_html()
</span><span class="cx" style="display: block; padding: 0 10px">  *                                          for the list of accepted context names.
</span><span class="cx" style="display: block; padding: 0 10px">  * @param string[]       $allowed_protocols Array of allowed URL protocols.
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+ *
</ins><span class="cx" style="display: block; padding: 0 10px">  * @return string Fixed HTML element
</span><span class="cx" style="display: block; padding: 0 10px">  */
</span><span class="cx" style="display: block; padding: 0 10px"> function wp_kses_split2( $content, $allowed_html, $allowed_protocols ) {
</span><span class="cx" style="display: block; padding: 0 10px">        $content = wp_kses_stripslashes( $content );
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-        // It matched a ">" character.
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+ /*
+        * The regex pattern used to split HTML into chunks attempts
+        * to split on HTML token boundaries. This function should
+        * thus receive chunks that _either_ start with meaningful
+        * syntax tokens, like a tag `<div>` or a comment `<!-- ... -->`.
+        *
+        * If the first character of the `$content` chunk _isn't_ one
+        * of these syntax elements, which always starts with `<`, then
+        * the match had to be for the final alternation of `>`. In such
+        * case, it's probably standing on its own and could be encoded
+        * with a character reference to remove ambiguity.
+        *
+        * In other words, if this chunk isn't from a match of a syntax
+        * token, it's just a plaintext greater-than (`>`) sign.
+        */
</ins><span class="cx" style="display: block; padding: 0 10px">         if ( ! str_starts_with( $content, '<' ) ) {
</span><span class="cx" style="display: block; padding: 0 10px">                return '&gt;';
</span><span class="cx" style="display: block; padding: 0 10px">        }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-        // Allow HTML comments.
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+ /*
+        * When a closing tag appears with a name that isn't a valid tag name,
+        * it must be interpreted as an HTML comment. It extends until the
+        * first `>` character after the initial opening `</`.
+        *
+        * Preserve these comments and do not treat them like tags.
+        */
+       if ( 1 === preg_match( '~^</[^a-zA-Z][^>]*>$~', $content ) ) {
+               $content     = substr( $content, 2, -1 );
+               $transformed = null;
+
+               while ( $transformed !== $content ) {
+                       $transformed = wp_kses( $content, $allowed_html, $allowed_protocols );
+                       $content     = $transformed;
+               }
+
+               return "</{$transformed}>";
+       }
+
+       /*
+        * Normative HTML comments should be handled separately as their
+        * parsing rules differ from those for tags and text nodes.
+        */
</ins><span class="cx" style="display: block; padding: 0 10px">         if ( str_starts_with( $content, '<!--' ) ) {
</span><span class="cx" style="display: block; padding: 0 10px">                $content = str_replace( array( '<!--', '-->' ), '', $content );
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span></span></pre></div>
<a id="trunktestsphpunittestsksesphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/tests/phpunit/tests/kses.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/kses.php        2024-06-14 15:24:32 UTC (rev 58417)
+++ trunk/tests/phpunit/tests/kses.php  2024-06-15 06:31:24 UTC (rev 58418)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -1932,6 +1932,35 @@
</span><span class="cx" style="display: block; padding: 0 10px">        }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">        /**
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+         * Ensures that `wp_kses()` preserves various kinds of HTML comments, both valid and invalid.
+        *
+        * @ticket 61009
+        *
+        * @param string $html_comment    HTML containing a comment; must not be a valid comment
+        *                                but must be syntax which a browser interprets as a comment.
+        * @param string $expected_output How `wp_kses()` ought to transform the comment.
+        */
+       public function wp_kses_preserves_html_comments( $html_comment, $expected_output ) {
+               $this->assertSame(
+                       $expected_output,
+                       wp_kses( $html_comment, array() ),
+                       'Failed to properly preserve HTML comment.'
+               );
+       }
+
+       /**
+        * Data provider.
+        *
+        * @return array[].
+        */
+       public static function data_html_containing_various_kinds_of_html_comments() {
+               return array(
+                       'Normative HTML comment'            => array( 'before<!-- this is a comment -->after', 'before<!-- this is a comment -->after' ),
+                       'Closing tag with invalid tag name' => array( 'before<//not a tag>after', 'before<//not a tag>after' ),
+               );
+       }
+
+       /**
</ins><span class="cx" style="display: block; padding: 0 10px">          * Test that attributes with a list of allowed values are filtered correctly.
</span><span class="cx" style="display: block; padding: 0 10px">         *
</span><span class="cx" style="display: block; padding: 0 10px">         * @ticket 54261
</span></span></pre>
</div>
</div>

</body>
</html>