From f59e69b612abeadfed3e3bd9383a70a8822b82d8 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 25 Aug 2026 12:45:53 -0700 Subject: [PATCH 01/27] KSES: Reimplement with Tag Processor Co-Authored-By: Jon Surrell --- src/wp-includes/kses.php | 435 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 434 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index f6ef8a1ed194f..6439c61896d0d 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( (string) $content, $allowed_html, $allowed_protocols ); + if ( empty( $allowed_protocols ) ) { $allowed_protocols = wp_allowed_protocols(); } @@ -970,6 +973,436 @@ 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 7.2.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() + * 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() ) { + // Remove filters built for legacy `wp_kses()`. + $had_pre_kses_less_than = has_filter( 'pre_kses', 'wp_pre_kses_less_than' ); + $had_pre_kses_block_attributes = has_filter( 'pre_kses', 'wp_pre_kses_block_attributes' ); + + if ( $had_pre_kses_less_than ) { + remove_filter( 'pre_kses', 'wp_pre_kses_less_than' ); + } + + if ( $had_pre_kses_block_attributes ) { + remove_filter( 'pre_kses', 'wp_pre_kses_block_attributes' ); + } + + $allowed_html = is_array( $allowed_html ) + ? $allowed_html + : wp_kses_allowed_html( $allowed_html ); + + $allowed_protocols = empty( $allowed_protocols ) + ? wp_allowed_protocols() + : $allowed_protocols; + + // Call legacy pre-kses filters that might have been added by plugins. + $content = wp_kses_hook( $content, $allowed_html, $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 should potentially be removed. + */ + $content = wp_kses_stripslashes( $content ); + + $processor = new class( $content, $allowed_html, $allowed_protocols ) extends WP_HTML_Tag_Processor { + private $allowed_html; + + 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() { + $this->set_bookmark( 'here' ); + + if ( ! isset( $this->bookmarks['here'] ) ) { + return null; + } + + return $this->bookmarks['here']; + } + + public function set_attribute( $name, $value ): bool { + $lower_name = strtolower( $name ); + $is_url_ish = in_array( $lower_name, $this->uris, true ); + + if ( ! $is_url_ish || ! is_string( $value ) ) { + return parent::set_attribute( $name, $value ); + } + + $escaped = wp_kses_bad_protocol( $value, $this->allowed_protocols ); + $escaped = strtr( + $escaped, + array( + '<' => '<', + '>' => '>', + '&' => '&', + '"' => '"', + "'" => ''', + ) + ); + /** This filter is documented in wp-includes/formatting.php */ + $escaped = apply_filters( 'attribute_escape', $escaped, $value ); + + // Set a benign placeholder to replace below. + if ( ! parent::set_attribute( $name, true ) ) { + return false; + } + + $this->lexical_updates[ $lower_name ]->text = " {$lower_name}=\"{$escaped}\""; + + return true; + } + + /** + * Returns a sanitized copy of the input HTML. + * + * @return string Sanitized copy of given input HTML. + */ + public function sanitize() { + $template_depth = 0; + $output = ''; + + /** + * 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(); + $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 ) { + if ( $template_depth > 0 && $is_closer ) { + --$template_depth; + } elseif ( ! $is_closer ) { + ++$template_depth; + } + } + + if ( $template_depth > 0 && ! isset( $this->allowed_html['template'] ) ) { + continue; + } + + switch ( $token_type ) { + case '#text': + $text = strtr( + $text, + array( + '<' => '<', + '&' => '&', + '>' => '>', + /* + * 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. + */ + // "'" => ''', + // '"' => '"', + ) + ); + + $output .= $text; + break; + + /* + * Untrusted sources should not be creating these kinds of tokens, + * so remove them entirely from the output. + */ + case '#doctype': + case '#presumptuous-tag': + case '#processing-instruction': + break; + + /* + * It’s questionable whether these should be allowed through, but + * the legacy behavior supports it. Therefore, allow them as long + * as they don’t contain potentially confusing syntax characters. + */ + case '#funky-comment': + if ( ! str_contains( $text, '<' ) ) { + $output .= substr( $this->html, $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': + /* + * There are several kinds of malformed HTML which are handled by interpreting + * them as HTML comments. For example, `` is called a “bogus comment” by the + * HTML specification, but when loaded by a browser is equivalent to ``. + * In this way, interacting with the DOM via JavaScript differs from handling + * the textual representation of a page in PHP. + * + * Ignore these non-normative comment forms to protect downstream parsers which + * might not be expecting their kinds of syntax. This prevents mis-parses for + * code which over-simplifies HTML parsing. + */ + if ( WP_HTML_Tag_Processor::COMMENT_AS_HTML_COMMENT !== $this->get_comment_type() ) { + break; + } + + // 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(); + $block_type = str_starts_with( $block_type, 'core/' ) + ? substr( $block_type, /* 'core/' */ 5 ) + : $block_type; + + $filtered_attributes = filter_block_kses_value( + $original_attributes, + $this->allowed_html, + $this->allowed_protocols, + array( 'blockName' => $block_type ) + ); + + if ( $original_attributes !== $filtered_attributes ) { + $serialized_attributes = serialize_block_attributes( $filtered_attributes ); + $voider = WP_Block_Processor::VOID === $block_processor->get_delimiter_type() ? '/' : ''; + $text = " wp:{$block_type} {$serialized_attributes} {$voider}"; + } + } + } + + $output .= ""; + break; + + /* + * 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. + */ + case '#cdata-section': + $output .= substr( $this->html, $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 ] ) ) { + if ( 'TEMPLATE' === $token_name && ! $is_closer ) { + $this->skip_opened_template(); + } + + break; + } + + if ( $is_closer ) { + $output .= ""; + break; + } + + $is_special_atomic_element = in_array( $token_name, $special_atomic_elements, true ); + + $expects_closer = ! ( + WP_HTML_Processor::is_void( $token_name ) || + $is_special_atomic_element + ); + + $closing_tag = $is_special_atomic_element ? "" : ''; + + $attribute_names = $this->get_attribute_names_with_prefix( '' ); + $element_attributes = $this->allowed_html[ $tag_name ]; + + // Check for required attributes. + $required_attributes = array(); + if ( is_array( $element_attributes ) ) { + foreach ( $element_attributes as $name => $spec ) { + if ( true === ( $spec['required'] ?? false ) ) { + $required_attributes[ $name ] = true; + } + } + } + + /* + * 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-*'] ) ) { + if ( is_array( $attribute_names ) ) { + 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 self( + "<{$tag_name}>{$closing_tag}", + $this->allowed_html, + $this->allowed_protocols + ); + $tag_maker->next_token(); + if ( is_array( $attribute_names ) ) { + 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; + } + + $raw_value = $this->get_attribute( $name ); + $value = is_string( $raw_value ) ? $raw_value : ''; + + // Process the style attribute through CSS sanitization. + if ( 'style' === $name && is_string( $raw_value ) ) { + $style = safecss_filter_attr( $value ); + + if ( '' !== trim( $style ) ) { + $tag_maker->set_attribute( 'style', $style ); + unset( $required_attributes['style'] ); + } + continue; + } + + /* + * Process the remaining attributes according to their policies. + * + * Non-array values for the attribute specification are assumed + * to be `true`, thus permitting the attribute. + */ + if ( is_array( $spec ) ) { + foreach ( $spec as $property => $constraint ) { + $vless = true === $raw_value ? 'y' : 'n'; + + if ( ! wp_kses_check_attr_val( $value, $vless, $property, $constraint ) ) { + continue 2; + } + } + } + + if ( true === $raw_value && '' === $value ) { + $tag_maker->set_attribute( $name, true ); + } else { + $tag_maker->set_attribute( $name, $value ); + } + unset( $required_attributes[ $name ] ); + } + } + + if ( ! empty( $required_attributes ) ) { + if ( ! $expects_closer ) { + 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 .= "<{$tag_name}>"; + break; + } + + if ( $is_special_atomic_element ) { + $tag_maker->set_modifiable_text( $text ); + } + + $output .= $tag_maker->get_updated_html(); + 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; + } + }; + + $sanitized = $processor->sanitize(); + + // Restore filters built for legacy `wp_kses()`. + if ( $had_pre_kses_less_than ) { + add_filter( 'pre_kses', 'wp_pre_kses_less_than' ); + } + + if ( $had_pre_kses_block_attributes ) { + add_filter( 'pre_kses', 'wp_pre_kses_block_attributes', 10, 3 ); + } + + return $sanitized; +} + /** * Filters one HTML attribute and ensures its value is allowed. * From 9aa68e079f2856b2530811d3a0aae80d4084cad7 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 8 Sep 2026 15:16:53 -0500 Subject: [PATCH 02/27] Strip C0 control characters from entire input stream. --- src/wp-includes/kses.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 6439c61896d0d..64d294502b712 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1009,6 +1009,9 @@ function wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols = ar ? wp_allowed_protocols() : $allowed_protocols; + // Preserve legacy behavior of stripping unwanted C0 control characters. + $content = preg_replace( '/[\x01-\x08\x0B\x0C\x0E-\x1F]/', '', $content ); + // Call legacy pre-kses filters that might have been added by plugins. $content = wp_kses_hook( $content, $allowed_html, $allowed_protocols ); From 35d2154fbb036f3511cd1cc07fa73c8e4ed6ab1d Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 8 Sep 2026 14:47:48 -0500 Subject: [PATCH 03/27] New filter `wp_kses_force_legacy_parser` for testing side-by-side. --- src/wp-includes/default-filters.php | 1 + src/wp-includes/kses.php | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 025a371781200..94ccca4c23b62 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -306,6 +306,7 @@ add_filter( 'teeny_mce_before_init', '_mce_set_direction' ); add_filter( 'pre_kses', 'wp_pre_kses_less_than' ); add_filter( 'pre_kses', 'wp_pre_kses_block_attributes', 10, 3 ); +add_filter( 'wp_kses_force_legacy_parser', '__return_false' ); add_filter( 'sanitize_title', 'sanitize_title_with_dashes', 10, 3 ); add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); add_filter( 'comment_flood_filter', 'wp_throttle_comment_flood', 10, 3 ); diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 64d294502b712..5182985acd83f 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -960,7 +960,17 @@ * @return string Filtered content containing only the allowed HTML. */ function wp_kses( $content, $allowed_html, $allowed_protocols = array() ) { - return wp_sanitize_html_kses( (string) $content, $allowed_html, $allowed_protocols ); + /** + * Filters whether to rely on the legacy parsing inside `wp_kses()`. + * + * @since 7.2.0 + * + * @param bool $force_legacy_parser Whether to force using the legacy parser + * instead of relying on the HTML API. + */ + if ( ! apply_filters( 'wp_kses_force_legacy_parser', true ) ) { + return wp_sanitize_html_kses( (string) $content, $allowed_html, $allowed_protocols ); + } if ( empty( $allowed_protocols ) ) { $allowed_protocols = wp_allowed_protocols(); From 6180a636bd2cb656d2cca7baaa5d3b06dde62b3c Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Mon, 31 Aug 2026 11:34:09 -0700 Subject: [PATCH 04/27] Prevent creating a block delimiter through comment normalization. --- src/wp-includes/kses.php | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 5182985acd83f..ada2c384d3e88 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1028,9 +1028,9 @@ function wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols = ar /* * 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 should potentially be removed. + * to parse HTML. Given that this corrupts text, it will be skipped. */ - $content = wp_kses_stripslashes( $content ); + //$content = wp_kses_stripslashes( $content ); $processor = new class( $content, $allowed_html, $allowed_protocols ) extends WP_HTML_Tag_Processor { private $allowed_html; @@ -1232,7 +1232,24 @@ public function sanitize() { } } - $output .= ""; + /* + * Ensure that normalization does not create a block where none + * previously existed. Should this be the case, there are two + * options: leave the incorrect-closed-comment in place; or + * remove the entire comment. + * + * For the sake of sanitization, remove the comment entirely. + */ + $was_incorrectly_closed = '!' === $comment[ strlen( $comment ) - 2 ]; + $normalized = ""; + if ( $was_incorrectly_closed ) { + $block_processor = new WP_Block_Processor( $normalized ); + if ( $block_processor->next_token() && ! $block_processor->is_html() ) { + break; + } + } + + $output .= $normalized; break; /* From 8ad51c51e092735e1b385676ab29dab8adaa3dff Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Wed, 16 Sep 2026 09:42:28 -0500 Subject: [PATCH 05/27] Reject incorrectly-closed comments entirely. --- src/wp-includes/kses.php | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index ada2c384d3e88..e6b3642b8523f 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1206,7 +1206,18 @@ public function sanitize() { } // Apply special filtering for block comment delimiters with JSON attributes. - $comment = substr( $this->html, $here->start, $here->length ); + $comment = substr( $this->html, $here->start, $here->length ); + + /* + * A comment like `` still appears as a normative HTML comment, + * but as an incorrectly-closed comment. Ignore these as well, as part of only + * allowing normative comment contents. + */ + $was_incorrectly_closed = '!' === $comment[ strlen( $comment ) - 2 ]; + if ( $was_incorrectly_closed ) { + break; + } + $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(); @@ -1233,23 +1244,15 @@ public function sanitize() { } /* - * Ensure that normalization does not create a block where none - * previously existed. Should this be the case, there are two - * options: leave the incorrect-closed-comment in place; or - * remove the entire comment. - * - * For the sake of sanitization, remove the comment entirely. + * 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. */ - $was_incorrectly_closed = '!' === $comment[ strlen( $comment ) - 2 ]; - $normalized = ""; - if ( $was_incorrectly_closed ) { - $block_processor = new WP_Block_Processor( $normalized ); - if ( $block_processor->next_token() && ! $block_processor->is_html() ) { - break; - } - } + $text = strtr( $text, array( '<' => '<' ) ); - $output .= $normalized; + $output .= ""; break; /* From f21ff1e4b708157bfb9f51c13a0deb6247f8922b Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Sun, 30 Aug 2026 21:34:13 -0700 Subject: [PATCH 06/27] Process foreign content conservatively --- src/wp-includes/kses.php | 228 +++++++++++++++++++++++++++++++++++---- 1 file changed, 206 insertions(+), 22 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index e6b3642b8523f..d92bf363dfda0 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1089,14 +1089,149 @@ public function set_attribute( $name, $value ): bool { return true; } + private function could_potentially_escape_foreign_content() { + $token_name = $this->get_token_name(); + $is_closer = $this->is_tag_closer(); + $namespace = $this->get_namespace(); + $self_closing = ! $is_closer && $this->has_self_closing_flag(); + + if ( + ! $is_closer && + in_array( + $token_name, + array( + 'B', + 'BIG', + 'BLOCKQUOTE', + 'BODY', + 'BR', + 'CENTER', + 'CODE', + 'DD', + 'DIV', + 'DL', + 'DT', + 'EM', + 'EMBED', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + 'HEAD', + 'HR', + 'I', + 'IMG', + 'LI', + 'LISTING', + 'MENU', + 'META', + 'NOBR', + 'OL', + 'P', + 'PRE', + 'RUBY', + 'S', + 'SMALL', + 'SPAN', + 'STRONG', + 'STRIKE', + 'SUB', + 'SUP', + 'TABLE', + 'TT', + 'U', + 'UL', + 'VAR', + + /* + * This is technically only necessary when it contains one + * of the `color`, `face`, or `size` attributes, but this + * is already a conservative system so it’s okay to reject. + */ + 'FONT', + + /* + * This will be parsed as 'IMG'. (Don’t ask.) + */ + 'IMAGE', + ), + true + ) || + ( + $is_closer && + in_array( + $token_name, + array( + 'BR', + 'P', + ), + true + ) + ) + ) { + return true; + } + + if ( 'math' === $namespace && ! $self_closing ) { + if ( + in_array( + $token_name, + array( + 'MI', + 'MO', + 'MN', + 'MS', + 'MTEXT', + ), + true + ) + ) { + return true; + } + + $encoding = $this->get_attribute( 'encoding' ); + if ( + 'ANNOTATION-XML' === $token_name && + is_string( $encoding ) && + ( + 0 === strcasecmp( $encoding, 'text/html' ) || + 0 === strcasecmp( $encoding, 'application/xhtml+xml' ) + ) + ) { + return true; + } + } + + if ( + 'svg' === $namespace && + ! $is_closer && + in_array( + $token_name, + array( + 'FOREIGNOBJECT', + 'DESC', + 'TITLE', + ), + true + ) + ) { + return true; + } + + return false; + } + /** * Returns a sanitized copy of the input HTML. * * @return string Sanitized copy of given input HTML. */ public function sanitize() { - $template_depth = 0; - $output = ''; + $template_depth = 0; + $output = ''; + $foreign_content_starts_at = PHP_INT_MAX; /** * These are treated as void elements inside the HTML API @@ -1116,21 +1251,23 @@ public function sanitize() { while ( $this->next_token() ) { $token_name = $this->get_token_name(); $token_type = $this->get_token_type(); + $namespace = $this->get_namespace(); $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. + * Enter the foreign content and change the parsing namespace + * so that the parser recognizes real self-closing elements. */ - if ( ! $is_closer && in_array( $token_name, array( 'MATH', 'SVG' ), true ) ) { - return $output; + $is_svg_or_math = 'MATH' === $token_name || 'SVG' === $token_name; + $has_self_closing_flag = ! $is_closer && $this->has_self_closing_flag(); + if ( $is_svg_or_math && ! $is_closer && 'html' === $namespace ) { + $this->change_parsing_namespace( strtolower( $token_name ) ); + $namespace = $this->get_namespace(); } - if ( 'TEMPLATE' === $token_name ) { + if ( 'TEMPLATE' === $token_name && 'html' === $namespace ) { if ( $template_depth > 0 && $is_closer ) { --$template_depth; } elseif ( ! $is_closer ) { @@ -1138,12 +1275,14 @@ public function sanitize() { } } - if ( $template_depth > 0 && ! isset( $this->allowed_html['template'] ) ) { - continue; - } + $skip_token = $template_depth > 0 && ! isset( $this->allowed_html['template'] ); switch ( $token_type ) { case '#text': + if ( $skip_token ) { + break; + } + $text = strtr( $text, array( @@ -1180,7 +1319,7 @@ public function sanitize() { * as they don’t contain potentially confusing syntax characters. */ case '#funky-comment': - if ( ! str_contains( $text, '<' ) ) { + if ( ! $skip_token && ! str_contains( $text, '<' ) ) { $output .= substr( $this->html, $here->start, $here->length ); } break; @@ -1190,6 +1329,10 @@ public function sanitize() { * but the content is benign in a browser. */ case '#comment': + if ( $skip_token ) { + break; + } + /* * There are several kinds of malformed HTML which are handled by interpreting * them as HTML comments. For example, `` is called a “bogus comment” by the @@ -1263,18 +1406,40 @@ public function sanitize() { * that exist for elements in the HTML namespace. Copy the token verbatim. */ case '#cdata-section': - $output .= substr( $this->html, $here->start, $here->length ); + if ( ! $skip_token ) { + $output .= substr( $this->html, $here->start, $here->length ); + } break; case '#tag': + /* + * Any failures inside foreign content should return the part of + * the post processed up until the entrance of the foreign content. + * This is necessary because it’s only inside foreign content that + * the self-closing flag indicates a self-closing element. + * + * While the HTML Processor can enter into SVG and MATH and track + * when they close, it’s substantially more complicated and requires + * considerable accounting. To avoid all of that, and to accept the + * kind of content that is nominal and safe, track only when the + * next tag _could_ lead to implicit changing of the parsing namespace + * or insertion mode. + */ + if ( + 'html' !== $namespace && + $this->could_potentially_escape_foreign_content() + ) { + return substr( $output, 0, $foreign_content_starts_at ); + } + + if ( $skip_token ) { + break; + } + $tag_name = strtolower( $token_name ); // Skip unallowed elements by tag name if ( ! isset( $this->allowed_html[ $tag_name ] ) ) { - if ( 'TEMPLATE' === $token_name && ! $is_closer ) { - $this->skip_opened_template(); - } - break; } @@ -1283,13 +1448,18 @@ public function sanitize() { break; } - $is_special_atomic_element = in_array( $token_name, $special_atomic_elements, true ); + $is_special_atomic_element = ( + 'html' === $namespace && + in_array( $token_name, $special_atomic_elements, true ) + ); $expects_closer = ! ( - WP_HTML_Processor::is_void( $token_name ) || - $is_special_atomic_element + 'html' === $namespace + ? ( WP_HTML_Processor::is_void( $token_name ) || $is_special_atomic_element ) + : $has_self_closing_flag ); + $self_closer = ( 'html' !== $namespace && $has_self_closing_flag ) ? ' /' : ''; $closing_tag = $is_special_atomic_element ? "" : ''; $attribute_names = $this->get_attribute_names_with_prefix( '' ); @@ -1333,7 +1503,7 @@ public function sanitize() { } $tag_maker = new self( - "<{$tag_name}>{$closing_tag}", + "<{$tag_name}{$self_closer}>{$closing_tag}", $this->allowed_html, $this->allowed_protocols ); @@ -1400,6 +1570,15 @@ public function sanitize() { break; } + /* + * Track the opening of the last transition into foreign + * content so that it can be discarded when encountering + * tags that would require more substantial parsing. + */ + if ( $is_svg_or_math ) { + $foreign_content_starts_at = strlen( $output ); + } + if ( $is_special_atomic_element ) { $tag_maker->set_modifiable_text( $text ); } @@ -1407,6 +1586,11 @@ public function sanitize() { $output .= $tag_maker->get_updated_html(); break; } + + // Re-enter the HTML namespace. + if ( $is_svg_or_math && ( $is_closer || $has_self_closing_flag ) && 'html' !== $namespace ) { + $this->change_parsing_namespace( 'html' ); + } } /* From 3195f6948d8742e612a6e77cdbe799be1da91e46 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Mon, 31 Aug 2026 17:35:13 -0700 Subject: [PATCH 07/27] Try: Track a stack in foreign content. --- src/wp-includes/kses.php | 91 ++++++++++++++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 18 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index d92bf363dfda0..443dfd7646c48 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1037,6 +1037,8 @@ function wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols = ar private $allowed_protocols; + private $foreign_content_stack = array(); + private $uris; public function __construct( $html, $allowed_html, $allowed_protocols ) { @@ -1202,6 +1204,19 @@ private function could_potentially_escape_foreign_content() { ) { return true; } + + /* + * When SVG becomes a direct descendant of a MathML ANNOTATION-XML, + * the namespace remains `math` but there could be an SVG element + * with an HTML integration point. Conservatively reject any child + * SVG element inside a MathML ANNOTATION-XML to prevent this. + */ + if ( + 'SVG' === $token_name && + in_array( 'ANNOTATION-XML', $this->foreign_content_stack, true ) + ) { + return true; + } } if ( @@ -1267,15 +1282,53 @@ public function sanitize() { $namespace = $this->get_namespace(); } - if ( 'TEMPLATE' === $token_name && 'html' === $namespace ) { - if ( $template_depth > 0 && $is_closer ) { - --$template_depth; - } elseif ( ! $is_closer ) { - ++$template_depth; + if ( 'html' !== $namespace && '#tag' === $token_type ) { + /* + * Ensure that only well-formed foreign content is allowed. + * Since un-balanced closing tags might implicitly close the + * open foreign-content element, these must be rejected. + */ + if ( $is_closer ) { + $open_element = array_pop( $this->foreign_content_stack ); + if ( null === $open_element || $token_name !== $open_element ) { + return substr( $output, 0, $foreign_content_starts_at ); + } + + /* + * Reset the foreign content tracker so it doesn’t truncate + * unintentionally after foreign content has properly closed. + */ + if ( empty( $this->foreign_content_stack ) ) { + $foreign_content_starts_at = PHP_INT_MAX; + } + } else { + /* + * Track the opening of the last transition into foreign + * content so that it can be discarded when encountering + * tags that would require more substantial parsing. + */ + if ( empty( $this->foreign_content_stack ) ) { + $foreign_content_starts_at = strlen( $output ); + } + + $this->foreign_content_stack[] = $token_name; } } - $skip_token = $template_depth > 0 && ! isset( $this->allowed_html['template'] ); + if ( 'TEMPLATE' === $token_name && 'html' === $namespace && ! $is_closer ) { + ++$template_depth; + } + + $skip_token = ( + ( + $template_depth > 0 && + ! isset( $this->allowed_html['template'] ) + ) || + ( + ! empty( $this->foreign_content_stack ) && + ! isset( $this->allowed_html[ strtolower( $this->foreign_content_stack[0] ) ] ) + ) + ); switch ( $token_type ) { case '#text': @@ -1570,15 +1623,6 @@ public function sanitize() { break; } - /* - * Track the opening of the last transition into foreign - * content so that it can be discarded when encountering - * tags that would require more substantial parsing. - */ - if ( $is_svg_or_math ) { - $foreign_content_starts_at = strlen( $output ); - } - if ( $is_special_atomic_element ) { $tag_maker->set_modifiable_text( $text ); } @@ -1588,8 +1632,19 @@ public function sanitize() { } // Re-enter the HTML namespace. - if ( $is_svg_or_math && ( $is_closer || $has_self_closing_flag ) && 'html' !== $namespace ) { - $this->change_parsing_namespace( 'html' ); + if ( 'html' !== $namespace ) { + if ( $has_self_closing_flag ) { + array_pop( $this->foreign_content_stack ); + } + + if ( empty( $this->foreign_content_stack ) ) { + $this->change_parsing_namespace( 'html' ); + $foreign_content_starts_at = PHP_INT_MAX; + } + } + + if ( 'TEMPLATE' === $token_name && $template_depth > 0 && $is_closer && 'html' === $namespace ) { + --$template_depth; } } @@ -1602,7 +1657,7 @@ public function sanitize() { * of the page’s HTML structure. */ - return $output; + return substr( $output, 0, $foreign_content_starts_at ); } }; From 3e94cace1fc4a3f253d756be06be06d7f2501a07 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Wed, 16 Sep 2026 10:10:56 -0500 Subject: [PATCH 08/27] Allow text data inside MathML text elements (MI, MN, MO, MS, MTEXT) --- src/wp-includes/kses.php | 53 ++++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 443dfd7646c48..53b68518a84dc 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1177,22 +1177,6 @@ private function could_potentially_escape_foreign_content() { } if ( 'math' === $namespace && ! $self_closing ) { - if ( - in_array( - $token_name, - array( - 'MI', - 'MO', - 'MN', - 'MS', - 'MTEXT', - ), - true - ) - ) { - return true; - } - $encoding = $this->get_attribute( 'encoding' ); if ( 'ANNOTATION-XML' === $token_name && @@ -1268,9 +1252,40 @@ public function sanitize() { $token_type = $this->get_token_type(); $namespace = $this->get_namespace(); $is_closer = $this->is_tag_closer(); - $text = $this->get_modifiable_text(); $here = $this->get_span(); + $in_mathml_text = ( + 'math' === $namespace && + in_array( + end( $this->foreign_content_stack ), + array( + 'MI', + 'MN', + 'MO', + 'MS', + 'MTEXT', + ), + true + ) + ); + + /* + * While content inside integration points is generally not allowed here, + * character data inside the MathML text elements _is_ allowed. This is + * because the rules only change slightly: NULL bytes are removed instead + * of being replaced with the Unicode replacement character U+FFFD; and + * active formats are reconstructed. The format reconstruction doesn’t + * occur here but a browser will still do so; this sanitizer is generally + * unaware of nesting structure. + */ + if ( $in_mathml_text && '#text' === $token_type ) { + $this->change_parsing_namespace( 'html' ); + $text = $this->get_modifiable_text(); + $this->change_parsing_namespace( $namespace ); + } else { + $text = $this->get_modifiable_text(); + } + /* * Enter the foreign content and change the parsing namespace * so that the parser recognizes real self-closing elements. @@ -1479,8 +1494,8 @@ public function sanitize() { * or insertion mode. */ if ( - 'html' !== $namespace && - $this->could_potentially_escape_foreign_content() + ( 'html' !== $namespace && $this->could_potentially_escape_foreign_content() ) || + ( $in_mathml_text && ! $is_closer ) ) { return substr( $output, 0, $foreign_content_starts_at ); } From aa604964b633a77e18528e086b5fe1906324d485 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Sun, 30 Aug 2026 17:09:22 -0700 Subject: [PATCH 09/27] Remove unwanted C0 characters from text nodes and attribute values. --- src/wp-includes/kses.php | 84 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 53b68518a84dc..1061f6b961034 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1178,9 +1178,12 @@ private function could_potentially_escape_foreign_content() { if ( 'math' === $namespace && ! $self_closing ) { $encoding = $this->get_attribute( 'encoding' ); + $encoding = is_string( $encoding ) + ? wp_remove_unwanted_c0_controls( $encoding ) + : ''; + if ( 'ANNOTATION-XML' === $token_name && - is_string( $encoding ) && ( 0 === strcasecmp( $encoding, 'text/html' ) || 0 === strcasecmp( $encoding, 'application/xhtml+xml' ) @@ -1351,6 +1354,14 @@ public function sanitize() { break; } + /* + * 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. + */ + $text = wp_remove_unwanted_c0_controls( $text ); + $text = strtr( $text, array( @@ -1588,6 +1599,14 @@ public function sanitize() { $raw_value = $this->get_attribute( $name ); $value = is_string( $raw_value ) ? $raw_value : ''; + /* + * At this point, C0 controls exist in a decoded attribute value, + * and the output will be re-escaped. This means that removing + * these characters cannot join together previously-separated + * syntax characters. + */ + $value = wp_remove_unwanted_c0_controls( $value ); + // Process the style attribute through CSS sanitization. if ( 'style' === $name && is_string( $raw_value ) ) { $style = safecss_filter_attr( $value ); @@ -2749,6 +2768,69 @@ function wp_kses_no_null( $content, $options = null ) { return $content; } +/** + * Removes unwanted C0 control characters from already-un-escaped HTML content, + * where only U+09 (`\t`), U+0A (`\n`), U+0D (`\r`), and U+20 (` `) are wanted. + * + * Note! It’s important to never run this on raw HTML, as that could create + * situations in which two parts of the text were safe while separated + * by the characters, but when joined together through their removal, + * that the two pieces form risky content as a result. + * + * Consider instead escaping the C0 controls with numeric character references, + * which will preserve them on the page render while avoiding potential issues + * from parsers what aren’t expecting them. + * + * Returns the original string when no C0 controls are present. + * + * Example: + * + * 'beforeafter' === wp_remove_c0_controls( "before\x05after" ); + * + * @since {WP_VERSION} + * + * @access private + * + * @param string $decoded_html_content Remove C0 controls from this decoded HTML content. + * @return string Updated content without the C0 control characters. + */ +function wp_remove_unwanted_c0_controls( $decoded_html_content ) { + return strtr( + $decoded_html_content, + array( + "\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" => '', + ) + ); +} + /** * Strips slashes from in front of quotes. * From 6aea7d88650fcea02e6b6e0e0d514ed697802fb3 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 8 Sep 2026 21:45:26 -0500 Subject: [PATCH 10/27] Revert "Remove unwanted C0 characters from text nodes and attribute values." This reverts commit ba325c140f809b4a7b314064a11ddafd24a25dd3. --- src/wp-includes/kses.php | 84 +--------------------------------------- 1 file changed, 1 insertion(+), 83 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 1061f6b961034..53b68518a84dc 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1178,12 +1178,9 @@ private function could_potentially_escape_foreign_content() { if ( 'math' === $namespace && ! $self_closing ) { $encoding = $this->get_attribute( 'encoding' ); - $encoding = is_string( $encoding ) - ? wp_remove_unwanted_c0_controls( $encoding ) - : ''; - if ( 'ANNOTATION-XML' === $token_name && + is_string( $encoding ) && ( 0 === strcasecmp( $encoding, 'text/html' ) || 0 === strcasecmp( $encoding, 'application/xhtml+xml' ) @@ -1354,14 +1351,6 @@ public function sanitize() { break; } - /* - * 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. - */ - $text = wp_remove_unwanted_c0_controls( $text ); - $text = strtr( $text, array( @@ -1599,14 +1588,6 @@ public function sanitize() { $raw_value = $this->get_attribute( $name ); $value = is_string( $raw_value ) ? $raw_value : ''; - /* - * At this point, C0 controls exist in a decoded attribute value, - * and the output will be re-escaped. This means that removing - * these characters cannot join together previously-separated - * syntax characters. - */ - $value = wp_remove_unwanted_c0_controls( $value ); - // Process the style attribute through CSS sanitization. if ( 'style' === $name && is_string( $raw_value ) ) { $style = safecss_filter_attr( $value ); @@ -2768,69 +2749,6 @@ function wp_kses_no_null( $content, $options = null ) { return $content; } -/** - * Removes unwanted C0 control characters from already-un-escaped HTML content, - * where only U+09 (`\t`), U+0A (`\n`), U+0D (`\r`), and U+20 (` `) are wanted. - * - * Note! It’s important to never run this on raw HTML, as that could create - * situations in which two parts of the text were safe while separated - * by the characters, but when joined together through their removal, - * that the two pieces form risky content as a result. - * - * Consider instead escaping the C0 controls with numeric character references, - * which will preserve them on the page render while avoiding potential issues - * from parsers what aren’t expecting them. - * - * Returns the original string when no C0 controls are present. - * - * Example: - * - * 'beforeafter' === wp_remove_c0_controls( "before\x05after" ); - * - * @since {WP_VERSION} - * - * @access private - * - * @param string $decoded_html_content Remove C0 controls from this decoded HTML content. - * @return string Updated content without the C0 control characters. - */ -function wp_remove_unwanted_c0_controls( $decoded_html_content ) { - return strtr( - $decoded_html_content, - array( - "\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" => '', - ) - ); -} - /** * Strips slashes from in front of quotes. * From 5ed9ae2262152d1ec349dcf5ab91afd542eec89a Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 15 Sep 2026 11:59:25 -0500 Subject: [PATCH 11/27] Use a global to avoid costs of removing and adding deprectaed filters. --- src/wp-includes/formatting.php | 16 ++++++++++++ src/wp-includes/kses.php | 46 ++++++++++++++++------------------ 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index faa94c6f6b93c..0d69a466b4c0e 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -5295,10 +5295,18 @@ function wp_parse_str( $input_string, &$result ) { * * @since 2.3.0 * + * @global string $wp_kses_operating_mode Indicates if this filter should run. + * * @param string $content Text to be converted. * @return string Converted text. */ function wp_pre_kses_less_than( $content ) { + global $wp_kses_operating_mode; + + if ( 'legacy' !== ( $wp_kses_operating_mode ?? 'legacy' ) ) { + return $content; + } + return preg_replace_callback( '%<[^>]*?((?=<)|>|$)%', 'wp_pre_kses_less_than_callback', $content ); } @@ -5323,6 +5331,8 @@ function wp_pre_kses_less_than_callback( $matches ) { * * @since 5.3.1 * + * @global string $wp_kses_operating_mode Indicates if this filter should run. + * * @param string $content Content to be run through KSES. * @param array[]|string $allowed_html An array of allowed HTML elements * and attributes, or a context name @@ -5331,6 +5341,12 @@ function wp_pre_kses_less_than_callback( $matches ) { * @return string Filtered text to run through KSES. */ function wp_pre_kses_block_attributes( $content, $allowed_html, $allowed_protocols ) { + global $wp_kses_operating_mode; + + if ( 'legacy' !== ( $wp_kses_operating_mode ?? 'legacy' ) ) { + return $content; + } + /* * `filter_block_content` is expected to call `wp_kses`. Temporarily remove * the filter to avoid recursion. diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 53b68518a84dc..8646d89d8c7fe 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -56,6 +56,14 @@ // (e.g. if using namespaces / autoload in the current PHP environment). global $allowedposttags, $allowedtags, $allowedentitynames, $allowedxmlentitynames; +/** + * Indicates which implementation of {@see \wp_kses()} is running. + * + * @global 'legacy'|'html-api' $wp_kses_operating_mode + */ +global $wp_kses_operating_mode; +$wp_kses_operating_mode = 'legacy'; + if ( ! CUSTOM_TAGS ) { /** * KSES global for default allowable HTML tags. @@ -951,6 +959,8 @@ * * @since 1.0.0 * + * @global string $wp_kses_operating_mode + * * @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()} @@ -960,6 +970,10 @@ * @return string Filtered content containing only the allowed HTML. */ function wp_kses( $content, $allowed_html, $allowed_protocols = array() ) { + global $wp_kses_operating_mode; + + $wp_kses_operating_mode = 'legacy'; + /** * Filters whether to rely on the legacy parsing inside `wp_kses()`. * @@ -990,6 +1004,8 @@ function wp_kses( $content, $allowed_html, $allowed_protocols = array() ) { * * @since 7.2.0 * + * @global string $wp_kses_operating_mode + * * @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() @@ -999,17 +1015,7 @@ function wp_kses( $content, $allowed_html, $allowed_protocols = array() ) { * @return string Filtered content containing only the allowed HTML. */ function wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols = array() ) { - // Remove filters built for legacy `wp_kses()`. - $had_pre_kses_less_than = has_filter( 'pre_kses', 'wp_pre_kses_less_than' ); - $had_pre_kses_block_attributes = has_filter( 'pre_kses', 'wp_pre_kses_block_attributes' ); - - if ( $had_pre_kses_less_than ) { - remove_filter( 'pre_kses', 'wp_pre_kses_less_than' ); - } - - if ( $had_pre_kses_block_attributes ) { - remove_filter( 'pre_kses', 'wp_pre_kses_block_attributes' ); - } + global $wp_kses_operating_mode; $allowed_html = is_array( $allowed_html ) ? $allowed_html @@ -1023,7 +1029,10 @@ function wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols = ar $content = preg_replace( '/[\x01-\x08\x0B\x0C\x0E-\x1F]/', '', $content ); // Call legacy pre-kses filters that might have been added by plugins. - $content = wp_kses_hook( $content, $allowed_html, $allowed_protocols ); + $previous_kses_mode = $wp_kses_operating_mode; + $wp_kses_operating_mode = 'html-api'; + $content = wp_kses_hook( $content, $allowed_html, $allowed_protocols ); + $wp_kses_operating_mode = $previous_kses_mode; /* * The explanation for this call is that “the quoting from `preg_replace(//e)` @@ -1676,18 +1685,7 @@ public function sanitize() { } }; - $sanitized = $processor->sanitize(); - - // Restore filters built for legacy `wp_kses()`. - if ( $had_pre_kses_less_than ) { - add_filter( 'pre_kses', 'wp_pre_kses_less_than' ); - } - - if ( $had_pre_kses_block_attributes ) { - add_filter( 'pre_kses', 'wp_pre_kses_block_attributes', 10, 3 ); - } - - return $sanitized; + return $processor->sanitize(); } /** From dd96ccd5003714106ac7740ae6662c6f6558ddec Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Mon, 31 Aug 2026 22:57:51 -0700 Subject: [PATCH 12/27] Tests: Add idempotency tests. Co-Authored-By: Jon Surrell --- tests/phpunit/tests/kses.php | 45 ++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index cbfcc6f1bc2df..548d086fc4e4f 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -108,6 +108,51 @@ public function data_wp_filter_post_kses_a() { return $data; } + /** + * Ensures that repeated runs return the same result. + * + * @ticket 65984 + * + * @dataProvider data_wp_kses_idempotent_inputs + * + * @param string $input Process this HTML. + * @param array|null $allowed_html Optional. If provided, will be passed into `wp_kses()`. + * Default is to rely on the WordPress defaults. + * @param array|null $allowed_protocols Optional. If provided, will be passed into `wp_kses()`. + * Default is to rely on the WordPress defaults. + * @return void + */ + public function test_wp_kses_is_idempotent( string $input, ?array $allowed_html = null, ?array $allowed_protocols = null ): void { + $output = wp_kses( $input, $allowed_html, $allowed_protocols ); + + $this->assertSame( + $output, + wp_kses( $output, $allowed_html, $allowed_protocols ), + 'Should have produced the same output after running the given input back through `wp_kses()`' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_wp_kses_idempotent_inputs(): array { + return array( + array( + '
a < b
', + ), + array( + '
', + array( + 'div' => array( + 'id' => true, + ), + ), + ), + ); + } + /** * Test video tag. * From 6d9652749e25b99f61601bb5c7900d2526225998 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 25 Aug 2026 14:35:52 -0700 Subject: [PATCH 13/27] 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. --- .../phpunit/tests/admin/includesTemplate.php | 8 +- .../tests/block-bindings/postMetaSource.php | 5 +- tests/phpunit/tests/block-bindings/render.php | 5 +- 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 | 133 ++++++++++-------- 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 | 24 ++-- .../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 +- .../tests/widgets/wpWidgetMediaImage.php | 4 +- 21 files changed, 145 insertions(+), 126 deletions(-) diff --git a/tests/phpunit/tests/admin/includesTemplate.php b/tests/phpunit/tests/admin/includesTemplate.php index 4b9b8bc68034e..43ff8dc2926c9 100644 --- a/tests/phpunit/tests/admin/includesTemplate.php +++ b/tests/phpunit/tests/admin/includesTemplate.php @@ -350,14 +350,14 @@ public function data_extra_args_for_add_settings_section() { ), 'disallowed tag in before_section' => array( array( - 'before_section' => '
', 'after_section' => '
', ), array( 'id' => 'test-section', 'title' => 'Section title', 'callback' => '__return_false', - 'before_section' => '
', 'after_section' => '
', 'section_class' => '', ), @@ -367,14 +367,14 @@ public function data_extra_args_for_add_settings_section() { 'disallowed tag in after_section' => array( array( 'before_section' => '
', - 'after_section' => '
', ), array( 'id' => 'test-section', 'title' => 'Section title', 'callback' => '__return_false', 'before_section' => '
', - 'after_section' => '
', 'section_class' => '', ), '
', diff --git a/tests/phpunit/tests/block-bindings/postMetaSource.php b/tests/phpunit/tests/block-bindings/postMetaSource.php index 555376e1c6b0c..1c51b5cb6a67c 100644 --- a/tests/phpunit/tests/block-bindings/postMetaSource.php +++ b/tests/phpunit/tests/block-bindings/postMetaSource.php @@ -260,9 +260,10 @@ 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..84a7fb08b33bf 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,9 +234,10 @@ 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 506ef23e27981..67f2f3c6db2b1 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 124015557be92..bf9757a70a426 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 c705c8e1001ab..e668bd78de2e5 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( '', $result ); break; case 'XML HTML+TIME': - $this->assertSame( '<t:set attributeName="innerHTML" to="XSSalert(\'XSS\')">', $result ); + $this->assertEqualHTML( '', $result ); break; case 'Commented-out Block': - $this->assertSame( "\nalert('XSS');", $result ); + $this->assertEqualHTML( "", $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( '', $result ); break; case 'PHP': - $this->assertSame( '<? echo('alert("XSS")\'); ?>', $result ); + $this->assertEqualHTML( '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->assertSame( '<', $result ); break; case 'Malformed IMG Tags': - $this->assertSame( 'alert("XSS")">', $result ); + $this->assertEqualHTML( '">', $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( '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 ); @@ -899,7 +899,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() { @@ -920,9 +921,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", ), ); } @@ -936,7 +943,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() { @@ -975,7 +991,7 @@ public function data_slash_zero_removal() { ), array( '', - 'div {background-image:\\0}', + '', ), ); } @@ -2398,7 +2414,7 @@ public function data_wp_kses_object_tag_allowed() { ), 'invalid value for type' => array( '', - '', + '', ), 'multiple type attributes, last invalid' => array( '', @@ -2414,39 +2430,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( '', @@ -2454,27 +2470,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( '', @@ -2513,11 +2529,11 @@ public function data_wp_kses_object_data_url_with_port_number_allowed() { ), 'url with wrong port number' => array( '', - '', + '', ), 'url without port number' => array( '', - '', + '', ), ); } @@ -2581,7 +2597,8 @@ public function filter_wp_kses_object_added_in_html_filter( $tags, $context ) { } /** - * Ensures that `wp_kses()` preserves various kinds of HTML comments, both valid and invalid. + * Ensures that `wp_kses()` preserves various kinds of HTML comments; + * specifically well-formed comments and “funky comments.” * * @ticket 61009 * @@ -2609,7 +2626,7 @@ public static function data_html_containing_various_kinds_of_html_comments() { return array( 'Normative HTML comment' => array( 'beforeafter', 'beforeafter' ), 'Closing tag with invalid tag name' => array( 'beforeafter', 'beforeafter' ), - 'Incorrectly opened comment (Markup declaration)' => array( 'beforeafter', 'beforeafter' ), + 'Incorrectly opened comment (Markup declaration)' => array( 'beforeafter', 'beforeafter' ), ); } diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php index d3e57d1b747df..e9b0ef0a967a1 100644 --- a/tests/phpunit/tests/media.php +++ b/tests/phpunit/tests/media.php @@ -4721,7 +4721,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 ); } /** @@ -4791,7 +4791,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 ); } /** @@ -4850,7 +4850,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 ); } /** @@ -5963,7 +5963,7 @@ static function ( $atts ) { // Cleanup. remove_shortcode( 'full_image' ); - $this->assertSame( $expected_content, $content ); + $this->assertEqualHTML( $expected_content, $content ); } /** @@ -6117,7 +6117,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 4f5603f72065f..b7a22df0b086c 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 308fc07e4c78c..1ef7eab22ae6c 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -3147,8 +3147,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 e301a1c44a546..420b66ba366c6 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,16 +4717,16 @@ 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', - '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

", ), ) ); diff --git a/tests/phpunit/tests/rest-api/rest-tags-controller.php b/tests/phpunit/tests/rest-api/rest-tags-controller.php index c12ee3b0c58e6..9b0526bad80ab 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 412da7d560a75..90b7a000f0087 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 ); } /** diff --git a/tests/phpunit/tests/widgets/wpWidgetMediaImage.php b/tests/phpunit/tests/widgets/wpWidgetMediaImage.php index 934adab6d50c9..7518e52d8b3e9 100644 --- a/tests/phpunit/tests/widgets/wpWidgetMediaImage.php +++ b/tests/phpunit/tests/widgets/wpWidgetMediaImage.php @@ -241,8 +241,8 @@ public function test_update() { $this->assertSame( $result, array( - 'caption' => '">', - ) + 'caption' => '">', + ), ); // Should return valid alt text. From ca0a3538ca2cc2fa112747e0afb2f0fcfaa4e07e Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Wed, 26 Aug 2026 16:17:25 -0700 Subject: [PATCH 14/27] 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 19f6e2ff83dfa402d684cc0d241e9fc339ac5dbe Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Wed, 16 Sep 2026 19:31:57 -0500 Subject: [PATCH 15/27] Refactor detection of foreign content exclusions, and relax rules for text nodes. --- src/wp-includes/kses.php | 69 ++++++++++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 8646d89d8c7fe..be1839805df18 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1100,12 +1100,38 @@ public function set_attribute( $name, $value ): bool { return true; } - private function could_potentially_escape_foreign_content() { + private function could_escape_foreign_content( bool $is_inside_mathml_text_integration_point ) { $token_name = $this->get_token_name(); $is_closer = $this->is_tag_closer(); $namespace = $this->get_namespace(); $self_closing = ! $is_closer && $this->has_self_closing_flag(); + /* + * These two elements are excepted in HTML from the normal processing + * rules because they function in similar ways to character data. + * + * > The mglyph element is used to represent non-standard characters or + * > symbols by images; the malignmark element establishes an alignment + * > point for use within table constructs, and is otherwise invisible. + * + * They must contain no elements, so only allow self-closing tags. + */ + if ( ! $is_closer && $is_inside_mathml_text_integration_point ) { + return ! ( $self_closing && ( 'MGLYPH' === $token_name || 'MALIGNMARK' === $token_name ) ); + } + + if ( + ! $is_closer && + 'FONT' === $token_name && + ( + null !== $this->get_attribute( 'color' ) || + null !== $this->get_attribute( 'face' ) || + null !== $this->get_attribute( 'size' ) + ) + ) { + return true; + } + if ( ! $is_closer && in_array( @@ -1156,13 +1182,6 @@ private function could_potentially_escape_foreign_content() { 'UL', 'VAR', - /* - * This is technically only necessary when it contains one - * of the `color`, `face`, or `size` attributes, but this - * is already a conservative system so it’s okay to reject. - */ - 'FONT', - /* * This will be parsed as 'IMG'. (Don’t ask.) */ @@ -1263,8 +1282,8 @@ public function sanitize() { $is_closer = $this->is_tag_closer(); $here = $this->get_span(); - $in_mathml_text = ( - 'math' === $namespace && + $is_in_mathml_text_integration_point = ( + 'math' === $this->get_namespace() && in_array( end( $this->foreign_content_stack ), array( @@ -1287,7 +1306,7 @@ public function sanitize() { * occur here but a browser will still do so; this sanitizer is generally * unaware of nesting structure. */ - if ( $in_mathml_text && '#text' === $token_type ) { + if ( $is_in_mathml_text_integration_point && '#text' === $token_type ) { $this->change_parsing_namespace( 'html' ); $text = $this->get_modifiable_text(); $this->change_parsing_namespace( $namespace ); @@ -1483,7 +1502,29 @@ public function sanitize() { * that exist for elements in the HTML namespace. Copy the token verbatim. */ case '#cdata-section': - if ( ! $skip_token ) { + if ( $skip_token ) { + break; + } + + if ( $is_in_mathml_text_integration_point ) { + /* + * As of the writing of this code, Chrome 153.0.8010.48 and Safari 26.6.1 + * both incorrectly treat the CDATA section inside a MathML integration + * point as an invalid HTML comment. To prevent the misparse in the browser, + * convert the CDATA section into escaped plaintext nodes. + * + * Once the minimum-supported browsers all correctly implement the HTML + * specification on this point, this conversion can be removed. + */ + $output .= strtr( + $text, + array( + '<' => '<', + '&' => '&', + '>' => '>', + ) + ); + } else { $output .= substr( $this->html, $here->start, $here->length ); } break; @@ -1503,8 +1544,8 @@ public function sanitize() { * or insertion mode. */ if ( - ( 'html' !== $namespace && $this->could_potentially_escape_foreign_content() ) || - ( $in_mathml_text && ! $is_closer ) + 'html' !== $namespace && + $this->could_escape_foreign_content( $is_in_mathml_text_integration_point ) ) { return substr( $output, 0, $foreign_content_starts_at ); } From 0ec786c0d1223f3036de241454ceadd8306f3733 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Thu, 17 Sep 2026 10:09:13 -0500 Subject: [PATCH 16/27] WIP: Allow character data inside foreign content integration points. --- src/wp-includes/kses.php | 42 +++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index be1839805df18..c3f8275d8d16c 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1100,12 +1100,16 @@ public function set_attribute( $name, $value ): bool { return true; } - private function could_escape_foreign_content( bool $is_inside_mathml_text_integration_point ) { + private function could_escape_foreign_content( bool $is_inside_mathml_text_integration_point, bool $is_inside_svg_html_integreation_point ) { $token_name = $this->get_token_name(); $is_closer = $this->is_tag_closer(); $namespace = $this->get_namespace(); $self_closing = ! $is_closer && $this->has_self_closing_flag(); + if ( ! $is_closer && $is_inside_svg_html_integreation_point ) { + return true; + } + /* * These two elements are excepted in HTML from the normal processing * rules because they function in similar ways to character data. @@ -1231,22 +1235,6 @@ private function could_escape_foreign_content( bool $is_inside_mathml_text_integ } } - if ( - 'svg' === $namespace && - ! $is_closer && - in_array( - $token_name, - array( - 'FOREIGNOBJECT', - 'DESC', - 'TITLE', - ), - true - ) - ) { - return true; - } - return false; } @@ -1297,6 +1285,17 @@ public function sanitize() { ) ); + $is_in_svg_html_integration_point = ( + 'svg' === $namespace && + ! $is_closer && + ( 'FOREIGNOBJECT' === $token_name || 'DESC' === $token_name || 'TITLE' === $token_name ) + ); + + $is_in_text_integration_point = ( + $is_in_mathml_text_integration_point || + $is_in_svg_html_integration_point + ); + /* * While content inside integration points is generally not allowed here, * character data inside the MathML text elements _is_ allowed. This is @@ -1306,7 +1305,7 @@ public function sanitize() { * occur here but a browser will still do so; this sanitizer is generally * unaware of nesting structure. */ - if ( $is_in_mathml_text_integration_point && '#text' === $token_type ) { + if ( $is_in_text_integration_point && '#text' === $token_type ) { $this->change_parsing_namespace( 'html' ); $text = $this->get_modifiable_text(); $this->change_parsing_namespace( $namespace ); @@ -1506,7 +1505,7 @@ public function sanitize() { break; } - if ( $is_in_mathml_text_integration_point ) { + if ( $is_in_text_integration_point ) { /* * As of the writing of this code, Chrome 153.0.8010.48 and Safari 26.6.1 * both incorrectly treat the CDATA section inside a MathML integration @@ -1545,7 +1544,10 @@ public function sanitize() { */ if ( 'html' !== $namespace && - $this->could_escape_foreign_content( $is_in_mathml_text_integration_point ) + $this->could_escape_foreign_content( + $is_in_mathml_text_integration_point, + $is_in_svg_html_integration_point + ) ) { return substr( $output, 0, $foreign_content_starts_at ); } From 424841104f8493d1832c79b268be434041828fd7 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Thu, 17 Sep 2026 16:55:14 -0500 Subject: [PATCH 17/27] Patch cleanup --- src/wp-includes/kses.php | 120 ++++++++++-------- tests/phpunit/tests/icons/wpIconsRegistry.php | 2 +- 2 files changed, 68 insertions(+), 54 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index c3f8275d8d16c..00bed47b14d14 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1017,14 +1017,6 @@ function wp_kses( $content, $allowed_html, $allowed_protocols = array() ) { function wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols = array() ) { global $wp_kses_operating_mode; - $allowed_html = is_array( $allowed_html ) - ? $allowed_html - : wp_kses_allowed_html( $allowed_html ); - - $allowed_protocols = empty( $allowed_protocols ) - ? wp_allowed_protocols() - : $allowed_protocols; - // Preserve legacy behavior of stripping unwanted C0 control characters. $content = preg_replace( '/[\x01-\x08\x0B\x0C\x0E-\x1F]/', '', $content ); @@ -1034,6 +1026,14 @@ function wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols = ar $content = wp_kses_hook( $content, $allowed_html, $allowed_protocols ); $wp_kses_operating_mode = $previous_kses_mode; + $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 @@ -1041,21 +1041,21 @@ 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( $content, $allowed_html, $allowed_protocols, wp_kses_uri_attributes() ) extends WP_HTML_Tag_Processor { private $allowed_html; private $allowed_protocols; private $foreign_content_stack = array(); - private $uris; + private $uri_attributes; - public function __construct( $html, $allowed_html, $allowed_protocols ) { + public function __construct( $html, $allowed_html, $allowed_protocols, $uri_attributes ) { parent::__construct( $html ); $this->allowed_html = $allowed_html; $this->allowed_protocols = $allowed_protocols; - $this->uris = wp_kses_uri_attributes(); + $this->uri_attributes = $uri_attributes; } private function get_span() { @@ -1070,15 +1070,14 @@ private function get_span() { public function set_attribute( $name, $value ): bool { $lower_name = strtolower( $name ); - $is_url_ish = in_array( $lower_name, $this->uris, true ); + $is_url_ish = in_array( $lower_name, $this->uri_attributes, true ); if ( ! $is_url_ish || ! is_string( $value ) ) { return parent::set_attribute( $name, $value ); } - $escaped = wp_kses_bad_protocol( $value, $this->allowed_protocols ); $escaped = strtr( - $escaped, + $value, array( '<' => '<', '>' => '>', @@ -1087,8 +1086,6 @@ public function set_attribute( $name, $value ): bool { "'" => ''', ) ); - /** This filter is documented in wp-includes/formatting.php */ - $escaped = apply_filters( 'attribute_escape', $escaped, $value ); // Set a benign placeholder to replace below. if ( ! parent::set_attribute( $name, true ) ) { @@ -1100,13 +1097,13 @@ public function set_attribute( $name, $value ): bool { return true; } - private function could_escape_foreign_content( bool $is_inside_mathml_text_integration_point, bool $is_inside_svg_html_integreation_point ) { + private function could_escape_foreign_content( bool $is_inside_mathml_text_integration_point, bool $is_inside_svg_html_integration_point ) { $token_name = $this->get_token_name(); $is_closer = $this->is_tag_closer(); $namespace = $this->get_namespace(); $self_closing = ! $is_closer && $this->has_self_closing_flag(); - if ( ! $is_closer && $is_inside_svg_html_integreation_point ) { + if ( ! $is_closer && $is_inside_svg_html_integration_point ) { return true; } @@ -1185,11 +1182,6 @@ private function could_escape_foreign_content( bool $is_inside_mathml_text_integ 'U', 'UL', 'VAR', - - /* - * This will be parsed as 'IMG'. (Don’t ask.) - */ - 'IMAGE', ), true ) || @@ -1208,7 +1200,7 @@ private function could_escape_foreign_content( bool $is_inside_mathml_text_integ return true; } - if ( 'math' === $namespace && ! $self_closing ) { + if ( 'math' === $namespace && ! $is_closer && ! $self_closing ) { $encoding = $this->get_attribute( 'encoding' ); if ( 'ANNOTATION-XML' === $token_name && @@ -1288,7 +1280,15 @@ public function sanitize() { $is_in_svg_html_integration_point = ( 'svg' === $namespace && ! $is_closer && - ( 'FOREIGNOBJECT' === $token_name || 'DESC' === $token_name || 'TITLE' === $token_name ) + in_array( + end( $this->foreign_content_stack ), + array( + 'DESC', + 'FOREIGNOBJECT', + 'TITLE', + ), + true + ) ); $is_in_text_integration_point = ( @@ -1462,9 +1462,6 @@ public function sanitize() { if ( isset( $original_attributes ) ) { $block_type = $block_processor->get_block_type(); - $block_type = str_starts_with( $block_type, 'core/' ) - ? substr( $block_type, /* 'core/' */ 5 ) - : $block_type; $filtered_attributes = filter_block_kses_value( $original_attributes, @@ -1474,6 +1471,11 @@ public function sanitize() { ); if ( $original_attributes !== $filtered_attributes ) { + // Strip the implicit `core/` prefix on serialization. + $block_type = str_starts_with( $block_type, 'core/' ) + ? substr( $block_type, /* 'core/' */ 5 ) + : $block_type; + $serialized_attributes = serialize_block_attributes( $filtered_attributes ); $voider = WP_Block_Processor::VOID === $block_processor->get_delimiter_type() ? '/' : ''; $text = " wp:{$block_type} {$serialized_attributes} {$voider}"; @@ -1518,13 +1520,17 @@ public function sanitize() { $output .= strtr( $text, array( - '<' => '<', - '&' => '&', - '>' => '>', + "\x00" => "\u{FFFD}", + '<' => '<', + '&' => '&', + '>' => '>', ) ); } else { - $output .= substr( $this->html, $here->start, $here->length ); + $output .= strtr( + substr( $this->html, $here->start, $here->length ), + array( "\x00" => "\u{FFFD}" ) + ); } break; @@ -1607,15 +1613,15 @@ public function sanitize() { if ( ! empty( $element_attributes['data-*'] ) ) { if ( is_array( $attribute_names ) ) { foreach ( $attribute_names as $name ) { - if ( ! str_starts_with( $name, 'data-' ) ) { - continue; - } - - if ( 1 !== preg_match( '/^data-[a-z0-9_-]+$/', $name ) ) { - continue; + if ( + 1 === preg_match( '/^data-[a-z0-9_-]+$/', $name ) && + ( + ! isset( $element_attributes[ $name ] ) || + '' === $element_attributes[ $name ] + ) + ) { + $element_attributes[ $name ] = $element_attributes['data-*']; } - - $element_attributes[ $name ] = $element_attributes['data-*']; } } @@ -1625,7 +1631,8 @@ public function sanitize() { $tag_maker = new self( "<{$tag_name}{$self_closer}>{$closing_tag}", $this->allowed_html, - $this->allowed_protocols + $this->allowed_protocols, + $this->uri_attributes ); $tag_maker->next_token(); if ( is_array( $attribute_names ) ) { @@ -1641,14 +1648,20 @@ public function sanitize() { $value = is_string( $raw_value ) ? $raw_value : ''; // Process the style attribute through CSS sanitization. - if ( 'style' === $name && is_string( $raw_value ) ) { - $style = safecss_filter_attr( $value ); + if ( 'style' === $name ) { + if ( ! is_string( $raw_value ) ) { + continue; + } - if ( '' !== trim( $style ) ) { - $tag_maker->set_attribute( 'style', $style ); - unset( $required_attributes['style'] ); + $value = safecss_filter_attr( $value ); + if ( '' === trim( $value ) ) { + continue; } - continue; + } + + $is_url_ish = in_array( strtolower( $name ), $this->uri_attributes, true ); + if ( $is_url_ish ) { + $value = wp_kses_bad_protocol( $value, $this->allowed_protocols ); } /* @@ -1667,12 +1680,13 @@ public function sanitize() { } } - if ( true === $raw_value && '' === $value ) { - $tag_maker->set_attribute( $name, true ); - } else { - $tag_maker->set_attribute( $name, $value ); + $did_set = ( true === $raw_value && '' === $value ) + ? $tag_maker->set_attribute( $name, true ) + : $tag_maker->set_attribute( $name, $value ); + + if ( $did_set ) { + unset( $required_attributes[ $name ] ); } - unset( $required_attributes[ $name ] ); } } diff --git a/tests/phpunit/tests/icons/wpIconsRegistry.php b/tests/phpunit/tests/icons/wpIconsRegistry.php index 34ba5330b6ab1..3283bcc849cf4 100644 --- a/tests/phpunit/tests/icons/wpIconsRegistry.php +++ b/tests/phpunit/tests/icons/wpIconsRegistry.php @@ -309,7 +309,7 @@ public function test_register_icon_sanitizes_content() { * @param non-falsy-string $expected The expected sanitized output. */ public function test_sanitize_icon_content( $input, $expected ) { - $this->assertSame( $expected, $this->sanitize_icon_content( $input ) ); + $this->assertEqualHTML( $expected, $this->sanitize_icon_content( $input ) ); } /** From 875a26b7f516fbd08165aa595f607325e4ffec22 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Sat, 19 Sep 2026 10:08:53 -0500 Subject: [PATCH 18/27] Prevent stepping into NOSCRIPT elements because of variable parsing rules. --- src/wp-includes/kses.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 00bed47b14d14..600fe6d08e676 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1262,6 +1262,16 @@ public function sanitize() { $is_closer = $this->is_tag_closer(); $here = $this->get_span(); + /* + * Prevent allowing NOSCRIPT elements whose parsing rules change + * based on whether the scripting flag is enabled in a browser. + * Rely on trusted inputs for producing the appropriate NOSCRIPT + * content, and prevent untrusted inputs from generating it. + */ + if ( 'NOSCRIPT' === $token_name && ! $is_closer ) { + break; + } + $is_in_mathml_text_integration_point = ( 'math' === $this->get_namespace() && in_array( From 1c1143c7715364a25f84c9760717faca6dad1968 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Sun, 20 Sep 2026 11:26:42 -0500 Subject: [PATCH 19/27] =?UTF-8?q?Track=20specified=20allowed=20HTML=20cont?= =?UTF-8?q?ext=20for=20filters=E2=80=99=20sake.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/wp-includes/kses.php | 72 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 600fe6d08e676..b41271b9a5567 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1026,6 +1026,8 @@ function wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols = ar $content = wp_kses_hook( $content, $allowed_html, $allowed_protocols ); $wp_kses_operating_mode = $previous_kses_mode; + $specified_allowed_html = $allowed_html; + $allowed_html = is_array( $allowed_html ) ? $allowed_html : wp_kses_allowed_html( $allowed_html ); @@ -1041,21 +1043,78 @@ function wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols = ar */ //$content = wp_kses_stripslashes( $content ); - $processor = new class( $content, $allowed_html, $allowed_protocols, wp_kses_uri_attributes() ) extends WP_HTML_Tag_Processor { + $processor = new class( $content, $specified_allowed_html, $allowed_html, $allowed_protocols, wp_kses_uri_attributes() ) extends WP_HTML_Tag_Processor { + /** + * An array of allowed HTML elements and attributes, or a context name such as 'post'. + * + * It’s important to store this alongside the resolved allowable HTML because some + * filters in some plugins look for the string values, e.g. for “post” instead of + * the resolved array, and apply logic based on that context. + * + * @see wp_kses_allowed_html() for the list of accepted context names. + * @see self::$allowed_html for the resolved array of allowable HTML elements and attributes. + * + * @since 7.2.0 + * + * @var array[]|string + */ + private $specified_allowed_html; + + /** + * An array of allowed HTML elements and attributes. + * + * This array of allowable HTML elements and attributes is resolved from the value provided + * to the sanitizer function. It’s resolved at the start to avoid repeatedly calling the + * filter stack and array-merging computations. However, it’s still necessary to carry along + * the provided context so that filters expecting the array-or-string version continue to + * operate properly. + * + * @see self::$specified_allowed_html + * + * @since 7.2.0 + * + * @var array[] + */ private $allowed_html; + /** + * Array of allowed URL protocols. + * + * @see \wp_allowed_protocols() + * + * @since 7.2.0 + * + * @var string + */ private $allowed_protocols; + /** + * Tracks balanced tags when inside foreign content. + * + * @since 7.2.0 + * + * @var string[] + */ private $foreign_content_stack = array(); + /** + * List of attributes whose values are expected to be considered URLs. + * + * @see \wp_kses_uri_attributes() + * + * @since 7.2.0 + * + * @var array + */ private $uri_attributes; - public function __construct( $html, $allowed_html, $allowed_protocols, $uri_attributes ) { + public function __construct( $html, $specified_allowed_html, $allowed_html, $allowed_protocols, $uri_attributes ) { parent::__construct( $html ); - $this->allowed_html = $allowed_html; - $this->allowed_protocols = $allowed_protocols; - $this->uri_attributes = $uri_attributes; + $this->specified_allowed_html = $specified_allowed_html; + $this->allowed_html = $allowed_html; + $this->allowed_protocols = $allowed_protocols; + $this->uri_attributes = $uri_attributes; } private function get_span() { @@ -1475,7 +1534,7 @@ public function sanitize() { $filtered_attributes = filter_block_kses_value( $original_attributes, - $this->allowed_html, + $this->specified_allowed_html, $this->allowed_protocols, array( 'blockName' => $block_type ) ); @@ -1640,6 +1699,7 @@ public function sanitize() { $tag_maker = new self( "<{$tag_name}{$self_closer}>{$closing_tag}", + $this->specified_allowed_html, $this->allowed_html, $this->allowed_protocols, $this->uri_attributes From 55fcfe844c4a009dd00c14cbcf2c80f622af72d2 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Sun, 20 Sep 2026 13:24:43 -0500 Subject: [PATCH 20/27] Patch cleanup --- src/wp-includes/kses.php | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index b41271b9a5567..37c0026bf8034 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -59,6 +59,9 @@ /** * Indicates which implementation of {@see \wp_kses()} is running. * + * Nominally `legacy` unless temporarily-switched for {@see \wp_sanitize_html_kses()}. + * It’s safe to latch this into `legacy`. + * * @global 'legacy'|'html-api' $wp_kses_operating_mode */ global $wp_kses_operating_mode; @@ -1017,25 +1020,34 @@ function wp_kses( $content, $allowed_html, $allowed_protocols = array() ) { function wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols = array() ) { global $wp_kses_operating_mode; + $specified_allowed_html = $allowed_html; + + $allowed_protocols = empty( $allowed_protocols ) + ? wp_allowed_protocols() + : $allowed_protocols; + // Preserve legacy behavior of stripping unwanted C0 control characters. $content = preg_replace( '/[\x01-\x08\x0B\x0C\x0E-\x1F]/', '', $content ); - // Call legacy pre-kses filters that might have been added by plugins. + /* + * Call legacy pre-kses filters that might have been added by plugins. + * + * Also set the operating mode to bypass the pre-filters from the legacy + * implementation of `wp_kses()`, as these filters are now run in-band + * during the processing of the input document. + * + * The reset of the operating mode should always be `legacy`, but just + * in case it isn’t, reset it to its previously-read value. + */ $previous_kses_mode = $wp_kses_operating_mode; $wp_kses_operating_mode = 'html-api'; - $content = wp_kses_hook( $content, $allowed_html, $allowed_protocols ); + $content = wp_kses_hook( $content, $specified_allowed_html, $allowed_protocols ); $wp_kses_operating_mode = $previous_kses_mode; - $specified_allowed_html = $allowed_html; - $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 @@ -1113,8 +1125,8 @@ public function __construct( $html, $specified_allowed_html, $allowed_html, $all $this->specified_allowed_html = $specified_allowed_html; $this->allowed_html = $allowed_html; - $this->allowed_protocols = $allowed_protocols; - $this->uri_attributes = $uri_attributes; + $this->allowed_protocols = $allowed_protocols; + $this->uri_attributes = $uri_attributes; } private function get_span() { @@ -1704,6 +1716,7 @@ public function sanitize() { $this->allowed_protocols, $this->uri_attributes ); + $tag_maker->change_parsing_namespace( $namespace ); $tag_maker->next_token(); if ( is_array( $attribute_names ) ) { foreach ( $attribute_names as $name ) { From 6a4826469e7eef653d90b4e83c66c2ca308b3a94 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 22 Sep 2026 12:09:34 -0500 Subject: [PATCH 21/27] Preserve newlines in PRE, LISTING, and TEXTAREA --- src/wp-includes/kses.php | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 37c0026bf8034..776aa50f600dd 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1309,6 +1309,7 @@ private function could_escape_foreign_content( bool $is_inside_mathml_text_integ public function sanitize() { $template_depth = 0; $output = ''; + $special_newline_at = PHP_INT_MIN; $foreign_content_starts_at = PHP_INT_MAX; /** @@ -1459,25 +1460,26 @@ public function sanitize() { break; } + $needs_special_newline = ( + strlen( $output ) === $special_newline_at && + 1 === strspn( $text, "\n\r", 0, 1 ) + ); + $text = strtr( $text, array( - '<' => '<', - '&' => '&', - '>' => '>', - /* - * 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. - */ - // "'" => ''', - // '"' => '"', + "\r" => ' ', + '<' => '<', + '&' => '&', + '>' => '>', ) ); - $output .= $text; + if ( $needs_special_newline ) { + $output .= "\n{$text}"; + } else { + $output .= $text; + } break; /* @@ -1773,6 +1775,8 @@ public function sanitize() { } } + $needs_special_newline = 'html' === $namespace && ( 'PRE' === $token_name || 'LISTING' === $token_name ); + if ( ! empty( $required_attributes ) ) { if ( ! $expects_closer ) { break; @@ -1784,6 +1788,9 @@ public function sanitize() { * missing, but strip them of their attributes. */ $output .= "<{$tag_name}>"; + if ( $needs_special_newline ) { + $special_newline_at = strlen( $output ); + } break; } @@ -1792,6 +1799,10 @@ public function sanitize() { } $output .= $tag_maker->get_updated_html(); + if ( $needs_special_newline ) { + $special_newline_at = strlen( $output ); + } + break; } From 53430f154dfa8926706b06dec611af7dce5b4514 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 22 Sep 2026 13:41:45 -0500 Subject: [PATCH 22/27] Ensure KSES operating mode always resets. --- src/wp-includes/kses.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 776aa50f600dd..99a23303ca841 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1039,10 +1039,13 @@ function wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols = ar * The reset of the operating mode should always be `legacy`, but just * in case it isn’t, reset it to its previously-read value. */ - $previous_kses_mode = $wp_kses_operating_mode; - $wp_kses_operating_mode = 'html-api'; - $content = wp_kses_hook( $content, $specified_allowed_html, $allowed_protocols ); - $wp_kses_operating_mode = $previous_kses_mode; + try { + $previous_kses_mode = $wp_kses_operating_mode; + $wp_kses_operating_mode = 'html-api'; + $content = wp_kses_hook( $content, $specified_allowed_html, $allowed_protocols ); + } finally { + $wp_kses_operating_mode = $previous_kses_mode; + } $allowed_html = is_array( $allowed_html ) ? $allowed_html From eb1efe35bcbf00eb128e932935be88d07408c07e Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 22 Sep 2026 14:52:01 -0500 Subject: [PATCH 23/27] Prevent sending RAWTEXT content containing block delimiters. --- src/wp-includes/kses.php | 42 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 99a23303ca841..fe396fcc00db3 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1304,6 +1304,24 @@ private function could_escape_foreign_content( bool $is_inside_mathml_text_integ return false; } + /** + * Indicates if a given string contains text that would parse as a block delimiter. + * + * @since 7.2.0 + * + * @param string $text Does a block comment delimiter exist in this string value? + * @return bool Whether a block comment delimiter of any kind was found in the given string. + */ + private static function contains_a_block_delimiter( string $text ): bool { + if ( '' === $text ) { + return false; + } + + $processor = new WP_Block_Processor( $text ); + + return $processor->next_delimiter(); + } + /** * Returns a sanitized copy of the input HTML. * @@ -1593,7 +1611,10 @@ public function sanitize() { break; } - if ( $is_in_text_integration_point ) { + if ( + $is_in_text_integration_point || + self::contains_a_block_delimiter( $text ) + ) { /* * As of the writing of this code, Chrome 153.0.8010.48 and Safari 26.6.1 * both incorrectly treat the CDATA section inside a MathML integration @@ -1798,7 +1819,24 @@ public function sanitize() { } if ( $is_special_atomic_element ) { - $tag_maker->set_modifiable_text( $text ); + $rawtext_elements = array( + 'IFRAME', + 'NOEMBED', + 'NOFRAMES', + 'STYLE', + 'XMP', + ); + + /** + * Reject updates containing a block comment delimiter from RAWTEXT nodes, + * since those do not escape their text. RCDATA elements escape syntax and + * so are benign to pass through to {@see self::set_modifiable_text()}. + */ + if ( in_array( $token_name, $rawtext_elements, true ) && self::contains_a_block_delimiter( $text ) ) { + $tag_maker->set_modifiable_text( '' ); + } else { + $tag_maker->set_modifiable_text( $text ); + } } $output .= $tag_maker->get_updated_html(); From e2a86fb49c2aa8499d614b28375151a4568f57b0 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 22 Sep 2026 15:23:54 -0500 Subject: [PATCH 24/27] More updates --- src/wp-includes/kses.php | 71 ++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 43 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index fe396fcc00db3..942d4304b5c28 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1099,7 +1099,7 @@ function wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols = ar * * @since 7.2.0 * - * @var string + * @var string[] */ private $allowed_protocols; @@ -1601,29 +1601,12 @@ public function sanitize() { /* * 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. + * where they represent text data without any escaping. However, because + * parsers tend to vary on how to parse these, for untrusted inputs, + * rewrite all CDATA sections as normal escaped text. */ case '#cdata-section': - if ( $skip_token ) { - break; - } - - if ( - $is_in_text_integration_point || - self::contains_a_block_delimiter( $text ) - ) { - /* - * As of the writing of this code, Chrome 153.0.8010.48 and Safari 26.6.1 - * both incorrectly treat the CDATA section inside a MathML integration - * point as an invalid HTML comment. To prevent the misparse in the browser, - * convert the CDATA section into escaped plaintext nodes. - * - * Once the minimum-supported browsers all correctly implement the HTML - * specification on this point, this conversion can be removed. - */ + if ( ! $skip_token ) { $output .= strtr( $text, array( @@ -1633,12 +1616,8 @@ public function sanitize() { '>' => '>', ) ); - } else { - $output .= strtr( - substr( $this->html, $here->start, $here->length ), - array( "\x00" => "\u{FFFD}" ) - ); } + break; case '#tag': @@ -1819,23 +1798,29 @@ public function sanitize() { } if ( $is_special_atomic_element ) { - $rawtext_elements = array( - 'IFRAME', - 'NOEMBED', - 'NOFRAMES', - 'STYLE', - 'XMP', - ); - - /** - * Reject updates containing a block comment delimiter from RAWTEXT nodes, - * since those do not escape their text. RCDATA elements escape syntax and - * so are benign to pass through to {@see self::set_modifiable_text()}. - */ - if ( in_array( $token_name, $rawtext_elements, true ) && self::contains_a_block_delimiter( $text ) ) { - $tag_maker->set_modifiable_text( '' ); - } else { + if ( 'TITLE' === $token_name || 'TEXTAREA' === $token_name ) { + /* + * RCDATA nodes can be safely escaped, but this must be + * done after enqueing the update to avoid double-escaping. + */ $tag_maker->set_modifiable_text( $text ); + $tag_maker->lexical_updates['modifiable text']->text = strtr( + $tag_maker->lexical_updates['modifiable text']->text, + array( + "\x00" => "\u{FFFD}", + "\r" => ' ', + ) + ); + } elseif ( ! self::contains_a_block_delimiter( $text ) ) { + // Other nodes not containing a block delimiter are safe. + $tag_maker->set_modifiable_text( $text ); + } else { + /* + * But RAWTEXT and SCRIPT cannot be generally escaped, so reject + * updates which would include something that could be misparsed + * as a block comment delimiter. + */ + $tag_maker->set_modifiable_text( '' ); } } From 2a74f671c3aef35ea51f6c4ff06b037d5c95492f Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Wed, 23 Sep 2026 10:15:41 -0500 Subject: [PATCH 25/27] Track and close open blocks. --- src/wp-includes/kses.php | 85 +++++++++++++++++++++++++++++----------- 1 file changed, 62 insertions(+), 23 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 942d4304b5c28..551374e889d20 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1332,6 +1332,8 @@ public function sanitize() { $output = ''; $special_newline_at = PHP_INT_MIN; $foreign_content_starts_at = PHP_INT_MAX; + $open_blocks = array(); + $open_blocks_at = array(); /** * These are treated as void elements inside the HTML API @@ -1436,7 +1438,7 @@ public function sanitize() { if ( $is_closer ) { $open_element = array_pop( $this->foreign_content_stack ); if ( null === $open_element || $token_name !== $open_element ) { - return substr( $output, 0, $foreign_content_starts_at ); + break; } /* @@ -1561,28 +1563,52 @@ public function sanitize() { } $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_attributes = filter_block_kses_value( - $original_attributes, - $this->specified_allowed_html, - $this->allowed_protocols, - array( 'blockName' => $block_type ) - ); + if ( $block_processor->next_token() && ! $block_processor->is_html() ) { + $block_type = $block_processor->get_block_type(); + $implicit_block_type = str_starts_with( $block_type, 'core/' ) + ? substr( $block_type, /* 'core/' */ 5 ) + : $block_type; + + + switch ( $block_processor->get_delimiter_type() ) { + // Track when blocks open and when they don’t self-close. + case WP_Block_Processor::OPENER: + $open_blocks[] = $implicit_block_type; + $open_blocks_at[] = strlen( $output ); + break; + + // Track when blocks close. + case WP_Block_Processor::CLOSER: + if ( empty( $open_blocks ) ) { + break 2; + } - if ( $original_attributes !== $filtered_attributes ) { - // Strip the implicit `core/` prefix on serialization. - $block_type = str_starts_with( $block_type, 'core/' ) - ? substr( $block_type, /* 'core/' */ 5 ) - : $block_type; + /* + * The default parser closes any open block, even when + * the names don’t match. Preserve this behavior here + * to avoid differences in sanitization and parsing. + */ + array_pop( $open_blocks ); + array_pop( $open_blocks_at ); + } - $serialized_attributes = serialize_block_attributes( $filtered_attributes ); - $voider = WP_Block_Processor::VOID === $block_processor->get_delimiter_type() ? '/' : ''; - $text = " wp:{$block_type} {$serialized_attributes} {$voider}"; + // Filter block attributes for opening delimiters. + if ( $block_processor->opens_block() ) { + $original_attributes = $block_processor->allocate_and_return_parsed_attributes(); + + if ( isset( $original_attributes ) ) { + $filtered_attributes = filter_block_kses_value( + $original_attributes, + $this->specified_allowed_html, + $this->allowed_protocols, + array( 'blockName' => $block_type ) + ); + + if ( $original_attributes !== $filtered_attributes ) { + $serialized_attributes = serialize_block_attributes( $filtered_attributes ); + $voider = WP_Block_Processor::VOID === $block_processor->get_delimiter_type() ? '/' : ''; + $text = " wp:{$implicit_block_type} {$serialized_attributes} {$voider}"; + } } } } @@ -1641,7 +1667,7 @@ public function sanitize() { $is_in_svg_html_integration_point ) ) { - return substr( $output, 0, $foreign_content_starts_at ); + break 2; } if ( $skip_token ) { @@ -1858,7 +1884,20 @@ public function sanitize() { * of the page’s HTML structure. */ - return substr( $output, 0, $foreign_content_starts_at ); + $sanitized = substr( $output, 0, $foreign_content_starts_at ); + + // Close any remaining-open blocks ensure isolation of block content. + for ( $i = count( $open_blocks ) - 1; $i >= 0; $i-- ) { + // Skip blocks that were opened when inside truncated foreign content. + if ( $open_blocks_at[ $i ] >= $foreign_content_starts_at ) { + continue; + } + + $block_name = $open_blocks[ $i ]; + $sanitized .= ""; + } + + return $sanitized; } }; From 8dc2fcf8b76485937b1dfcd91dd37392cbc269c6 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Wed, 23 Sep 2026 11:51:38 -0500 Subject: [PATCH 26/27] Close blocks that were closed inside truncated foreign content. --- src/wp-includes/kses.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 551374e889d20..0f4829e443873 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1334,6 +1334,7 @@ public function sanitize() { $foreign_content_starts_at = PHP_INT_MAX; $open_blocks = array(); $open_blocks_at = array(); + $foreign_closed_blocks = array(); /** * These are treated as void elements inside the HTML API @@ -1588,8 +1589,12 @@ public function sanitize() { * the names don’t match. Preserve this behavior here * to avoid differences in sanitization and parsing. */ - array_pop( $open_blocks ); - array_pop( $open_blocks_at ); + $closed_block = array_pop( $open_blocks ); + $closed_block_at = array_pop( $open_blocks_at ); + + if ( 'html' !== $namespace && $closed_block_at < $foreign_content_starts_at ) { + $foreign_closed_blocks[] = $closed_block; + } } // Filter block attributes for opening delimiters. @@ -1867,6 +1872,7 @@ public function sanitize() { if ( empty( $this->foreign_content_stack ) ) { $this->change_parsing_namespace( 'html' ); $foreign_content_starts_at = PHP_INT_MAX; + $foreign_closed_blocks = array(); } } @@ -1887,6 +1893,10 @@ public function sanitize() { $sanitized = substr( $output, 0, $foreign_content_starts_at ); // Close any remaining-open blocks ensure isolation of block content. + foreach ( $foreign_closed_blocks as $block_name ) { + $sanitized .= ""; + } + for ( $i = count( $open_blocks ) - 1; $i >= 0; $i-- ) { // Skip blocks that were opened when inside truncated foreign content. if ( $open_blocks_at[ $i ] >= $foreign_content_starts_at ) { From 09ae9460587c9c94027d5ac4bf0d73f6e1527bbb Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Wed, 23 Sep 2026 14:30:00 -0500 Subject: [PATCH 27/27] Optimize integration point detection. --- src/wp-includes/kses.php | 80 ++++++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 35 deletions(-) diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 0f4829e443873..35972c3ad7082 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -1112,6 +1112,16 @@ function wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols = ar */ private $foreign_content_stack = array(); + /** + * Tracks how deeply into a MathML ANNOTATION-XML element the current token is; + * an optimization to avoid checking up the open-element stack on every token. + * + * @since 7.2.0 + * + * @var int + */ + private $math_annotation_xml_depth = 0; + /** * List of attributes whose values are expected to be considered URLs. * @@ -1293,10 +1303,7 @@ private function could_escape_foreign_content( bool $is_inside_mathml_text_integ * with an HTML integration point. Conservatively reject any child * SVG element inside a MathML ANNOTATION-XML to prevent this. */ - if ( - 'SVG' === $token_name && - in_array( 'ANNOTATION-XML', $this->foreign_content_stack, true ) - ) { + if ( 'SVG' === $token_name && $this->math_annotation_xml_depth > 0 ) { return true; } } @@ -1336,6 +1343,9 @@ public function sanitize() { $open_blocks_at = array(); $foreign_closed_blocks = array(); + $is_in_mathml_text_integration_point = false; + $is_in_svg_html_integration_point = false; + /** * These are treated as void elements inside the HTML API * due to the special handling of their inner text content. @@ -1368,38 +1378,9 @@ public function sanitize() { break; } - $is_in_mathml_text_integration_point = ( - 'math' === $this->get_namespace() && - in_array( - end( $this->foreign_content_stack ), - array( - 'MI', - 'MN', - 'MO', - 'MS', - 'MTEXT', - ), - true - ) - ); - - $is_in_svg_html_integration_point = ( - 'svg' === $namespace && - ! $is_closer && - in_array( - end( $this->foreign_content_stack ), - array( - 'DESC', - 'FOREIGNOBJECT', - 'TITLE', - ), - true - ) - ); - $is_in_text_integration_point = ( $is_in_mathml_text_integration_point || - $is_in_svg_html_integration_point + ( ! $is_closer && $is_in_svg_html_integration_point ) ); /* @@ -1442,6 +1423,10 @@ public function sanitize() { break; } + if ( 'math' === $namespace && 'ANNOTATION-XML' === $open_element ) { + --$this->math_annotation_xml_depth; + } + /* * Reset the foreign content tracker so it doesn’t truncate * unintentionally after foreign content has properly closed. @@ -1460,6 +1445,10 @@ public function sanitize() { } $this->foreign_content_stack[] = $token_name; + + if ( 'math' === $namespace && 'ANNOTATION-XML' === $token_name ) { + ++$this->math_annotation_xml_depth; + } } } @@ -1570,7 +1559,6 @@ public function sanitize() { ? substr( $block_type, /* 'core/' */ 5 ) : $block_type; - switch ( $block_processor->get_delimiter_type() ) { // Track when blocks open and when they don’t self-close. case WP_Block_Processor::OPENER: @@ -1867,12 +1855,34 @@ public function sanitize() { if ( 'html' !== $namespace ) { if ( $has_self_closing_flag ) { array_pop( $this->foreign_content_stack ); + + if ( 'math' === $namespace && 'ANNOTATION-XML' === $token_name ) { + --$this->math_annotation_xml_depth; + } } if ( empty( $this->foreign_content_stack ) ) { + $is_in_mathml_text_integration_point = false; + $is_in_svg_html_integration_point = false; $this->change_parsing_namespace( 'html' ); $foreign_content_starts_at = PHP_INT_MAX; $foreign_closed_blocks = array(); + } elseif ( '#tag' === $token_type && ! $has_self_closing_flag ) { + switch ( $token_name ) { + case 'MI': + case 'MN': + case 'MO': + case 'MS': + case 'MTEXT': + $is_in_mathml_text_integration_point = ! $is_closer && 'math' === $namespace; + break; + + case 'DESC': + case 'FOREIGNOBJECT': + case 'TITLE': + $is_in_svg_html_integration_point = ! $is_closer && 'svg' === $namespace; + break; + } } }