Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 127 additions & 45 deletions src/wp-includes/html-api/class-wp-html-tag-processor.php
Original file line number Diff line number Diff line change
Expand Up @@ -3978,8 +3978,7 @@ public function set_modifiable_text( string $plaintext_content ): bool {
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 ) ) {
if ( self::has_comment_closer( $plaintext_content ) ) {
_doing_it_wrong(
__METHOD__,
__( 'Comment text cannot contain a comment closer.' ),
Expand Down Expand Up @@ -4093,14 +4092,13 @@ public function set_modifiable_text( string $plaintext_content ): bool {
* start tag which follows `<!--` moves the tokenizer into the double-escaped
* states, where a later `</script>` no longer closes the element.
*
* In both cases the tag name ends only at one of the characters matched
* below, so text such as `</scriptx>` cannot change that structure and is
* safe to set.
* In both cases the tag name ends only at whitespace, "/" or ">", so text
* such as `</scriptx>` cannot change that structure and is safe to set.
*
* @link https://html.spec.whatwg.org/#script-data-end-tag-name-state
* @link https://html.spec.whatwg.org/#script-data-double-escape-start-state
*/
if ( 1 === preg_match( '~</?script[ \t\f\r\n/>]~i', $plaintext_content ) ) {
if ( array() !== self::find_tag_names( $plaintext_content, 'script', true ) ) {
_doing_it_wrong(
__METHOD__,
__( 'SCRIPT text with an unrecognized content type cannot contain a SCRIPT tag. Apply the escaping appropriate for the content type.' ),
Expand All @@ -4122,12 +4120,12 @@ public function set_modifiable_text( string $plaintext_content ): bool {
$tag_name = $this->get_tag();

/*
* A tag name ends only at one of the characters matched below, so text
* such as `</xmp-tag>` cannot close the element and is safe to set.
* A tag name ends only at whitespace, "/" or ">", so text such as
* `</xmp-tag>` cannot close the element and is safe to set.
*
* @link https://html.spec.whatwg.org/#rawtext-end-tag-name-state
*/
if ( 1 === preg_match( '~</' . preg_quote( $tag_name, '~' ) . '[ \t\f\r\n/>]~i', $plaintext_content ) ) {
if ( array() !== self::find_tag_names( $plaintext_content, $tag_name, false ) ) {
_doing_it_wrong(
__METHOD__,
sprintf(
Expand All @@ -4149,18 +4147,10 @@ public function set_modifiable_text( string $plaintext_content ): bool {
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
self::escape_style_contents( $plaintext_content )
);

return true;
Expand Down Expand Up @@ -4516,53 +4506,145 @@ private function get_script_content_type(): ?string {
* @return string Escaped form of input contents which will not lead to premature closing of the containing SCRIPT element.
*/
private static function escape_javascript_script_contents( string $sourcecode ): string {
$at = 0;
$was_at = 0;
$end = strlen( $sourcecode );
$escaped = '';
$was_at = 0;

/*
* Replace all instances of the ASCII case-insensitive match of "<script"
* and "</script", when followed by whitespace or "/" or ">", by using a
* character replacement for the "s" (or the "S").
*/
while ( $at < $end ) {
$tag_at = strpos( $sourcecode, '<', $at );
if ( false === $tag_at ) {
break;
}
foreach ( self::find_tag_names( $sourcecode, 'script', true ) as $at ) {
$escaped .= substr( $sourcecode, $was_at, $at - $was_at );
$escaped .= 's' === $sourcecode[ $at ] ? '\u0073' : '\u0053';
$was_at = $at + 1;
}

$tag_name_at = $tag_at + 1;
$has_closing_slash = $tag_name_at < $end && '/' === $sourcecode[ $tag_name_at ];
$tag_name_at += $has_closing_slash ? 1 : 0;
if ( 0 === $was_at ) {
return $sourcecode;
}

if ( 0 !== substr_compare( $sourcecode, 'script', $tag_name_at, 6, true ) ) {
$at = $tag_at + 1;
continue;
}
$escaped .= substr( $sourcecode, $was_at );
return $escaped;
}

if ( 1 !== strspn( $sourcecode, " \t\f\r\n/>", $tag_name_at + 6, 1 ) ) {
$at = $tag_name_at + 6;
continue;
/**
* Indicates whether comment text holds a sequence which would end the comment.
*
* A comment ends at "-->" or "--!>", or at ">" or "->" right after "<!--".
*
* @link https://html.spec.whatwg.org/#comment-start-state
*
* @since 7.2.0
* @ignore
*
* @param string $text Comment text.
* @return bool Whether the text would end the comment.
*/
private static function has_comment_closer( string $text ): bool {
$at = 0;
while ( false !== ( $gt_at = strpos( $text, '>', $at ) ) ) {
$at = $gt_at + 1;
if (
0 === $gt_at ||
( 1 === $gt_at && '-' === $text[0] ) ||
( $gt_at >= 2 && '-' === $text[ $gt_at - 1 ] && '-' === $text[ $gt_at - 2 ] ) ||
( $gt_at >= 3 && '!' === $text[ $gt_at - 1 ] && '-' === $text[ $gt_at - 2 ] && '-' === $text[ $gt_at - 3 ] )
) {
return true;
}

$escaped .= substr( $sourcecode, $was_at, $tag_name_at - $was_at );
$escaped .= 's' === $sourcecode[ $tag_name_at ] ? '\u0073' : '\u0053';
$was_at = $tag_name_at + 1;
$at = $tag_name_at + 7;
}

if ( '' === $escaped ) {
return $sourcecode;
return false;
}

/**
* Escape style tag contents.
*
* Prevent CSS text from modifying the HTML structure of a document and
* ensure that it's contained within its enclosing STYLE tag as intended.
*
* @since 7.2.0
* @ignore
*
* @param string $text Raw contents intended to be serialized into an HTML STYLE element.
* @return string Escaped form of input contents which will not lead to premature closing of the containing STYLE element.
*/
private static function escape_style_contents( string $text ): string {
$escaped = '';
$was_at = 0;

/*
* Replace all instances of the ASCII case-insensitive match of "</style"
* when followed by whitespace or "/" or ">", by using a CSS Unicode
* escape sequence for the "s" (or the "S").
*
* CSS Unicode escape sequences will terminate at the first non-hexadecimal,
* so the `t` character in `style` ensures that a Unicode escape sequence
* like `\73t` is correctly interpreted as `st`.
*/
foreach ( self::find_tag_names( $text, 'style', false ) as $at ) {
$escaped .= substr( $text, $was_at, $at - $was_at );
$escaped .= 's' === $text[ $at ] ? '\73' : '\53';
$was_at = $at + 1;
}

if ( $was_at < $end ) {
$escaped .= substr( $sourcecode, $was_at );
if ( 0 === $was_at ) {
return $text;
}

$escaped .= substr( $text, $was_at );
return $escaped;
}

/**
* Finds each tag name in raw text which the tokenizer would read as a tag
* of the given name.
*
* A tag name matches after "</", or also after "<" when start tags count,
* in any mix of ASCII case, when whitespace, "/" or ">" follows it.
*
* Example:
*
* array( 1, 10 ) === self::find_tag_names( '<script></SCRIPT/>', 'script', true );
* array( 10 ) === self::find_tag_names( '<script></SCRIPT/>', 'script', false );
* array() === self::find_tag_names( '</scripts>', 'script', false );
*
* @since 7.2.0
* @ignore
*
* @param string $text Raw text.
* @param string $tag_name Tag name, in any case.
* @param bool $start_tags_too Whether a start tag matches as well as an end tag.
* @return int[] Byte offset of the first letter of each matching tag name.
*/
private static function find_tag_names( string $text, string $tag_name, bool $start_tags_too ): array {
$found = array();
$opener = $start_tags_too ? '<' : '</';
$length = strlen( $tag_name );
$end = strlen( $text );
$at = 0;

while ( false !== ( $tag_at = strpos( $text, $opener, $at ) ) ) {
$at = $tag_at + strlen( $opener );
if ( $start_tags_too && $at < $end && '/' === $text[ $at ] ) {
++$at;
}

// Tag names match ASCII case-insensitively. The first letter is compared before the name is copied.
if (
$at < $end &&
0 === strncasecmp( $text[ $at ], $tag_name, 1 ) &&
0 === strncasecmp( substr( $text, $at, $length ), $tag_name, $length ) &&
1 === strspn( $text, " \t\f\r\n/>", $at + $length, 1 )
) {
$found[] = $at;
}
}

return $found;
}

/**
* Updates or creates a new attribute on the currently matched tag with the passed value.
*
Expand Down
2 changes: 1 addition & 1 deletion tests/phpunit/tests/customize/custom-css-setting.php
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ public function test_wp_custom_css_cb_escapes_dangerous_html() {
$expected =
<<<'HTML'
<style id="wp-custom-css">
*::before { content: "\3c\2fstyle><script>alert(1)</script>"; }
*::before { content: "</\73tyle><script>alert(1)</script>"; }
</style>

HTML;
Expand Down
85 changes: 67 additions & 18 deletions tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php
Original file line number Diff line number Diff line change
Expand Up @@ -429,24 +429,30 @@ public function test_updates_basic_modifiable_text_on_supported_nodes( string $h
*/
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>' ),
'PI node' => array( 'before<?wp-bit data?>after', 2, 'other', 'before<?wp-bit other?>after' ),
'PI node (no separator)' => array( '<?wp-bit?>', 1, '{"just": "kidding"}', '<?wp-bit {"just": "kidding"}?>' ),
'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>' ),
'IFRAME tag' => array( 'before<iframe>old content</iframe>after', 2, '<p>raw & text</p>', 'before<iframe><p>raw & text</p></iframe>after' ),
'NOEMBED tag' => array( 'before<noembed>old content</noembed>after', 2, '<p>raw & text</p>', 'before<noembed><p>raw & text</p></noembed>after' ),
'NOFRAMES tag' => array( 'before<noframes>old content</noframes>after', 2, '<p>raw & text</p>', 'before<noframes><p>raw & text</p></noframes>after' ),
'XMP tag' => array( 'before<xmp>old content</xmp>after', 2, '<p>raw & text</p>', 'before<xmp><p>raw & text</p></xmp>after' ),
'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' ),
'Text node (start)' => array( 'Text', 1, 'Blubber', 'Blubber' ),
'Text node (middle)' => array( '<em>Bold move</em>', 2, 'yo', '<em>yo</em>' ),
'PI node' => array( 'before<?wp-bit data?>after', 2, 'other', 'before<?wp-bit other?>after' ),
'PI node (no separator)' => array( '<?wp-bit?>', 1, '{"just": "kidding"}', '<?wp-bit {"just": "kidding"}?>' ),
'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>' ),
'IFRAME tag' => array( 'before<iframe>old content</iframe>after', 2, '<p>raw & text</p>', 'before<iframe><p>raw & text</p></iframe>after' ),
'NOEMBED tag' => array( 'before<noembed>old content</noembed>after', 2, '<p>raw & text</p>', 'before<noembed><p>raw & text</p></noembed>after' ),
'NOFRAMES tag' => array( 'before<noframes>old content</noframes>after', 2, '<p>raw & text</p>', 'before<noframes><p>raw & text</p></noframes>after' ),
'XMP tag' => array( 'before<xmp>old content</xmp>after', 2, '<p>raw & text</p>', 'before<xmp><p>raw & text</p></xmp>after' ),
'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> & </\73tyle>"; }</style>' ),
'STYLE tag (mixed casing)' => array( '<style></style>', 1, 'p::before { content: "<img> & </StYlE>"; }', '<style>p::before { content: "<img> & </\53tYlE>"; }</style>' ),
'STYLE tag (trailing characters)' => array( '<style></style>', 1, "p::before { content: \"<img> & </style\t>\"; }", "<style>p::before { content: \"<img> & </\\73tyle\t>\"; }</style>" ),
'STYLE tag (non-closing tag)' => array( '<style></style>', 1, 'p::before { content: "<img> & </stylesheet>"; }', '<style>p::before { content: "<img> & </stylesheet>"; }</style>' ),
'STYLE tag (repeats)' => array( '<style></style>', 1, '*{ content: "</style></STYLE></style</style>" }', '<style>*{ content: "</\73tyle></\53TYLE></style</\73tyle>" }</style>' ),
'STYLE tag (terminators)' => array( '<style></style>', 1, "*{ content: \"</style </style\f</style\r</style\n</style/\" }", "<style>*{ content: \"</\\73tyle </\\73tyle\f</\\73tyle\r</\\73tyle\n</\\73tyle/\" }</style>" ),
'STYLE tag (start tag)' => array( '<style></style>', 1, '*{ content: "<style><STYLE>" }', '<style>*{ content: "<style><STYLE>" }</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' ),
);
}

Expand Down Expand Up @@ -703,6 +709,8 @@ 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 --!>' ),
'Comment starting with >' => array( '<!-- this is a comment -->', '> ends an empty comment' ),
'Comment starting with ->' => array( '<!-- this is a comment -->', '-> ends an empty comment' ),
'IFRAME with </iframe>' => array( '<iframe>Replace me</iframe>', 'Just a </iframe>' ),
'NOEMBED with </NOEMBED>' => array( '<noembed>Replace me</noembed>', 'Just a </NOEMBED>' ),
'NOFRAMES with </noframes attributes>' => array( '<noframes>Replace me</noframes>', 'before</noframes sneaky>after' ),
Expand All @@ -721,6 +729,46 @@ public static function data_unallowed_modifiable_text_updates() {
);
}

/**
* Ensures that comment text which resembles a comment closer, but cannot
* end the comment, is allowed.
*
* @dataProvider data_comment_text_resembling_a_closer
*
* @param string $update Text resembling, but not forming, a comment closer.
*/
public function test_allows_comment_text_which_cannot_close_the_comment( string $update ): void {
$processor = new WP_HTML_Tag_Processor( '<!-- comment -->' );
$processor->next_token();

$this->assertTrue(
$processor->set_modifiable_text( $update ),
'Should have allowed text which cannot close the comment.'
);

$this->assertSame(
"<!--{$update}-->",
$processor->get_updated_html(),
'Should have updated the comment.'
);
}

/**
* Data provider.
*
* @return array[]
*/
public static function data_comment_text_resembling_a_closer(): array {
return array(
'> after text' => array( 'a>' ),
'-> after text' => array( 'a->' ),
'-!> at the start' => array( '-!>' ),
'--! before text' => array( '--!x>' ),
'Spaced dashes' => array( '- ->' ),
'Dashes at the end' => array( 'a--' ),
);
}

/**
* Ensures that raw text which resembles a closing tag, but cannot close its
* element, is allowed as modifiable text.
Expand Down Expand Up @@ -814,6 +862,7 @@ public static function data_script_tag_text_updates(): array {
'Tricky script open tag with \r\n' => array( '<script></script>', "<!-- <script\r\n>", "<script><!-- <\\u0073cript\r\n></script>" ),
'Tricky script close tag with \r' => array( '<script></script>', "// </script\r>", "<script>// </\\u0073cript\r></script>" ),
'Tricky script close tag with \r\n' => array( '<script></script>', "// </script\r\n>", "<script>// </\\u0073cript\r\n></script>" ),
'Tag name terminators' => array( '<script></script>', "a<script b</script\tc<script\fd</script\ne<script/f", "<script>a<\\u0073cript b</\\u0073cript\tc<\\u0073cript\fd</\\u0073cript\ne<\\u0073cript/f</script>" ),
'Module tag' => array( '<script type="module"></script>', '"<script>"', '<script type="module">"<\u0073cript>"</script>' ),
'Tag with type' => array( '<script type="text/javascript"></script>', '"<script>"', '<script type="text/javascript">"<\u0073cript>"</script>' ),
'Tag with language' => array( '<script language="javascript"></script>', '"<script>"', '<script language="javascript">"<\u0073cript>"</script>' ),
Expand Down
Loading