From d2d74e3c2a1c0d3ee623a76063292980926949b6 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 25 Aug 2026 12:45:53 -0700 Subject: [PATCH 01/17] KSES: Reimplement with Tag Processor --- src/wp-includes/kses.php | 370 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 369 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 9394b75989912..a88cb029bec9a 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -947,18 +947,21 @@ * * @see wp_kses_post() for specifically filtering post content and fields. * @see wp_allowed_protocols() for the default allowed protocols in link URLs. + * @see wp_sanitize_html() for a modern implementation based on the HTML API. * * @since 1.0.0 * * @param string $content Text content to filter. * @param array[]|string $allowed_html An array of allowed HTML elements and attributes, - * or a context name such as 'post'. See wp_kses_allowed_html() + * or a context name such as 'post'. {@see wp_kses_allowed_html()} * for the list of accepted context names. * @param string[] $allowed_protocols Optional. Array of allowed URL protocols. * Defaults to the result of wp_allowed_protocols(). * @return string Filtered content containing only the allowed HTML. */ function wp_kses( $content, $allowed_html, $allowed_protocols = array() ) { + return wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols ); + if ( empty( $allowed_protocols ) ) { $allowed_protocols = wp_allowed_protocols(); } @@ -970,6 +973,371 @@ function wp_kses( $content, $allowed_html, $allowed_protocols = array() ) { return wp_kses_split( $content, $allowed_html, $allowed_protocols ); } +/** + * Filters HTML content, sanitizing according to given policies. + * + * Modern implementation of {@see wp_kses()} which parses via the HTML API. + * + * @since {WP_VERSION} + * + * @param string $content Text content to filter. + * @param array[]|string $allowed_html An array of allowed HTML elements and attributes, + * or a context name such as 'post'. See wp_kses_allowed_html() + * for the list of accepted context names. + * @param string[] $allowed_protocols Optional. Array of allowed URL protocols. + * Defaults to the result of wp_allowed_protocols(). + * @return string Filtered content containing only the allowed HTML. + */ +function wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols = array() ) { + $allowed_html = is_array( $allowed_html ) + ? $allowed_html + : wp_kses_allowed_html( $allowed_html ); + + $allowed_protocols = empty( $allowed_protocols ) + ? wp_allowed_protocols() + : $allowed_protocols; + + /* + * The explanation for this call is that “the quoting from `preg_replace(//e)` + * requires” it, but this version of `wp_kses()` doesn’t rely on PCRE functions + * to parse HTML. Given that this corrupts text, it will be skipped. + */ + //$content = wp_kses_stripslashes( $content ); + + $processor = new class( $content, $allowed_html, $allowed_protocols ) extends WP_HTML_Tag_Processor { + private $allowed_html; + + private $allowed_protocols; + + public function __construct( $html, $allowed_html, $allowed_protocols ) { + parent::__construct( $html ); + + $this->allowed_html = $allowed_html; + $this->allowed_protocols = $allowed_protocols; + } + + private function get_span() { + $this->set_bookmark( 'here' ); + + if ( ! isset( $this->bookmarks['here'] ) ) { + return null; + } + + return $this->bookmarks['here']; + } + + /** + * Returns a sanitized copy of the input HTML. + * + * @return string Sanitized copy of given input HTML. + */ + public function sanitize() { + $output = ''; + $was_at = 0; + $template_depth = 0; + $uris = wp_kses_uri_attributes(); + + while ( $this->next_token() ) { + $token_name = $this->get_token_name(); + $token_type = $this->get_token_type(); + $is_closer = $this->is_tag_closer(); + $text = $this->get_modifiable_text(); + $here = $this->get_span(); + + /* + * Without running the full HTML Processor, it’s not easy to know + * when these sections end, and since they introduce different + * parsing rules with the change of namespace, it’s best to end + * processing entirely when encountering these. + */ + if ( ! $is_closer && in_array( $token_name, array( 'MATH', 'SVG' ), true ) ) { + return $output; + } + + if ( 'TEMPLATE' === $token_name ) { + // Ignore stray TEMPLATE closing tags. + if ( $is_closer ) { + continue; + } + + /* + * TEMPLATE elements properly nest, meaning that no other syntax closes them + * except for a TEMPLATE closing tag. This makes it possible to safely skip + * over TEMPLATE elements, which untrusted inputs should not be providing. + */ + ++$template_depth; + while ( $template_depth > 0 && $this->next_token() ) { + if ( 'TEMPLATE' !== $this->get_tag() ) { + continue; + } + + $template_depth += $this->is_tag_closer() ? -1 : 1; + } + + $here = $this->get_span(); + $was_at = $here->start + $here->length; + continue; + } + + switch ( $token_type ) { + case '#text': + $output .= strtr( + $text, + array( + '<' => '<', + '&' => '&', + '>' => '>', + "'" => ''', + '"' => '"', + ) + ); + $was_at = $here->start + $here->length; + break; + + /* + * Untrusted sources should not be creating these kinds of tokens, + * so remove them entirely from the output. + */ + case '#doctype': + case '#presumptuous-tag': + $was_at = $here->start + $here->length; + break; + + /* + * `wp_kses()` runs iteratively on the content inside of these tokens, + * but the content is benign in a browser. + */ + case '#comment': + // Apply special filtering for block comment delimiters with JSON attributes. + $comment = substr( $this->html, $here->start, $here->length ); + $block_processor = new WP_Block_Processor( $comment ); + if ( $block_processor->next_token() && $block_processor->opens_block() ) { + $original_attributes = $block_processor->allocate_and_return_parsed_attributes(); + + if ( isset( $original_attributes ) ) { + $block_type = $block_processor->get_block_type(); + $filtered_attributed = filter_block_kses_value( + $original_attributes, + $this->allowed_html, + $this->allowed_protocols, + array( 'blockName' => $block_type ) + ); + + if ( $original_attributes !== $filtered_attributed ) { + $serialized_attributes = serialize_block_attributes( $filtered_attributed ); + $voider = WP_Block_Processor::VOID === $block_processor->get_delimiter_type() ? '/' : ''; + $text = " wp:{$block_type} {$serialized_attributes} {$voider}"; + } + } + } + + $output .= substr( $this->html, $was_at, $here->start - $was_at ); + $output .= ""; + $was_at = $here->start + $here->length; + break; + + case '#funky-comment': + case '#cdata-section': + case '#processing-instruction': + // @todo add this. + $was_at = $here->start + $here->length; + break; + + case '#tag': + $tag_name = strtolower( $token_name ); + + // Skip unallowed elements by tag name + if ( ! isset( $this->allowed_html[ $tag_name ] ) ) { + $was_at = $here->start + $here->length; + break; + } + + if ( $is_closer ) { + $output .= substr( $this->html, $was_at, $here->start - $was_at ); + $output .= ""; + $was_at = $here->start + $here->length; + break; + } + + $is_void = WP_HTML_Processor::is_void( $token_name ); + $attribute_names = $this->get_attribute_names_with_prefix( '' ); + $element_attributes = $this->allowed_html[ $tag_name ]; + + // Check for required attributes. + foreach ( $element_attributes as $name => $spec ) { + if ( + isset( $spec['required'] ) && + true === $spec['required'] && + ! in_array( $name, $attribute_names, true ) + ) { + if ( $is_void ) { + $was_at = $here->start + $here->length; + break 2; + } + + /* + * Since this processor cannot track nesting of HTML elements + * generally, leave opening tags when required attributes are + * missing, but strip them of their attributes. + */ + $output .= substr( $this->html, $was_at, $here->start - $was_at ); + $output .= "<{$tag_name}>"; + $was_at = $here->start + $here->length; + break 2; + } + } + + /* + * Allow `data-*` attributes. + * + * When specifying `$allowed_html`, the attribute name should be set as + * `data-*` (not to be mixed with the HTML 4.0 `data` attribute, see + * https://www.w3.org/TR/html40/struct/objects.html#adef-data). + * + * Note: the attribute name should only contain `A-Za-z0-9_-` chars. + */ + if ( ! empty( $element_attributes['data-*'] ) ) { + foreach ( $attribute_names as $name ) { + if ( ! str_starts_with( $name, 'data-' ) ) { + continue; + } + + if ( 1 !== preg_match( '/^data-[a-z0-9_-]+$/', $name ) ) { + continue; + } + + $element_attributes[ $name ] = $element_attributes['data-*']; + } + + unset( $element_attributes['data-*'] ); + } + + $tag_maker = new WP_HTML_Tag_Processor( "<{$tag_name}>" ); + $tag_maker->next_token(); + foreach ( $attribute_names as $name ) { + $spec = $element_attributes[ $name ] ?? null; + + // This attribute is not specified, thus not allowed. Skip it. + if ( null === $spec || '' === $spec ) { + continue; + } + + // Process the style attribute through CSS sanitization. + if ( 'style' === $name ) { + $style = safecss_filter_attr( $this->get_attribute( 'style' ) ); + $tag_maker->set_attribute( 'style', $style ); + continue; + } + + $raw_value = $this->get_attribute( $name ); + $value = is_string( $raw_value ) ? $raw_value : ''; + + if ( is_string( $raw_value ) && in_array( $name, $uris, true ) ) { + $value = wp_kses_bad_protocol( $value, $this->allowed_protocols ); + } + + // The expectation is that the spec here is `true`. + if ( ! is_array( $spec ) ) { + if ( true === $raw_value && '' === $value ) { + $tag_maker->set_attribute( $name, true ); + } else { + $tag_maker->set_attribute( $name, $value ); + } + continue; + } + + // Process the remaining attributes according to their policies. + foreach ( $spec as $property => $constraint ) { + if ( + ( 'maxlen' === $property && strlen( $value ) > $constraint ) || + ( 'minlen' === $property && strlen( $value ) < $constraint ) + ) { + continue 2; + } + + if ( 'maxval' === $property || 'minval' === $property ) { + $max_digits = 1 + (int) floor( log10( (int) abs( $constraint ) ) ); + $zeros = strspn( $value, '0' ); + $digits = strlen( $value ) - $zeros; + + if ( + 'maxval' === $property && + ( + $digits > $max_digits || + strspn( $value, '0123456789', $zeros ) !== $digits || + $value > $constraint + ) + ) { + continue 2; + } + + if ( + 'minval' === $property && + ( + $digits < $max_digits || + strspn( $value, '0123456789', $zeros ) !== $digits || + $value < $constraint + ) + ) { + continue 2; + } + } + + if ( 'valueless' === $property ) { + if ( + ( ( 'y' === $constraint || 'Y' === $constraint ) && is_string( $raw_value ) ) || + ( ( 'n' === $constraint || 'N' === $constraint ) && true === $raw_value ) + ) { + continue 2; + } + } + + if ( + 'values' === $property && + ! in_array( strtolower( $value ), $constraint, true ) + ) { + continue 2; + } + + if ( + 'value_callback' === $property && + ! call_user_func( $constraint, $value ) + ) { + continue 2; + } + } + + if ( true === $raw_value && '' === $value ) { + $tag_maker->set_attribute( $name, true ); + } else { + $tag_maker->set_attribute( $name, $value ); + } + } + + $output .= substr( $this->html, $was_at, $here->start - $was_at ); + $output .= $tag_maker->get_updated_html(); + $was_at = $here->start + $here->length; + + break; + } + } + + /* + * While there might have been an incomplete token in the output stream, + * there is no need to render it to the output. They would disappear on + * their own in a browser if they ended the document, but here they do + * not end the document; instead, they are likely being inserted into an + * existing document, where the incomplete token might mess with the rest + * of the page’s HTML structure. + */ + + return $output; + } + }; + + return $processor->sanitize(); +} + /** * Filters one HTML attribute and ensures its value is allowed. * From 4222df1399c4a80ad0bde89308279e9f6d00f388 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Fri, 28 Aug 2026 22:03:46 -0700 Subject: [PATCH 02/17] Copy various comment-like tokens verbatim. --- src/wp-includes/kses.php | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index a88cb029bec9a..3139030339c48 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1100,6 +1100,7 @@ public function sanitize() { */ case '#doctype': case '#presumptuous-tag': + case '#processing-instruction': $was_at = $here->start + $here->length; break; @@ -1136,11 +1137,23 @@ public function sanitize() { $was_at = $here->start + $here->length; break; - case '#funky-comment': + /* + * True CDATA sections only exist within embedded SVG and MathML content, + * where they represent text data without any escaping, and where downstream + * parsers are generally reliable enough. In fact, most downstream parsers + * are more likely to properly detect true CDATA sections than the lookalikes + * that exist for elements in the HTML namespace. Copy the token verbatim. + * + * Funky comments and PI nodes are similar, but since they terminate at + * the first `>` character, the misparses of downstream parsers would + * tend to accidentally parse one of these properly. It’s also + * effective to copy verbatim. + */ case '#cdata-section': - case '#processing-instruction': - // @todo add this. - $was_at = $here->start + $here->length; + case '#funky-comment': + $output .= substr( $this->html, $was_at, $here->start - $was_at ); + $output .= substr( $this->html, $here->start, $here->length ); + $was_at = $here->start + $here->length; break; case '#tag': From d3b607d2f650ee7afc29789fed097d335e030173 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 25 Aug 2026 13:41:48 -0700 Subject: [PATCH 03/17] Cast inputs to string --- src/wp-includes/kses.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 3139030339c48..365fda4b11c07 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -960,7 +960,7 @@ * @return string Filtered content containing only the allowed HTML. */ function wp_kses( $content, $allowed_html, $allowed_protocols = array() ) { - return wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols ); + return wp_sanitize_html_kses( (string) $content, $allowed_html, $allowed_protocols ); if ( empty( $allowed_protocols ) ) { $allowed_protocols = wp_allowed_protocols(); From f0815c56429065dac27253ce2fffe585f7290d93 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 25 Aug 2026 14:06:35 -0700 Subject: [PATCH 04/17] Do not escape quote characters (match legacy behavior). --- src/wp-includes/kses.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 365fda4b11c07..1fe960add814d 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1087,8 +1087,15 @@ public function sanitize() { '<' => '<', '&' => '&', '>' => '>', - "'" => ''', - '"' => '"', + /* + * Keep compatibility with legacy `wp_kses()`. + * These don’t need to be escaped, but they may. + * The value in escaping them is preventing errant + * PCRE patterns from catching them. In fact, only + * the `<` and `&` are required to be escaped. + */ + // "'" => ''', + // '"' => '"', ) ); $was_at = $here->start + $here->length; From 505619f62bce3d8f46ea8da24710e55a27459386 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Fri, 28 Aug 2026 11:48:43 -0700 Subject: [PATCH 05/17] Preserve `wp_kses()` comment-content corruption defect. --- src/wp-includes/kses.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 1fe960add814d..5009878a8b32b 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1139,6 +1139,15 @@ public function sanitize() { } } + /* + * Legacy `wp_kses()` recursively calls itself on the contents of comments. + * Since comment content is not escaped, this changes the meaning of those + * comments when parsed. Still, code often expects to find tag-like syntax + * only when they are real tags. This legacy defect is preserved to avoid + * presenting content that downstream parsers might misinterpret as markup. + */ + $text = strtr( $text, array( '<' => '<' ) ); + $output .= substr( $this->html, $was_at, $here->start - $was_at ); $output .= ""; $was_at = $here->start + $here->length; From 54ded2b0e8d8170af1606b2d510c0b80593d07db Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 25 Aug 2026 14:33:09 -0700 Subject: [PATCH 06/17] Handle special-atomic elements. --- src/wp-includes/kses.php | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 5009878a8b32b..a627d505b1593 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1037,6 +1037,21 @@ public function sanitize() { $template_depth = 0; $uris = wp_kses_uri_attributes(); + /** + * These are treated as void elements inside the HTML API + * due to the special handling of their inner text content. + */ + $special_atomic_elements = array( + 'IFRAME', + 'NOEMBED', + 'NOFRAMES', + 'SCRIPT', + 'STYLE', + 'TEXTAREA', + 'TITLE', + 'XMP', + ); + while ( $this->next_token() ) { $token_name = $this->get_token_name(); $token_type = $this->get_token_type(); @@ -1098,7 +1113,7 @@ public function sanitize() { // '"' => '"', ) ); - $was_at = $here->start + $here->length; + $was_at = $here->start + $here->length; break; /* @@ -1241,7 +1256,12 @@ public function sanitize() { unset( $element_attributes['data-*'] ); } - $tag_maker = new WP_HTML_Tag_Processor( "<{$tag_name}>" ); + $is_special_atomic_element = in_array( $token_name, $special_atomic_elements, true ); + $tag_maker = new WP_HTML_Tag_Processor( + $is_special_atomic_element + ? "<{$tag_name}>" + : "<{$tag_name}>" + ); $tag_maker->next_token(); foreach ( $attribute_names as $name ) { $spec = $element_attributes[ $name ] ?? null; @@ -1344,6 +1364,9 @@ public function sanitize() { } $output .= substr( $this->html, $was_at, $here->start - $was_at ); + if ( $is_special_atomic_element ) { + $tag_maker->set_modifiable_text( $text ); + } $output .= $tag_maker->get_updated_html(); $was_at = $here->start + $here->length; From c29e7961df8e8604bcc59a5e80e3be5a27f3ff33 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 25 Aug 2026 18:31:50 -0700 Subject: [PATCH 07/17] =?UTF-8?q?Process=20required=20attributes=20after?= =?UTF-8?q?=20validation;=20they=20aren=E2=80=99t=20present=20if=20invalid?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/wp-includes/kses.php | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index a627d505b1593..b95ae86960ae1 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1208,12 +1208,14 @@ public function sanitize() { $element_attributes = $this->allowed_html[ $tag_name ]; // Check for required attributes. + $required_attributes = array(); foreach ( $element_attributes as $name => $spec ) { - if ( - isset( $spec['required'] ) && - true === $spec['required'] && - ! in_array( $name, $attribute_names, true ) - ) { + $is_required = true === ( $spec['required'] ?? false ); + if ( ! $is_required ) { + continue; + } + + if ( ! in_array( $name, $attribute_names, true ) ) { if ( $is_void ) { $was_at = $here->start + $here->length; break 2; @@ -1229,6 +1231,8 @@ public function sanitize() { $was_at = $here->start + $here->length; break 2; } + + $required_attributes[ $name ] = true; } /* @@ -1275,6 +1279,7 @@ public function sanitize() { if ( 'style' === $name ) { $style = safecss_filter_attr( $this->get_attribute( 'style' ) ); $tag_maker->set_attribute( 'style', $style ); + unset( $required_attributes['style'] ); continue; } @@ -1292,6 +1297,7 @@ public function sanitize() { } else { $tag_maker->set_attribute( $name, $value ); } + unset( $required_attributes[ $name ] ); continue; } @@ -1361,6 +1367,24 @@ public function sanitize() { } else { $tag_maker->set_attribute( $name, $value ); } + unset( $required_attributes[ $name ] ); + } + + if ( ! empty( $required_attributes ) ) { + if ( $is_void ) { + $was_at = $here->start + $here->length; + break; + } + + /* + * Since this processor cannot track nesting of HTML elements + * generally, leave opening tags when required attributes are + * missing, but strip them of their attributes. + */ + $output .= substr( $this->html, $was_at, $here->start - $was_at ); + $output .= "<{$tag_name}>"; + $was_at = $here->start + $here->length; + break; } $output .= substr( $this->html, $was_at, $here->start - $was_at ); From 583a3123cb3aa42c9495cc09e3ff0f6961fcf819 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 25 Aug 2026 23:03:47 -0700 Subject: [PATCH 08/17] Remove unwanted C0 characters from text nodes. --- src/wp-includes/kses.php | 48 +++++++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index b95ae86960ae1..56295fca3b8d7 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1096,12 +1096,48 @@ public function sanitize() { switch ( $token_type ) { case '#text': - $output .= strtr( + $text = strtr( $text, array( - '<' => '<', - '&' => '&', - '>' => '>', + /* + * At this point, C0 controls exist in a decoded text node, + * and the output will be re-escaped. This means that removing + * these characters cannot join together previously-separated + * syntax characters. + */ + "\x00" => '', + "\x01" => '', + "\x02" => '', + "\x03" => '', + "\x04" => '', + "\x05" => '', + "\x06" => '', + "\x07" => '', + "\x08" => '', + "\x0B" => '', + "\x0C" => '', + "\x0E" => '', + "\x0F" => '', + "\x10" => '', + "\x11" => '', + "\x12" => '', + "\x13" => '', + "\x14" => '', + "\x15" => '', + "\x16" => '', + "\x17" => '', + "\x18" => '', + "\x19" => '', + "\x1A" => '', + "\x1B" => '', + "\x1C" => '', + "\x1D" => '', + "\x1E" => '', + "\x1F" => '', + + '<' => '<', + '&' => '&', + '>' => '>', /* * Keep compatibility with legacy `wp_kses()`. * These don’t need to be escaped, but they may. @@ -1113,7 +1149,9 @@ public function sanitize() { // '"' => '"', ) ); - $was_at = $here->start + $here->length; + + $output .= $text; + $was_at = $here->start + $here->length; break; /* From 0cec67b84f46e53ea5da777cfb3321bac1037730 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Wed, 26 Aug 2026 12:15:47 -0700 Subject: [PATCH 09/17] Undo http:// prefix on no-path-separator URLs --- src/wp-includes/kses.php | 61 +++++++++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 56295fca3b8d7..3fd25e6287387 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1009,11 +1009,14 @@ function wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols = ar private $allowed_protocols; + private $uris; + public function __construct( $html, $allowed_html, $allowed_protocols ) { parent::__construct( $html ); $this->allowed_html = $allowed_html; $this->allowed_protocols = $allowed_protocols; + $this->uris = wp_kses_uri_attributes(); } private function get_span() { @@ -1026,6 +1029,53 @@ private function get_span() { return $this->bookmarks['here']; } + public function set_attribute( $name, $value ): bool { + $given_value = $value; + $is_url_ish = in_array( $name, $this->uris, true ); + + if ( is_string( $value ) && '' !== $value && $is_url_ish ) { + $value = wp_kses_bad_protocol( $value, $this->allowed_protocols ); + } + + if ( ! parent::set_attribute( $name, $value ) ) { + return false; + } + + /** + * Legacy `wp_kses()` does not add the http/https prefix that `esc_url()` adds + * when a given URL contains a relative path containing no path separators, + * e.g. "foo" or "smile.png". This "undo" preserves that behavior. + * + * This stems from an ambiguity inside {@see \esc_url()} whereby it treats URLs + * with no path separators, no `?`, and no `#` as domains, thus prefixing the + * HTTP protocol. + */ + if ( $is_url_ish ) { + $lower_name = strtolower( $name ); + $enqueued_value = $this->lexical_updates[ $lower_name ]->text; + $enqueued_value = substr( $enqueued_value, strpos( $enqueued_value, '"' ) + 1, -1 ); + $enqueued_value = WP_HTML_Decoder::decode_attribute( $enqueued_value ); + $had_no_prefix = 1 !== preg_match( '~^[a-z][a-z0-9-]://~i', $given_value ); + $has_prefix = 1 === preg_match( '~^https?://~', $enqueued_value ); + + if ( $had_no_prefix && $has_prefix ) { + $escaped = strtr( + $value, + array( + '<' => '<', + '>' => '>', + '&' => '&', + '"' => '"', + "'" => ''', + ) + ); + $this->lexical_updates[ $lower_name ]->text = " {$lower_name}=\"{$escaped}\""; + } + } + + return true; + } + /** * Returns a sanitized copy of the input HTML. * @@ -1035,7 +1085,6 @@ public function sanitize() { $output = ''; $was_at = 0; $template_depth = 0; - $uris = wp_kses_uri_attributes(); /** * These are treated as void elements inside the HTML API @@ -1299,10 +1348,12 @@ public function sanitize() { } $is_special_atomic_element = in_array( $token_name, $special_atomic_elements, true ); - $tag_maker = new WP_HTML_Tag_Processor( + $tag_maker = new self( $is_special_atomic_element ? "<{$tag_name}>" - : "<{$tag_name}>" + : "<{$tag_name}>", + $this->allowed_html, + $this->allowed_protocols ); $tag_maker->next_token(); foreach ( $attribute_names as $name ) { @@ -1324,10 +1375,6 @@ public function sanitize() { $raw_value = $this->get_attribute( $name ); $value = is_string( $raw_value ) ? $raw_value : ''; - if ( is_string( $raw_value ) && in_array( $name, $uris, true ) ) { - $value = wp_kses_bad_protocol( $value, $this->allowed_protocols ); - } - // The expectation is that the spec here is `true`. if ( ! is_array( $spec ) ) { if ( true === $raw_value && '' === $value ) { From e076bb8fb05d47407b78b541a8e2b77faf348894 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Thu, 27 Aug 2026 09:58:47 -0700 Subject: [PATCH 10/17] Use the HTML Processor for SVG and overall better parsing. Todo - [ ] Remove opening and closing tags when missing attributes. - [ ] Handle parsing when it bails for unsupported content. --- .../html-api/class-wp-html-processor.php | 2 +- src/wp-includes/kses.php | 144 ++++++++---------- 2 files changed, 65 insertions(+), 81 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 9e18d0e9b7121..64fe15ba6481e 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -896,7 +896,7 @@ public function is_tag_closer(): bool { * * @return bool Whether the current token is virtual. */ - private function is_virtual(): bool { + protected function is_virtual(): bool { return ( isset( $this->current_element->provenance ) && 'virtual' === $this->current_element->provenance diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 3fd25e6287387..044d72ad7e6e3 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1004,29 +1004,37 @@ function wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols = ar */ //$content = wp_kses_stripslashes( $content ); - $processor = new class( $content, $allowed_html, $allowed_protocols ) extends WP_HTML_Tag_Processor { + $processor = new class( '', WP_HTML_Processor::CONSTRUCTOR_UNLOCK_CODE ) extends WP_HTML_Processor { private $allowed_html; private $allowed_protocols; private $uris; - public function __construct( $html, $allowed_html, $allowed_protocols ) { - parent::__construct( $html ); + private function get_span() { + static $last_bookmark = null; - $this->allowed_html = $allowed_html; - $this->allowed_protocols = $allowed_protocols; - $this->uris = wp_kses_uri_attributes(); - } + if ( $this->is_virtual() ) { + return $last_bookmark; + } - private function get_span() { - $this->set_bookmark( 'here' ); + parent::set_bookmark( 'here' ); - if ( ! isset( $this->bookmarks['here'] ) ) { + if ( ! isset( $this->bookmarks['_here'] ) ) { return null; } - return $this->bookmarks['here']; + $last_bookmark = $this->bookmarks['_here']; + + return $last_bookmark; + } + + private function skip_node() { + $depth = $this->get_current_depth(); + + while ( $this->next_token() && $depth < $this->get_current_depth() ) { + continue; + } } public function set_attribute( $name, $value ): bool { @@ -1081,10 +1089,15 @@ public function set_attribute( $name, $value ): bool { * * @return string Sanitized copy of given input HTML. */ - public function sanitize() { - $output = ''; - $was_at = 0; - $template_depth = 0; + public static function sanitize( $content, $allowed_html, $allowed_protocols ) { + $self = self::create_fragment( $content ); + + $self->allowed_html = $allowed_html; + $self->allowed_protocols = $allowed_protocols; + $self->uris = wp_kses_uri_attributes(); + + $output = ''; + $was_at = 0; /** * These are treated as void elements inside the HTML API @@ -1101,46 +1114,15 @@ public function sanitize() { 'XMP', ); - while ( $this->next_token() ) { - $token_name = $this->get_token_name(); - $token_type = $this->get_token_type(); - $is_closer = $this->is_tag_closer(); - $text = $this->get_modifiable_text(); - $here = $this->get_span(); - - /* - * Without running the full HTML Processor, it’s not easy to know - * when these sections end, and since they introduce different - * parsing rules with the change of namespace, it’s best to end - * processing entirely when encountering these. - */ - if ( ! $is_closer && in_array( $token_name, array( 'MATH', 'SVG' ), true ) ) { - return $output; - } - - if ( 'TEMPLATE' === $token_name ) { - // Ignore stray TEMPLATE closing tags. - if ( $is_closer ) { - continue; - } - - /* - * TEMPLATE elements properly nest, meaning that no other syntax closes them - * except for a TEMPLATE closing tag. This makes it possible to safely skip - * over TEMPLATE elements, which untrusted inputs should not be providing. - */ - ++$template_depth; - while ( $template_depth > 0 && $this->next_token() ) { - if ( 'TEMPLATE' !== $this->get_tag() ) { - continue; - } - - $template_depth += $this->is_tag_closer() ? -1 : 1; - } + while ( $self->next_token() ) { + $token_name = $self->get_token_name(); + $token_type = $self->get_token_type(); + $is_closer = $self->is_tag_closer(); + $text = $self->get_modifiable_text(); + $here = $self->get_span(); - $here = $this->get_span(); - $was_at = $here->start + $here->length; - continue; + while ( in_array( $self->get_token_name(), array( 'TEMPLATE' ), true ) ) { + $self->skip_node(); } switch ( $token_type ) { @@ -1219,7 +1201,7 @@ public function sanitize() { */ case '#comment': // Apply special filtering for block comment delimiters with JSON attributes. - $comment = substr( $this->html, $here->start, $here->length ); + $comment = substr( $self->html, $here->start, $here->length ); $block_processor = new WP_Block_Processor( $comment ); if ( $block_processor->next_token() && $block_processor->opens_block() ) { $original_attributes = $block_processor->allocate_and_return_parsed_attributes(); @@ -1228,8 +1210,8 @@ public function sanitize() { $block_type = $block_processor->get_block_type(); $filtered_attributed = filter_block_kses_value( $original_attributes, - $this->allowed_html, - $this->allowed_protocols, + $self->allowed_html, + $self->allowed_protocols, array( 'blockName' => $block_type ) ); @@ -1250,7 +1232,7 @@ public function sanitize() { */ $text = strtr( $text, array( '<' => '<' ) ); - $output .= substr( $this->html, $was_at, $here->start - $was_at ); + $output .= substr( $self->html, $was_at, $here->start - $was_at ); $output .= ""; $was_at = $here->start + $here->length; break; @@ -1269,8 +1251,8 @@ public function sanitize() { */ case '#cdata-section': case '#funky-comment': - $output .= substr( $this->html, $was_at, $here->start - $was_at ); - $output .= substr( $this->html, $here->start, $here->length ); + $output .= substr( $self->html, $was_at, $here->start - $was_at ); + $output .= substr( $self->html, $here->start, $here->length ); $was_at = $here->start + $here->length; break; @@ -1278,21 +1260,22 @@ public function sanitize() { $tag_name = strtolower( $token_name ); // Skip unallowed elements by tag name - if ( ! isset( $this->allowed_html[ $tag_name ] ) ) { + if ( ! isset( $self->allowed_html[ $tag_name ] ) ) { $was_at = $here->start + $here->length; break; } if ( $is_closer ) { - $output .= substr( $this->html, $was_at, $here->start - $was_at ); + $output .= substr( $self->html, $was_at, $here->start - $was_at ); $output .= ""; $was_at = $here->start + $here->length; break; } - $is_void = WP_HTML_Processor::is_void( $token_name ); - $attribute_names = $this->get_attribute_names_with_prefix( '' ); - $element_attributes = $this->allowed_html[ $tag_name ]; + $has_no_closer = ! $self->expects_closer(); + $attribute_names = $self->get_attribute_names_with_prefix( '' ); + $element_attributes = $self->allowed_html[ $tag_name ]; + $non_html_self_closer = ( 'html' !== $self->get_namespace() && $has_no_closer ) ? ' /' : ''; // Check for required attributes. $required_attributes = array(); @@ -1303,7 +1286,7 @@ public function sanitize() { } if ( ! in_array( $name, $attribute_names, true ) ) { - if ( $is_void ) { + if ( $has_no_closer ) { $was_at = $here->start + $here->length; break 2; } @@ -1313,8 +1296,8 @@ public function sanitize() { * generally, leave opening tags when required attributes are * missing, but strip them of their attributes. */ - $output .= substr( $this->html, $was_at, $here->start - $was_at ); - $output .= "<{$tag_name}>"; + $output .= substr( $self->html, $was_at, $here->start - $was_at ); + $output .= "<{$tag_name}{$non_html_self_closer}>"; $was_at = $here->start + $here->length; break 2; } @@ -1347,14 +1330,15 @@ public function sanitize() { unset( $element_attributes['data-*'] ); } - $is_special_atomic_element = in_array( $token_name, $special_atomic_elements, true ); - $tag_maker = new self( + $is_special_atomic_element = in_array( $token_name, $special_atomic_elements, true ); + $tag_maker = self::create_fragment( $is_special_atomic_element ? "<{$tag_name}>" - : "<{$tag_name}>", - $this->allowed_html, - $this->allowed_protocols + : "<{$tag_name}{$non_html_self_closer}>", ); + $tag_maker->allowed_html = $self->allowed_html; + $tag_maker->allowed_protocols = $self->allowed_protocols; + $tag_maker->uris = $self->uris; $tag_maker->next_token(); foreach ( $attribute_names as $name ) { $spec = $element_attributes[ $name ] ?? null; @@ -1366,13 +1350,13 @@ public function sanitize() { // Process the style attribute through CSS sanitization. if ( 'style' === $name ) { - $style = safecss_filter_attr( $this->get_attribute( 'style' ) ); + $style = safecss_filter_attr( $self->get_attribute( 'style' ) ); $tag_maker->set_attribute( 'style', $style ); unset( $required_attributes['style'] ); continue; } - $raw_value = $this->get_attribute( $name ); + $raw_value = $self->get_attribute( $name ); $value = is_string( $raw_value ) ? $raw_value : ''; // The expectation is that the spec here is `true`. @@ -1456,7 +1440,7 @@ public function sanitize() { } if ( ! empty( $required_attributes ) ) { - if ( $is_void ) { + if ( $has_no_closer ) { $was_at = $here->start + $here->length; break; } @@ -1466,13 +1450,13 @@ public function sanitize() { * generally, leave opening tags when required attributes are * missing, but strip them of their attributes. */ - $output .= substr( $this->html, $was_at, $here->start - $was_at ); - $output .= "<{$tag_name}>"; + $output .= substr( $self->html, $was_at, $here->start - $was_at ); + $output .= "<{$tag_name}{$non_html_self_closer}>"; $was_at = $here->start + $here->length; break; } - $output .= substr( $this->html, $was_at, $here->start - $was_at ); + $output .= substr( $self->html, $was_at, $here->start - $was_at ); if ( $is_special_atomic_element ) { $tag_maker->set_modifiable_text( $text ); } @@ -1496,7 +1480,7 @@ public function sanitize() { } }; - return $processor->sanitize(); + return $processor::sanitize( $content, $allowed_html, $allowed_protocols ); } /** From 7454414ce667e11fc0f7748ea860a8139dec594d Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 25 Aug 2026 14:35:52 -0700 Subject: [PATCH 11/17] Update tests to reflect HTML parsing standards. Notably, contents of SCRIPT elements _should not_ be extracted and rendered as HTML text nodes. These are SCRIPT contents, and should be hidden from the page. --- .../tests/block-bindings/postMetaSource.php | 4 +- tests/phpunit/tests/block-bindings/render.php | 4 +- tests/phpunit/tests/customize/manager.php | 8 +- .../tests/customize/nav-menu-item-setting.php | 6 +- .../tests/formatting/sanitizeTextField.php | 4 +- .../tests/functions/wpTriggerError.php | 2 +- .../tests/icons/wpRestIconsController.php | 10 +- tests/phpunit/tests/kses.php | 96 +++++++++++-------- tests/phpunit/tests/media.php | 10 +- tests/phpunit/tests/post/output.php | 2 +- tests/phpunit/tests/post/wpPublishPost.php | 2 +- ...acyGeneratePersonalDataExportGroupHtml.php | 4 +- .../rest-api/rest-attachments-controller.php | 24 ++--- .../rest-api/rest-comments-controller.php | 4 +- .../tests/rest-api/rest-posts-controller.php | 16 ++-- .../tests/rest-api/rest-tags-controller.php | 6 +- .../tests/rest-api/rest-users-controller.php | 6 +- .../rest-api/rest-widgets-controller.php | 2 +- .../rest-api/wpRestUrlDetailsController.php | 2 +- 19 files changed, 114 insertions(+), 98 deletions(-) diff --git a/tests/phpunit/tests/block-bindings/postMetaSource.php b/tests/phpunit/tests/block-bindings/postMetaSource.php index 555376e1c6b0c..353e11a2a8e52 100644 --- a/tests/phpunit/tests/block-bindings/postMetaSource.php +++ b/tests/phpunit/tests/block-bindings/postMetaSource.php @@ -260,8 +260,8 @@ public function test_custom_field_with_unsafe_html_is_sanitized() { $content = $this->get_modified_post_content( '

Fallback value

' ); - $this->assertSame( - '

alert(“Unsafe HTML”)

', + $this->assertEqualHTML( + '

', $content, 'The post content should not include the script tag.' ); diff --git a/tests/phpunit/tests/block-bindings/render.php b/tests/phpunit/tests/block-bindings/render.php index 3ce1993e4c351..08875a450f979 100644 --- a/tests/phpunit/tests/block-bindings/render.php +++ b/tests/phpunit/tests/block-bindings/render.php @@ -193,7 +193,7 @@ function ( $source_args, $block_instance, $attribute_name ) { function () { return ''; }, - '

alert("Unsafe HTML")

', + '

', ), 'symbols and numbers should be rendered correctly' => array( function () { @@ -234,7 +234,7 @@ public function test_different_get_value_callbacks( $get_value_callback, $expect $block = new WP_Block( $parsed_blocks[0] ); $result = $block->render(); - $this->assertSame( + $this->assertEqualHTML( $expected, trim( $result ), 'The block content should be updated with the value returned by the source.' diff --git a/tests/phpunit/tests/customize/manager.php b/tests/phpunit/tests/customize/manager.php index 6937fcd4b2c1e..ce4991a29ae7d 100644 --- a/tests/phpunit/tests/customize/manager.php +++ b/tests/phpunit/tests/customize/manager.php @@ -1357,11 +1357,11 @@ public function test_save_changeset_post_without_kses_corrupting_json() { // User saved as one who cannot bypass content_save_pre filter. $this->assertStringNotContainsString( '' ) ); + $this->assertSame( 'Unfiltered', apply_filters( 'content_save_pre', 'Unfiltered' ) ); wp_publish_post( $changeset_post_id ); // @todo If wp_update_post() is used here, then kses will corrupt the post_content. $this->assertSame( 'Unfiltered', get_option( 'scratchpad' ) ); } diff --git a/tests/phpunit/tests/customize/nav-menu-item-setting.php b/tests/phpunit/tests/customize/nav-menu-item-setting.php index 0c832f3c6887c..df255d622880c 100644 --- a/tests/phpunit/tests/customize/nav-menu-item-setting.php +++ b/tests/phpunit/tests/customize/nav-menu-item-setting.php @@ -588,11 +588,11 @@ public function test_sanitize() { 'menu_item_parent' => 0, 'position' => -123, 'type' => 'customb', - 'title' => current_user_can( 'unfiltered_html' ) ? '\o/ o\'o Hi' : '\o/ o\'o HiunfilteredHtml()', + 'title' => current_user_can( 'unfiltered_html' ) ? '\o/ o\'o Hi' : '\o/ o\'o Hi', 'url' => '', 'target' => 'onclick', - 'attr_title' => current_user_can( 'unfiltered_html' ) ? '\o/ o\'o bolded' : '\o/ o\'o boldedunfilteredHtml()', - 'description' => current_user_can( 'unfiltered_html' ) ? '\o/ o\'o Hello world' : '\o/ o\'o Hello worldunfilteredHtml()', + 'attr_title' => current_user_can( 'unfiltered_html' ) ? '\o/ o\'o bolded' : '\o/ o\'o bolded', + 'description' => current_user_can( 'unfiltered_html' ) ? '\o/ o\'o Hello world' : '\o/ o\'o Hello world', 'classes' => 'hello inject', 'xfn' => 'hello inject', 'status' => 'draft', diff --git a/tests/phpunit/tests/formatting/sanitizeTextField.php b/tests/phpunit/tests/formatting/sanitizeTextField.php index 579f8e29de74e..664301ac8d6cd 100644 --- a/tests/phpunit/tests/formatting/sanitizeTextField.php +++ b/tests/phpunit/tests/formatting/sanitizeTextField.php @@ -20,7 +20,7 @@ public function test_sanitize_text_field( $str, $expected ) { $expected_oneline = $expected; $expected_multiline = $expected; } - $this->assertSame( $expected_oneline, sanitize_text_field( $str ) ); + $this->assertEqualHTML( $expected_oneline, sanitize_text_field( $str ) ); $this->assertSameIgnoreEOL( $expected_multiline, sanitize_textarea_field( $str ) ); } @@ -55,7 +55,7 @@ public function data_sanitize_text_field() { array( "foo <\ndiv\n> bar", array( - 'oneline' => 'foo < div > bar', + 'oneline' => 'foo < div > bar', 'multiline' => "foo <\ndiv\n> bar", ), ), diff --git a/tests/phpunit/tests/functions/wpTriggerError.php b/tests/phpunit/tests/functions/wpTriggerError.php index b642b7b08f6ae..6d577cc294fc8 100644 --- a/tests/phpunit/tests/functions/wpTriggerError.php +++ b/tests/phpunit/tests/functions/wpTriggerError.php @@ -110,7 +110,7 @@ public function data_should_trigger_error() { 'disallowed HTML elements are present in message' => array( 'function_name' => 'some_function', 'message' => '', - 'expected_message' => 'some_function(): alert("expected the function name and message")', + 'expected_message' => 'some_function(): ', ), ); } diff --git a/tests/phpunit/tests/icons/wpRestIconsController.php b/tests/phpunit/tests/icons/wpRestIconsController.php index dc899ce2bd7be..dbe8c7270b263 100644 --- a/tests/phpunit/tests/icons/wpRestIconsController.php +++ b/tests/phpunit/tests/icons/wpRestIconsController.php @@ -333,11 +333,11 @@ public function test_get_item_returns_specific_icon() { $this->assertSame( 'core/arrow-left', $data['name'] ); $this->assertSame( 'Arrow Left', $data['label'] ); $this->assertNotEmpty( $data['content'] ); - $this->assertStringStartsWith( - 'assertEqualHTML( '\';alert(String.fromCharCode(88,83,83))//\\\';alert(String.fromCharCode(88,83,83))//";alert(String.fromCharCode(88,83,83))//\\";alert(String.fromCharCode(88,83,83))//-->">\'>=&{}', $result ); break; case 'XSS Quick Test': - $this->assertSame( '\'\';!--"=&{()}', $result ); + $this->assertEqualHTML( '\'\';!--"=&{()}', $result ); break; case 'SCRIPT w/Alert()': - $this->assertSame( "alert('XSS')", $result ); + $this->assertEqualHTML( "alert('XSS')", $result ); break; case 'SCRIPT w/Char Code': - $this->assertSame( 'alert(String.fromCharCode(88,83,83))', $result ); + $this->assertEqualHTML( 'alert(String.fromCharCode(88,83,83))', $result ); break; case 'IMG STYLE w/expression': - $this->assertSame( 'exp/*', $result ); + $this->assertEqualHTML( 'exp/*', $result ); break; case 'List-style-image': - $this->assertSame( 'li {list-style-image: url("javascript:alert(\'XSS\')");}XSS', $result ); + $this->assertEqualHTML( 'li {list-style-image: url("javascript:alert(\'XSS\')");}XSS', $result ); break; case 'STYLE': - $this->assertSame( "alert('XSS');", $result ); + $this->assertEqualHTML( "alert('XSS');", $result ); break; case 'STYLE w/background-image': - $this->assertSame( '.XSS{background-image:url("javascript:alert(\'XSS\')");}', $result ); + $this->assertEqualHTML( '', $result ); break; case 'STYLE w/background': - $this->assertSame( 'BODY{background:url("javascript:alert(\'XSS\')")}', $result ); + $this->assertEqualHTML( 'BODY{background:url("javascript:alert(\'XSS\')")}', $result ); break; case 'Remote Stylesheet 2': - $this->assertSame( "@import'http://ha.ckers.org/xss.css';", $result ); + $this->assertEqualHTML( "@import'http://ha.ckers.org/xss.css';", $result ); break; case 'Remote Stylesheet 3': - $this->assertSame( '<META HTTP-EQUIV="Link" Content="; REL=stylesheet">', $result ); + $this->assertEqualHTML( '<META HTTP-EQUIV="Link" Content="; REL=stylesheet">', $result ); break; case 'Remote Stylesheet 4': - $this->assertSame( 'BODY{-moz-binding:url("http://ha.ckers.org/xssmoz.xml#xss")}', $result ); + $this->assertEqualHTML( 'BODY{-moz-binding:url("http://ha.ckers.org/xssmoz.xml#xss")}', $result ); break; case 'XML data island w/CDATA': - $this->assertSame( '<![CDATA[]]>', $result ); + $this->assertEqualHTML( "]]>", $result ); break; case 'XML data island w/comment': - $this->assertSame( "<IMG SRC="javascript:alert('XSS')\">", $result ); + $this->assertEqualHTML( "<IMG SRC="javascript:alert('XSS')\">", $result ); break; case 'XML HTML+TIME': - $this->assertSame( '<t:set attributeName="innerHTML" to="XSSalert(\'XSS\')">', $result ); + $this->assertEqualHTML( '<t:set attributeName="innerHTML" to="XSSalert(\'XSS\')">', $result ); break; case 'Commented-out Block': - $this->assertSame( "\nalert('XSS');", $result ); + $this->assertEqualHTML( "\nalert('XSS');", $result ); break; case 'Cookie Manipulation': - $this->assertSame( '<META HTTP-EQUIV="Set-Cookie" Content="USERID=alert(\'XSS\')">', $result ); + $this->assertEqualHTML( '<META HTTP-EQUIV="Set-Cookie" Content="USERID=alert(\'XSS\')">', $result ); break; case 'SSI': - $this->assertSame( '<!--#exec cmd="/bin/echo '', $result ); + $this->assertEqualHTML( '<!--#exec cmd="/bin/echo '', $result ); break; case 'PHP': - $this->assertSame( '<? echo('alert("XSS")\'); ?>', $result ); + $this->assertEqualHTML( '<? echo('alert("XSS")\'); ?>', $result ); break; case 'UTF-7 Encoding': - $this->assertSame( '+ADw-SCRIPT+AD4-alert(\'XSS\');+ADw-/SCRIPT+AD4-', $result ); + $this->assertEqualHTML( '+ADw-SCRIPT+AD4-alert(\'XSS\');+ADw-/SCRIPT+AD4-', $result ); break; case 'Escaping JavaScript escapes': - $this->assertSame( '\";alert(\'XSS\');//', $result ); + $this->assertEqualHTML( '\";alert(\'XSS\');//', $result ); break; case 'STYLE w/broken up JavaScript': - $this->assertSame( '@im\port\'\ja\vasc\ript:alert("XSS")\';', $result ); + $this->assertEqualHTML( '@im\port\'\ja\vasc\ript:alert("XSS")\';', $result ); break; case 'Null Chars 2': - $this->assertSame( '&alert("XSS")', $result ); + $this->assertEqualHTML( '&alert("XSS")', $result ); break; case 'No Closing Script Tag': - $this->assertSame( '<SCRIPT SRC=http://ha.ckers.org/xss.js', $result ); + $this->assertEqualHTML( '<SCRIPT SRC=http://ha.ckers.org/xss.js', $result ); break; case 'Half-Open HTML/JavaScript': - $this->assertSame( '<IMG SRC="javascript:alert('XSS')"', $result ); + $this->assertEqualHTML( '<IMG SRC="javascript:alert('XSS')"', $result ); break; case 'Double open angle brackets': - $this->assertSame( '<IFRAME SRC=http://ha.ckers.org/scriptlet.html <', $result ); + $this->assertEqualHTML( '<IFRAME SRC=http://ha.ckers.org/scriptlet.html <', $result ); break; case 'Extraneous Open Brackets': - $this->assertSame( '<alert("XSS");//<', $result ); + $this->assertEqualHTML( '<alert("XSS");//<', $result ); break; case 'Malformed IMG Tags': - $this->assertSame( 'alert("XSS")">', $result ); + $this->assertEqualHTML( 'alert("XSS")">', $result ); break; case 'No Quotes/Semicolons': - $this->assertSame( "a=/XSS/\nalert(a.source)", $result ); + $this->assertEqualHTML( "a=/XSS/\nalert(a.source)", $result ); break; case 'Evade Regex Filter 1': - $this->assertSame( '" SRC="http://ha.ckers.org/xss.js">', $result ); + $this->assertEqualHTML( '" SRC="http://ha.ckers.org/xss.js">', $result ); break; case 'Evade Regex Filter 4': - $this->assertSame( '\'" SRC="http://ha.ckers.org/xss.js">', $result ); + $this->assertEqualHTML( '\'" SRC="http://ha.ckers.org/xss.js">', $result ); break; case 'Evade Regex Filter 5': - $this->assertSame( '` SRC="http://ha.ckers.org/xss.js">', $result ); + $this->assertEqualHTML( '` SRC="http://ha.ckers.org/xss.js">', $result ); break; case 'Filter Evasion 1': - $this->assertSame( 'document.write("<SCRI");PT SRC="http://ha.ckers.org/xss.js">', $result ); + $this->assertEqualHTML( 'document.write("<SCRI");PT SRC="http://ha.ckers.org/xss.js">', $result ); break; case 'Filter Evasion 2': - $this->assertSame( '\'>" SRC="http://ha.ckers.org/xss.js">', $result ); + $this->assertEqualHTML( '\'>" SRC="http://ha.ckers.org/xss.js">', $result ); break; default: $this->fail( 'KSES failed on ' . $attack->name . ': ' . $result ); @@ -854,7 +854,8 @@ public function test_wp_kses_normalize_entities( string $input, string $expected public function test_ctrl_removal( $content, $expected ) { global $allowedposttags; - return $this->assertEqualHTML( $expected, wp_kses( $content, $allowedposttags ) ); + // It also must explicitly escape the C0 control characters. + $this->assertSame( $expected, wp_kses( $content, $allowedposttags ) ); } public function data_ctrl_removal() { @@ -875,9 +876,15 @@ public function data_ctrl_removal() { "\x1Fh\x1Ee\x1Dl\x1Cl\x1Bo\x1A \x19w\x18o\x17r\x16l\x15d\x14.\x13 \x12W\x11O\x10R\x0FD\x0EP\x0CR\x0BE\x08S\x07S\x06 \x05K\x04S\X03E\x02S\x01.\x00/", 'hello world. WORDPRESS KSES./', ), + /* + * When decoding HTML, all "\r\n" grapheme clusters are converted into "\n" and + * then any remaining "\r" characters are also converted into "\n". This is why + * the two yield different outputs after normalization depending on the order in + * which they appear together. + */ array( "\t\r\n word \n\r\t", - "\t\r\n word \n\r\t", + "\t\n word \n\n\t", ), ); } @@ -891,7 +898,16 @@ public function data_ctrl_removal() { public function test_slash_zero_removal( $content, $expected ) { global $allowedposttags; - return $this->assertEqualHTML( $expected, wp_kses( $content, $allowedposttags ) ); + $with_style = array_merge( + $allowedposttags, + array( + 'style' => array( + 'type' => true, + ), + ) + ); + + return $this->assertEqualHTML( $expected, wp_kses( $content, $with_style ) ); } public function data_slash_zero_removal() { @@ -930,7 +946,7 @@ public function data_slash_zero_removal() { ), array( '', - 'div {background-image:\\0}', + '', ), ); } @@ -2462,11 +2478,11 @@ public function data_wp_kses_object_data_url_with_port_number_allowed() { ), 'url with wrong port number' => array( '', - '', + '', ), 'url without port number' => array( '', - '', + '', ), ); } diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php index 06d46b1736494..9a428acf55ba7 100644 --- a/tests/phpunit/tests/media.php +++ b/tests/phpunit/tests/media.php @@ -4704,7 +4704,7 @@ public function test_wp_filter_content_tags_does_not_lazy_load_first_image_in_bl $_wp_current_template_content = ''; $html = get_the_block_template_html(); - $this->assertSame( '
' . $expected_content . '
', $html ); + $this->assertEqualHTML( '
' . $expected_content . '
', $html ); } /** @@ -4774,7 +4774,7 @@ static function ( $attr ) { $_wp_current_template_content = ' '; $html = get_the_block_template_html(); - $this->assertSame( '
' . $expected_featured_image . '
' . $expected_content . '
', $html ); + $this->assertEqualHTML( '
' . $expected_featured_image . '
' . $expected_content . '
', $html ); } /** @@ -4833,7 +4833,7 @@ public function test_wp_filter_content_tags_does_not_lazy_load_images_in_header( $expected_template_content .= '
' . wp_img_tag_add_loading_optimization_attrs( $footer_img, 'force-lazy' ) . '
'; $html = get_the_block_template_html(); - $this->assertSame( '
' . $expected_template_content . '
', $html ); + $this->assertEqualHTML( '
' . $expected_template_content . '
', $html ); } /** @@ -5946,7 +5946,7 @@ static function ( $atts ) { // Cleanup. remove_shortcode( 'full_image' ); - $this->assertSame( $expected_content, $content ); + $this->assertEqualHTML( $expected_content, $content ); } /** @@ -6100,7 +6100,7 @@ static function ( $matches ) { remove_shortcode( 'full_image' ); unregister_block_type( 'core/full-image-shortcode' ); - $this->assertSame( $expected_content, $content ); + $this->assertEqualHTML( $expected_content, $content ); } private function reset_content_media_count() { diff --git a/tests/phpunit/tests/post/output.php b/tests/phpunit/tests/post/output.php index c1d04303161ab..94d33597c5d21 100644 --- a/tests/phpunit/tests/post/output.php +++ b/tests/phpunit/tests/post/output.php @@ -147,7 +147,7 @@ public function test_the_content_attribute_filtering() { $this->assertTrue( have_posts() ); $this->assertNull( the_post() ); - $this->assertSame( strip_ws( $expected ), strip_ws( get_echo( 'the_content' ) ) ); + $this->assertEqualHTML( strip_ws( $expected ), strip_ws( get_echo( 'the_content' ) ) ); kses_remove_filters(); } diff --git a/tests/phpunit/tests/post/wpPublishPost.php b/tests/phpunit/tests/post/wpPublishPost.php index 25fa87a71c91d..a8aa4b35f0d71 100644 --- a/tests/phpunit/tests/post/wpPublishPost.php +++ b/tests/phpunit/tests/post/wpPublishPost.php @@ -92,7 +92,7 @@ public function test_wp_update_post_with_content_filtering() { ) ); $post = get_post( $post_id ); - $this->assertSame( '', $post->post_title ); + $this->assertSame( 'Talking about: ', $post->post_title ); $this->assertSame( 'draft', $post->post_status ); kses_init_filters(); diff --git a/tests/phpunit/tests/privacy/wpPrivacyGeneratePersonalDataExportGroupHtml.php b/tests/phpunit/tests/privacy/wpPrivacyGeneratePersonalDataExportGroupHtml.php index 4a2ddd6a4e0e7..81eeba9933482 100644 --- a/tests/phpunit/tests/privacy/wpPrivacyGeneratePersonalDataExportGroupHtml.php +++ b/tests/phpunit/tests/privacy/wpPrivacyGeneratePersonalDataExportGroupHtml.php @@ -174,7 +174,7 @@ public function test_disallowed_html_is_stripped() { array( 'scripts' => array( 'name' => 'Script tags are not allowed.', - 'value' => '', + 'value' => 'BeforeAfter', ), 'images' => array( 'name' => 'Images are not allowed', @@ -187,7 +187,7 @@ public function test_disallowed_html_is_stripped() { $actual = wp_privacy_generate_personal_data_export_group_html( $data, 'test-data-group', 2 ); $this->assertStringNotContainsString( $data['items'][0]['scripts']['value'], $actual ); - $this->assertStringContainsString( 'Testing that script tags are stripped.', $actual ); + $this->assertStringContainsString( 'BeforeAfter', $actual ); $this->assertStringNotContainsString( $data['items'][0]['images']['value'], $actual ); $this->assertStringContainsString( 'Images are not allowed', $actual ); diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 4dd0b60172cb4..daa0709ff8ee6 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -1843,16 +1843,16 @@ public static function data_attachment_roundtrip_as_author() { // Expected returned values. array( 'title' => array( - 'raw' => 'div strong oh noes', - 'rendered' => 'div strong oh noes', + 'raw' => 'div strong ', + 'rendered' => 'div strong', ), 'description' => array( - 'raw' => '
div
strong oh noes', - 'rendered' => "
div
\n

strong oh noes

", + 'raw' => '
div
strong ', + 'rendered' => "
div
\n

strong

", ), 'caption' => array( - 'raw' => '
div
strong oh noes', - 'rendered' => "
div
\n

strong oh noes

", + 'raw' => '
div
strong ', + 'rendered' => "
div
\n

strong

", ), ), ), @@ -1897,16 +1897,16 @@ public function test_attachment_roundtrip_as_editor_unfiltered_html() { ), array( 'title' => array( - 'raw' => 'div strong oh noes', - 'rendered' => 'div strong oh noes', + 'raw' => 'div strong ', + 'rendered' => 'div strong', ), 'description' => array( - 'raw' => '
div
strong oh noes', - 'rendered' => "
div
\n

strong oh noes

", + 'raw' => '
div
strong ', + 'rendered' => "
div
\n

strong

", ), 'caption' => array( - 'raw' => '
div
strong oh noes', - 'rendered' => "
div
\n

strong oh noes

", + 'raw' => '
div
strong ', + 'rendered' => "
div
\n

strong

", ), ) ); diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 7162b278839d5..0ca5307011ea4 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -3122,8 +3122,8 @@ public function test_comment_roundtrip_as_editor_unfiltered_html() { ), array( 'content' => array( - 'raw' => 'div strong oh noes', - 'rendered' => '

div strong oh noes

', + 'raw' => 'div strong ', + 'rendered' => '

div strong

', ), 'author_name' => 'div strong', 'author_user_agent' => 'div strong', diff --git a/tests/phpunit/tests/rest-api/rest-posts-controller.php b/tests/phpunit/tests/rest-api/rest-posts-controller.php index 212ddde70dd83..2f797cba68b40 100644 --- a/tests/phpunit/tests/rest-api/rest-posts-controller.php +++ b/tests/phpunit/tests/rest-api/rest-posts-controller.php @@ -4665,16 +4665,16 @@ public static function data_post_roundtrip_as_author() { // Expected returned values. array( 'title' => array( - 'raw' => 'div strong oh noes', - 'rendered' => 'div strong oh noes', + 'raw' => 'div strong ', + 'rendered' => 'div strong', ), 'content' => array( - 'raw' => '
div
strong oh noes', - 'rendered' => "
div
\n

strong oh noes

", + 'raw' => '
div
strong ', + 'rendered' => "
div
\n

strong

", ), 'excerpt' => array( - 'raw' => '
div
strong oh noes', - 'rendered' => "
div
\n

strong oh noes

", + 'raw' => '
div
strong ', + 'rendered' => "
div
\n

strong

", ), ), ), @@ -4717,8 +4717,8 @@ public function test_post_roundtrip_as_editor_unfiltered_html() { ), array( 'title' => array( - 'raw' => 'div strong oh noes', - 'rendered' => 'div strong oh noes', + 'raw' => 'div strong ', + 'rendered' => 'div strong', ), 'content' => array( 'raw' => '
div
strong oh noes', diff --git a/tests/phpunit/tests/rest-api/rest-tags-controller.php b/tests/phpunit/tests/rest-api/rest-tags-controller.php index 3b23135c93706..6104892a09f7e 100644 --- a/tests/phpunit/tests/rest-api/rest-tags-controller.php +++ b/tests/phpunit/tests/rest-api/rest-tags-controller.php @@ -1104,7 +1104,7 @@ public function test_tag_roundtrip_as_editor_html() { ), array( 'name' => 'div strong', - 'description' => 'div strong oh noes', + 'description' => 'div strong ', ) ); } else { @@ -1116,7 +1116,7 @@ public function test_tag_roundtrip_as_editor_html() { ), array( 'name' => 'div strong', - 'description' => 'div strong oh noes', + 'description' => 'div strong ', ) ); } @@ -1149,7 +1149,7 @@ public function test_tag_roundtrip_as_superadmin_html() { ), array( 'name' => 'div strong', - 'description' => 'div strong oh noes', + 'description' => 'div strong ', ) ); } diff --git a/tests/phpunit/tests/rest-api/rest-users-controller.php b/tests/phpunit/tests/rest-api/rest-users-controller.php index 86ec4b8048551..cd4372c50b073 100644 --- a/tests/phpunit/tests/rest-api/rest-users-controller.php +++ b/tests/phpunit/tests/rest-api/rest-users-controller.php @@ -2387,7 +2387,7 @@ public function test_user_roundtrip_as_editor_html() { 'first_name' => 'div strong', 'last_name' => 'div strong', 'url' => 'http://divdiv/div%20strongstrong/strong%20scriptoh%20noes/script', - 'description' => 'div strong oh noes', + 'description' => 'div strong ', 'nickname' => 'div strong', 'password' => '
div
strong ', ) @@ -2410,7 +2410,7 @@ public function test_user_roundtrip_as_editor_html() { 'first_name' => 'div strong', 'last_name' => 'div strong', 'url' => 'http://divdiv/div%20strongstrong/strong%20scriptoh%20noes/script', - 'description' => 'div strong oh noes', + 'description' => 'div strong ', 'nickname' => 'div strong', 'password' => '
div
strong ', ) @@ -2469,7 +2469,7 @@ public function test_user_roundtrip_as_superadmin_html() { 'first_name' => 'div strong', 'last_name' => 'div strong', 'url' => 'http://divdiv/div%20strongstrong/strong%20scriptoh%20noes/script', - 'description' => 'div strong oh noes', + 'description' => 'div strong ', 'nickname' => 'div strong', 'password' => '
div
strong ', ) diff --git a/tests/phpunit/tests/rest-api/rest-widgets-controller.php b/tests/phpunit/tests/rest-api/rest-widgets-controller.php index 1a9e09dfdefa4..ed9e252dd0937 100644 --- a/tests/phpunit/tests/rest-api/rest-widgets-controller.php +++ b/tests/phpunit/tests/rest-api/rest-widgets-controller.php @@ -1254,7 +1254,7 @@ public function test_update_item_shouldnt_require_id_base() { public function test_store_html_as_admin() { if ( is_multisite() ) { $this->assertSame( - '
alert(1)
', + '
', $this->update_text_widget_with_raw_html( '' ) ); } else { diff --git a/tests/phpunit/tests/rest-api/wpRestUrlDetailsController.php b/tests/phpunit/tests/rest-api/wpRestUrlDetailsController.php index f39d9eb67e88f..134e6dc5011fc 100644 --- a/tests/phpunit/tests/rest-api/wpRestUrlDetailsController.php +++ b/tests/phpunit/tests/rest-api/wpRestUrlDetailsController.php @@ -747,7 +747,7 @@ public function test_get_description( $html, $expected ) { $method = $this->get_reflective_method( 'get_description' ); $actual = $method->invoke( $controller, $meta_elements ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } /** From f89c6b85c0d8f781fbdcc7c97f5013a40b2af9e6 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Wed, 26 Aug 2026 16:17:25 -0700 Subject: [PATCH 12/17] Because the title was emptied, the post update failed entirely, leaving the original content. --- tests/phpunit/tests/post/wpPublishPost.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/post/wpPublishPost.php b/tests/phpunit/tests/post/wpPublishPost.php index a8aa4b35f0d71..2f0818afae9c6 100644 --- a/tests/phpunit/tests/post/wpPublishPost.php +++ b/tests/phpunit/tests/post/wpPublishPost.php @@ -88,7 +88,7 @@ public function test_wp_update_post_with_content_filtering() { $post_id = wp_insert_post( array( - 'post_title' => '', + 'post_title' => 'Talking about: ', ) ); $post = get_post( $post_id ); @@ -107,7 +107,7 @@ public function test_wp_update_post_with_content_filtering() { kses_remove_filters(); $post = get_post( $post->ID ); - $this->assertSame( 'Test', $post->post_title ); + $this->assertSame( 'Talking about: ', $post->post_title ); } /** From 60419a03585684313482d2a45441551fb20a5c8a Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Thu, 27 Aug 2026 22:32:33 -0700 Subject: [PATCH 13/17] Tests: CSS should convey the given values, even if it corrupts the CSS. From c9ea2839e948d8d6fe34a19c8aeaff810e01c32a Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 25 Aug 2026 23:54:27 -0700 Subject: [PATCH 14/17] WidgetMediaImage test spec compliance --- tests/phpunit/tests/widgets/wpWidgetMediaImage.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/widgets/wpWidgetMediaImage.php b/tests/phpunit/tests/widgets/wpWidgetMediaImage.php index 934adab6d50c9..499b3cd595da7 100644 --- a/tests/phpunit/tests/widgets/wpWidgetMediaImage.php +++ b/tests/phpunit/tests/widgets/wpWidgetMediaImage.php @@ -239,10 +239,10 @@ public function test_update() { $instance ); $this->assertSame( - $result, array( - 'caption' => '">', - ) + 'caption' => '">', + ), + $result ); // Should return valid alt text. From ad889894dcb3f6393b07c9ae51106ad4e7f82e47 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 25 Aug 2026 18:32:10 -0700 Subject: [PATCH 15/17] There are no self-closing HTML elements. (OBJECT) --- tests/phpunit/tests/kses.php | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 03ffb828aa023..53140145db489 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -2363,7 +2363,7 @@ public function data_wp_kses_object_tag_allowed() { ), 'invalid value for type' => array( '', - '', + '', ), 'multiple type attributes, last invalid' => array( '', @@ -2379,39 +2379,39 @@ public function data_wp_kses_object_tag_allowed() { ), 'multiple type attributes, first invalid' => array( '', - '', + '', ), 'multiple type attributes, first upper case and invalid' => array( '', - '', + '', ), 'multiple type attributes, first invalid, last uppercase' => array( '', - '', + '', ), 'multiple object tags, last invalid' => array( '', - '', + '', ), 'multiple object tags, first invalid' => array( '', - '', + '', ), 'type attribute with partially incorrect value' => array( '', - '', + '', ), 'type attribute with empty value' => array( '', - '', + '', ), 'type attribute with no value' => array( '', - '', + '', ), 'no type attribute' => array( '', - '', + '', ), 'different protocol in url' => array( '', @@ -2419,27 +2419,27 @@ public function data_wp_kses_object_tag_allowed() { ), 'query string on url' => array( '', - '', + '', ), 'fragment on url' => array( '', - '', + '', ), 'wrong extension' => array( '', - '', + '', ), 'protocol-relative url' => array( '', - '', + '', ), 'unsupported protocol' => array( '', - '', + '', ), 'relative url' => array( '', - '', + '', ), 'url with port number-like path' => array( '', From 4ecffe0c1ee251f5c21e0ef5eaa451a9032579be Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Thu, 27 Aug 2026 21:30:00 -0700 Subject: [PATCH 16/17] Update tests for HTML Processor normalization --- tests/phpunit/tests/widgets/wpWidgetMediaImage.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/widgets/wpWidgetMediaImage.php b/tests/phpunit/tests/widgets/wpWidgetMediaImage.php index 499b3cd595da7..568f032d70857 100644 --- a/tests/phpunit/tests/widgets/wpWidgetMediaImage.php +++ b/tests/phpunit/tests/widgets/wpWidgetMediaImage.php @@ -240,7 +240,7 @@ public function test_update() { ); $this->assertSame( array( - 'caption' => '">', + 'caption' => '">', ), $result ); From 47a8b285d2a48ef4e7d24fb28626565476e1d2ef Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Thu, 27 Aug 2026 22:16:55 -0700 Subject: [PATCH 17/17] Remove early-abort for missing required attributes. --- src/wp-includes/kses.php | 24 ++---------------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 044d72ad7e6e3..663002f2636fa 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1280,29 +1280,9 @@ public static function sanitize( $content, $allowed_html, $allowed_protocols ) { // Check for required attributes. $required_attributes = array(); foreach ( $element_attributes as $name => $spec ) { - $is_required = true === ( $spec['required'] ?? false ); - if ( ! $is_required ) { - continue; - } - - if ( ! in_array( $name, $attribute_names, true ) ) { - if ( $has_no_closer ) { - $was_at = $here->start + $here->length; - break 2; - } - - /* - * Since this processor cannot track nesting of HTML elements - * generally, leave opening tags when required attributes are - * missing, but strip them of their attributes. - */ - $output .= substr( $self->html, $was_at, $here->start - $was_at ); - $output .= "<{$tag_name}{$non_html_self_closer}>"; - $was_at = $here->start + $here->length; - break 2; + if ( true === ( $spec['required'] ?? false ) ) { + $required_attributes[ $name ] = true; } - - $required_attributes[ $name ] = true; } /*