<!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>[58829] trunk: HTML API: Add set_modifiable_text() for replacing text nodes.</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/58829">58829</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/58829","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-07-29 17:57:12 +0000 (Mon, 29 Jul 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'>HTML API: Add set_modifiable_text() for replacing text nodes.

This patch introduces a new method, `set_modifiable_text()` to the
Tag Processor, which makes it possible and safe to replace text nodes
within an HTML document, performing the appropriate escaping.

This method can be used in conjunction with other code to modify the
text content of a document, and can be used for transforming HTML
in a streaming fashion.

Developed in https://github.com/wordpress/wordpress-develop/pull/7007
Discussed in https://core.trac.wordpress.org/ticket/61617

Props: dmsnell, gziolo, zieladam.
Fixes <a href="https://core.trac.wordpress.org/ticket/61617">#61617</a>.</pre>

<h3>Modified Paths</h3>
<ul>
<li><a href="#trunksrcwpincludeshtmlapiclasswphtmltagprocessorphp">trunk/src/wp-includes/html-api/class-wp-html-tag-processor.php</a></li>
<li><a href="#trunktestsphpunittestshtmlapiwpHtmlTagProcessorModifiableTextphp">trunk/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php</a></li>
</ul>

</div>
<div id="patch">
<h3>Diff</h3>
<a id="trunksrcwpincludeshtmlapiclasswphtmltagprocessorphp"></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/html-api/class-wp-html-tag-processor.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/html-api/class-wp-html-tag-processor.php    2024-07-29 17:37:48 UTC (rev 58828)
+++ trunk/src/wp-includes/html-api/class-wp-html-tag-processor.php      2024-07-29 17:57:12 UTC (rev 58829)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -2889,7 +2889,9 @@
</span><span class="cx" style="display: block; padding: 0 10px">                        return '';
</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">-                $text = substr( $this->html, $this->text_starts_at, $this->text_length );
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+         $text = isset( $this->lexical_updates['modifiable text'] )
+                       ? $this->lexical_updates['modifiable text']->text
+                       : substr( $this->html, $this->text_starts_at, $this->text_length );
</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">                 * Pre-processing the input stream would normally happen before
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -2957,6 +2959,157 @@
</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">+         * Sets the modifiable text for the matched token, if matched.
+        *
+        * Modifiable text is text content that may be read and changed without
+        * changing the HTML structure of the document around it. This includes
+        * the contents of `#text` nodes in the HTML as well as the inner
+        * contents of HTML comments, Processing Instructions, and others, even
+        * though these nodes aren't part of a parsed DOM tree. They also contain
+        * the contents of SCRIPT and STYLE tags, of TEXTAREA tags, and of any
+        * other section in an HTML document which cannot contain HTML markup (DATA).
+        *
+        * Not all modifiable text may be set by this method, and not all content
+        * may be set as modifiable text. In the case that this fails it will return
+        * `false` indicating as much. For instance, it will not allow inserting the
+        * string `</script` into a SCRIPT element, because the rules for escaping
+        * that safely are complicated. Similarly, it will not allow setting content
+        * into a comment which would prematurely terminate the comment.
+        *
+        * Example:
+        *
+        *     // Add a preface to all STYLE contents.
+        *     while ( $processor->next_tag( 'STYLE' ) ) {
+        *         $style = $processor->get_modifiable_text();
+        *         $processor->set_modifiable_text( "// Made with love on the World Wide Web\n{$style}" );
+        *     }
+        *
+        *     // Replace smiley text with Emoji smilies.
+        *     while ( $processor->next_token() ) {
+        *         if ( '#text' !== $processor->get_token_name() ) {
+        *             continue;
+        *         }
+        *
+        *         $chunk = $processor->get_modifiable_text();
+        *         if ( ! str_contains( $chunk, ':)' ) ) {
+        *             continue;
+        *         }
+        *
+        *         $processor->set_modifiable_text( str_replace( ':)', '🙂', $chunk ) );
+        *     }
+        *
+        * @since 6.7.0
+        *
+        * @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 {
+               if ( self::STATE_TEXT_NODE === $this->parser_state ) {
+                       $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement(
+                               $this->text_starts_at,
+                               $this->text_length,
+                               htmlspecialchars( $plaintext_content, ENT_QUOTES | ENT_HTML5 )
+                       );
+
+                       return true;
+               }
+
+               // Comment data is not encoded.
+               if (
+                       self::STATE_COMMENT === $this->parser_state &&
+                       self::COMMENT_AS_HTML_COMMENT === $this->comment_type
+               ) {
+                       // Check if the text could close the comment.
+                       if ( 1 === preg_match( '/--!?>/', $plaintext_content ) ) {
+                               return false;
+                       }
+
+                       $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement(
+                               $this->text_starts_at,
+                               $this->text_length,
+                               $plaintext_content
+                       );
+
+                       return true;
+               }
+
+               if ( self::STATE_MATCHED_TAG !== $this->parser_state ) {
+                       return false;
+               }
+
+               switch ( $this->get_tag() ) {
+                       case 'SCRIPT':
+                               /*
+                                * This is over-protective, but ensures the update doesn't break
+                                * out of the SCRIPT element. A more thorough check would need to
+                                * ensure that the script closing tag doesn't exist, and isn't
+                                * also "hidden" inside the script double-escaped state.
+                                *
+                                * It may seem like replacing `</script` with `<\/script` would
+                                * properly escape these things, but this could mask regex patterns
+                                * that previously worked. Resolve this by not sending `</script`
+                                */
+                               if ( false !== stripos( $plaintext_content, '</script' ) ) {
+                                       return false;
+                               }
+
+                               $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement(
+                                       $this->text_starts_at,
+                                       $this->text_length,
+                                       $plaintext_content
+                               );
+
+                               return true;
+
+                       case 'STYLE':
+                               $plaintext_content = preg_replace_callback(
+                                       '~</(?P<TAG_NAME>style)~i',
+                                       static function ( $tag_match ) {
+                                               return "\\3c\\2f{$tag_match['TAG_NAME']}";
+                                       },
+                                       $plaintext_content
+                               );
+
+                               $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement(
+                                       $this->text_starts_at,
+                                       $this->text_length,
+                                       $plaintext_content
+                               );
+
+                               return true;
+
+                       case 'TEXTAREA':
+                       case 'TITLE':
+                               $plaintext_content = preg_replace_callback(
+                                       "~</(?P<TAG_NAME>{$this->get_tag()})~i",
+                                       static function ( $tag_match ) {
+                                               return "&lt;/{$tag_match['TAG_NAME']}";
+                                       },
+                                       $plaintext_content
+                               );
+
+                               /*
+                                * These don't _need_ to be escaped, but since they are decoded it's
+                                * safe to leave them escaped and this can prevent other code from
+                                * naively detecting tags within the contents.
+                                *
+                                * @todo It would be useful to prefix a multiline replacement text
+                                *       with a newline, but not necessary. This is for aesthetics.
+                                */
+                               $this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement(
+                                       $this->text_starts_at,
+                                       $this->text_length,
+                                       $plaintext_content
+                               );
+
+                               return true;
+               }
+
+               return false;
+       }
+
+       /**
</ins><span class="cx" style="display: block; padding: 0 10px">          * Updates or creates a new attribute on the currently matched tag with the passed value.
</span><span class="cx" style="display: block; padding: 0 10px">         *
</span><span class="cx" style="display: block; padding: 0 10px">         * For boolean attributes special handling is provided:
</span></span></pre></div>
<a id="trunktestsphpunittestshtmlapiwpHtmlTagProcessorModifiableTextphp"></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/html-api/wpHtmlTagProcessorModifiableText.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php   2024-07-29 17:37:48 UTC (rev 58828)
+++ trunk/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php     2024-07-29 17:57:12 UTC (rev 58829)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -40,6 +40,90 @@
</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 updates to modifiable text that are shorter than the
+        * original text do not cause the parser to lose its orientation.
+        *
+        * @ticket 61617
+        */
+       public function test_setting_shorter_modifiable_text() {
+               $processor = new WP_HTML_Tag_Processor( '<div><textarea>very long text</textarea><div id="not a <span>">' );
+
+               // Find the test node in the middle.
+               while ( 'TEXTAREA' !== $processor->get_token_name() && $processor->next_token() ) {
+                       continue;
+               }
+
+               $this->assertSame(
+                       'TEXTAREA',
+                       $processor->get_token_name(),
+                       'Failed to find the test TEXTAREA node; check the test setup.'
+               );
+
+               $processor->set_modifiable_text( 'short' );
+               $processor->get_updated_html();
+               $this->assertSame(
+                       'short',
+                       $processor->get_modifiable_text(),
+                       'Should have updated modifiable text to something shorter than the original.'
+               );
+
+               $this->assertTrue(
+                       $processor->next_token(),
+                       'Should have advanced to the last token in the input.'
+               );
+
+               $this->assertSame(
+                       'DIV',
+                       $processor->get_token_name(),
+                       'Should have recognized the final DIV in the input.'
+               );
+
+               $this->assertSame(
+                       'not a <span>',
+                       $processor->get_attribute( 'id' ),
+                       'Should have read in the id from the last DIV as "not a <span>"'
+               );
+       }
+
+       /**
+        * Ensures that reads to modifiable text after setting it reads the updated
+        * enqueued values, and not the original value.
+        *
+        * @ticket 61617
+        */
+       public function test_modifiable_text_reads_updates_after_setting() {
+               $processor = new WP_HTML_Tag_Processor( 'This is text<!-- this is not -->' );
+
+               $processor->next_token();
+               $this->assertSame(
+                       '#text',
+                       $processor->get_token_name(),
+                       'Failed to find first text node: check test setup.'
+               );
+
+               $update = 'This is new text';
+               $processor->set_modifiable_text( $update );
+               $this->assertSame(
+                       $update,
+                       $processor->get_modifiable_text(),
+                       'Failed to read updated enqueued value of text node.'
+               );
+
+               $processor->next_token();
+               $this->assertSame(
+                       '#comment',
+                       $processor->get_token_name(),
+                       'Failed to advance to comment: check test setup.'
+               );
+
+               $this->assertSame(
+                       ' this is not ',
+                       $processor->get_modifiable_text(),
+                       'Failed to read modifiable text for next token; did it read the old enqueued value from the previous token?'
+               );
+       }
+
+       /**
</ins><span class="cx" style="display: block; padding: 0 10px">          * Ensures that when ignoring a newline after LISTING and PRE tags, that this
</span><span class="cx" style="display: block; padding: 0 10px">         * happens appropriately after seeking.
</span><span class="cx" style="display: block; padding: 0 10px">         */
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -108,4 +192,155 @@
</span><span class="cx" style="display: block; padding: 0 10px">                        'Should not have removed the leading newline from the last DIV on its second traversal.'
</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 modifiable text updates are not applied where they aren't supported.
+        *
+        * @ticket 61617
+        *
+        * @dataProvider data_tokens_not_supporting_modifiable_text_updates
+        *
+        * @param string $html             Contains HTML with a token not supporting modifiable text updates.
+        * @param int    $advance_n_tokens Count of times to run `next_token()` before reaching target node.
+        */
+       public function test_rejects_updates_on_unsupported_match_locations( string $html, int $advance_n_tokens ) {
+               $processor = new WP_HTML_Tag_Processor( $html );
+               while ( --$advance_n_tokens >= 0 ) {
+                       $processor->next_token();
+               }
+
+               $this->assertFalse(
+                       $processor->set_modifiable_text( 'Bazinga!' ),
+                       'Should have prevented modifying the text at the target node.'
+               );
+
+               $this->assertSame(
+                       $html,
+                       $processor->get_updated_html(),
+                       'Should not have modified the input document in any way.'
+               );
+       }
+
+       /**
+        * Data provider.
+        *
+        * @return array[]
+        */
+       public static function data_tokens_not_supporting_modifiable_text_updates() {
+               return array(
+                       'Before parsing'               => array( 'nothing to see here', 0 ),
+                       'After parsing'                => array( 'nothing here either', 2 ),
+                       'Incomplete document'          => array( '<tag without="an end', 1 ),
+                       'Presumptuous closer'          => array( 'before</>after', 2 ),
+                       'Invalid (CDATA)'              => array( '<![CDATA[this is a comment]]>', 1 ),
+                       'Invalid (shortest comment)'   => array( '<!-->', 1 ),
+                       'Invalid (shorter comment)'    => array( '<!--->', 1 ),
+                       'Invalid (markup declaration)' => array( '<!run>', 1 ),
+                       'Invalid (PI-like node)'       => array( '<?xml is not html ?>', 1 ),
+               );
+       }
+
+       /**
+        * Ensures that modifiable text updates are applied as expected to supported nodes.
+        *
+        * @ticket 61617
+        *
+        * @dataProvider data_tokens_with_basic_modifiable_text_updates
+        *
+        * @param string $html             Contains HTML with a token supporting modifiable text updates.
+        * @param int    $advance_n_tokens Count of times to run `next_token()` before reaching target node.
+        * @param string $raw_replacement  This should be escaped properly when replaced as modifiable text.
+        * @param string $transformed      Expected output after updating modifiable text.
+        */
+       public function test_updates_basic_modifiable_text_on_supported_nodes( string $html, int $advance_n_tokens, string $raw_replacement, string $transformed ) {
+               $processor = new WP_HTML_Tag_Processor( $html );
+               while ( --$advance_n_tokens >= 0 ) {
+                       $processor->next_token();
+               }
+
+               $this->assertTrue(
+                       $processor->set_modifiable_text( $raw_replacement ),
+                       'Should have modified the text at the target node.'
+               );
+
+               $this->assertSame(
+                       $transformed,
+                       $processor->get_updated_html(),
+                       "Should have transformed the HTML as expected why modifying the target node's modifiable text."
+               );
+       }
+
+       /**
+        * Data provider.
+        *
+        * @return array[]
+        */
+       public static function data_tokens_with_basic_modifiable_text_updates() {
+               return array(
+                       'Text node (start)'       => array( 'Text', 1, 'Blubber', 'Blubber' ),
+                       'Text node (middle)'      => array( '<em>Bold move</em>', 2, 'yo', '<em>yo</em>' ),
+                       'Text node (end)'         => array( '<img>of a dog', 2, 'of a cat', '<img>of a cat' ),
+                       'Encoded text node'       => array( '<figcaption>birds and dogs</figcaption>', 2, '<birds> & <dogs>', '<figcaption>&lt;birds&gt; &amp; &lt;dogs&gt;</figcaption>' ),
+                       'SCRIPT tag'              => array( 'before<script></script>after', 2, 'const img = "<img> & <br>";', 'before<script>const img = "<img> & <br>";</script>after' ),
+                       'STYLE tag'               => array( '<style></style>', 1, 'p::before { content: "<img> & </style>"; }', '<style>p::before { content: "<img> & \3c\2fstyle>"; }</style>' ),
+                       'TEXTAREA tag'            => array( 'a<textarea>has no need to escape</textarea>b', 2, "so it <doesn't>", "a<textarea>so it <doesn't></textarea>b" ),
+                       'TEXTAREA (escape)'       => array( 'a<textarea>has no need to escape</textarea>b', 2, 'but it does for </textarea>', 'a<textarea>but it does for &lt;/textarea></textarea>b' ),
+                       'TEXTAREA (escape+attrs)' => array( 'a<textarea>has no need to escape</textarea>b', 2, 'but it does for </textarea not an="attribute">', 'a<textarea>but it does for &lt;/textarea not an="attribute"></textarea>b' ),
+                       'TITLE tag'               => array( 'a<title>has no need to escape</title>b', 2, "so it <doesn't>", "a<title>so it <doesn't></title>b" ),
+                       'TITLE (escape)'          => array( 'a<title>has no need to escape</title>b', 2, 'but it does for </title>', 'a<title>but it does for &lt;/title></title>b' ),
+                       'TITLE (escape+attrs)'    => array( 'a<title>has no need to escape</title>b', 2, 'but it does for </title not an="attribute">', 'a<title>but it does for &lt;/title not an="attribute"></title>b' ),
+               );
+       }
+
+       /**
+        * Ensures that updates with potentially-compromising values aren't accepted.
+        *
+        * For example, a modifiable text update should be allowed which would break
+        * the structure of the containing element, such as in a script or comment.
+        *
+        * @ticket 61617
+        *
+        * @dataProvider data_unallowed_modifiable_text_updates
+        *
+        * @param string $html_with_nonempty_modifiable_text Will be used to find the test element.
+        * @param string $invalid_update                     Update containing possibly-compromising text.
+        */
+       public function test_rejects_updates_with_unallowed_substrings( string $html_with_nonempty_modifiable_text, string $invalid_update ) {
+               $processor = new WP_HTML_Tag_Processor( $html_with_nonempty_modifiable_text );
+
+               while ( '' === $processor->get_modifiable_text() && $processor->next_token() ) {
+                       continue;
+               }
+
+               $original_text = $processor->get_modifiable_text();
+               $this->assertNotEmpty( $original_text, 'Should have found non-empty text: check test setup.' );
+
+               $this->assertFalse(
+                       $processor->set_modifiable_text( $invalid_update ),
+                       'Should have reject possibly-compromising modifiable text update.'
+               );
+
+               // Flush updates.
+               $processor->get_updated_html();
+
+               $this->assertSame(
+                       $original_text,
+                       $processor->get_modifiable_text(),
+                       'Should have preserved the original modifiable text before the rejected update.'
+               );
+       }
+
+       /**
+        * Data provider.
+        *
+        * @return array[]
+        */
+       public static function data_unallowed_modifiable_text_updates() {
+               return array(
+                       'Comment with -->'                 => array( '<!-- this is a comment -->', 'Comments end in -->' ),
+                       'Comment with --!>'                => array( '<!-- this is a comment -->', 'Invalid but legitimate comments end in --!>' ),
+                       'SCRIPT with </script>'            => array( '<script>Replace me</script>', 'Just a </script>' ),
+                       'SCRIPT with </script attributes>' => array( '<script>Replace me</script>', 'before</script id=sneak>after' ),
+               );
+       }
</ins><span class="cx" style="display: block; padding: 0 10px"> }
</span></span></pre>
</div>
</div>

</body>
</html>