From 292f17d1024a14f06a4e1cd6636f856ee5781c2c Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Sun, 1 Mar 2026 16:39:58 -0600 Subject: [PATCH 1/5] Fix problem with encoding of css entities when post with block level custom css is edited by user without unfiltered_html --- src/wp-includes/blocks.php | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index 89007d0d0d036..ac3b0b1b7f33c 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -2077,6 +2077,17 @@ function _filter_block_content_callback( $matches ) { function filter_block_kses( $block, $allowed_html, $allowed_protocols = array() ) { $block['attrs'] = filter_block_kses_value( $block['attrs'], $allowed_html, $allowed_protocols, $block ); + // Per-block custom CSS (attrs.style.css) may contain & and > as valid + // CSS selectors. wp_kses() entity-encodes these because it treats the + // value as HTML. Decode them after KSES has already stripped any + // dangerous HTML tags, so the CSS round-trips correctly through + // serialize_block_attributes(). + if ( isset( $block['attrs']['style']['css'] ) ) { + $block['attrs']['style']['css'] = undo_block_custom_css_kses_entities( + $block['attrs']['style']['css'] + ); + } + if ( is_array( $block['innerBlocks'] ) ) { foreach ( $block['innerBlocks'] as $i => $inner_block ) { $block['innerBlocks'][ $i ] = filter_block_kses( $inner_block, $allowed_html, $allowed_protocols ); @@ -2124,6 +2135,40 @@ function filter_block_kses_value( $value, $allowed_html, $allowed_protocols = ar return $value; } +/** + * Decodes HTML entities in per-block custom CSS that were incorrectly + * introduced by wp_kses() during the block KSES filtering pipeline. + * + * Per-block custom CSS (stored in attrs.style.css) may contain & and > + * as valid CSS selectors (nesting and child combinator). When wp_kses() + * processes this CSS string as if it were HTML, it entity-encodes these + * characters (&, >). If the block is then re-serialized via + * serialize_block_attributes(), the entity's ampersand is escaped again + * (\u0026amp;), producing a double-encoded value that corrupts the CSS + * on subsequent editor loads. + * + * This reverses only the specific named entities that wp_kses() may + * introduce, intentionally narrower than wp_specialchars_decode() to + * avoid decoding numeric/hex references that KSES intentionally preserved. + * + * @since 6.9.0 + * + * @param string $value Per-block custom CSS string potentially containing + * KSES-introduced entities. + * @return string CSS string with KSES-introduced entities decoded. + */ +function undo_block_custom_css_kses_entities( $value ) { + if ( ! is_string( $value ) || false === strpos( $value, '&' ) ) { + return $value; + } + + return str_replace( + array( '&', '>', '"', ''' ), + array( '&', '>', '"', "'" ), + $value + ); +} + /** * Sanitizes the value of the Template Part block's `tagName` attribute. * From 51f3c125f41c8b9c01f7b5b656bfba5509c46a38 Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Sun, 1 Mar 2026 17:00:07 -0600 Subject: [PATCH 2/5] Fix WP version --- src/wp-includes/blocks.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index ac3b0b1b7f33c..501ebb383c8a3 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -2151,7 +2151,7 @@ function filter_block_kses_value( $value, $allowed_html, $allowed_protocols = ar * introduce, intentionally narrower than wp_specialchars_decode() to * avoid decoding numeric/hex references that KSES intentionally preserved. * - * @since 6.9.0 + * @since 7.0 * * @param string $value Per-block custom CSS string potentially containing * KSES-introduced entities. From 35eab157fe83798f4bb50e21b1d06102d3bd38c2 Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Sun, 8 Mar 2026 11:46:14 -0600 Subject: [PATCH 3/5] Sanitise CSS separate to other attribs --- src/wp-includes/block-supports/custom-css.php | 5 +- src/wp-includes/blocks.php | 78 +++++++++-------- .../class-wp-customize-custom-css-setting.php | 83 ++----------------- src/wp-includes/formatting.php | 75 +++++++++++++++++ ...class-wp-rest-global-styles-controller.php | 75 ++--------------- 5 files changed, 138 insertions(+), 178 deletions(-) diff --git a/src/wp-includes/block-supports/custom-css.php b/src/wp-includes/block-supports/custom-css.php index 9d5b13426f4ef..02b6ba70e0ef7 100644 --- a/src/wp-includes/block-supports/custom-css.php +++ b/src/wp-includes/block-supports/custom-css.php @@ -26,8 +26,9 @@ function wp_render_custom_css_support_styles( $parsed_block ) { return $parsed_block; } - // Validate CSS doesn't contain HTML markup (same validation as global styles REST API). - if ( preg_match( '# element (same validation as global styles). + $css_validity = wp_validate_css_for_style_element( $custom_css ); + if ( is_wp_error( $css_validity ) ) { return $parsed_block; } diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index 501ebb383c8a3..b9fd37c7ce820 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -2075,17 +2075,29 @@ function _filter_block_content_callback( $matches ) { * @return array The filtered and sanitized block object result. */ function filter_block_kses( $block, $allowed_html, $allowed_protocols = array() ) { + /* + * Extract per-block custom CSS before KSES processes the attributes. + * + * Custom CSS (attrs.style.css) may contain characters like & and > that + * are valid CSS selectors but would be entity-encoded or stripped by + * wp_kses(), which treats all values as HTML. Instead, the CSS is + * temporarily removed, KSES runs on the remaining attributes, and then + * the CSS is sanitized with CSS-appropriate methods and reinserted. + */ + $custom_css = null; + if ( isset( $block['attrs']['style']['css'] ) ) { + $custom_css = $block['attrs']['style']['css']; + unset( $block['attrs']['style']['css'] ); + } + $block['attrs'] = filter_block_kses_value( $block['attrs'], $allowed_html, $allowed_protocols, $block ); - // Per-block custom CSS (attrs.style.css) may contain & and > as valid - // CSS selectors. wp_kses() entity-encodes these because it treats the - // value as HTML. Decode them after KSES has already stripped any - // dangerous HTML tags, so the CSS round-trips correctly through - // serialize_block_attributes(). - if ( isset( $block['attrs']['style']['css'] ) ) { - $block['attrs']['style']['css'] = undo_block_custom_css_kses_entities( - $block['attrs']['style']['css'] - ); + // Sanitize and reinsert the custom CSS using CSS-appropriate methods. + if ( null !== $custom_css ) { + $sanitized_css = wp_sanitize_block_custom_css( $custom_css ); + if ( '' !== $sanitized_css ) { + $block['attrs']['style']['css'] = $sanitized_css; + } } if ( is_array( $block['innerBlocks'] ) ) { @@ -2136,37 +2148,37 @@ function filter_block_kses_value( $value, $allowed_html, $allowed_protocols = ar } /** - * Decodes HTML entities in per-block custom CSS that were incorrectly - * introduced by wp_kses() during the block KSES filtering pipeline. + * Sanitizes per-block custom CSS using CSS-appropriate methods. * - * Per-block custom CSS (stored in attrs.style.css) may contain & and > - * as valid CSS selectors (nesting and child combinator). When wp_kses() - * processes this CSS string as if it were HTML, it entity-encodes these - * characters (&, >). If the block is then re-serialized via - * serialize_block_attributes(), the entity's ampersand is escaped again - * (\u0026amp;), producing a double-encoded value that corrupts the CSS - * on subsequent editor loads. + * This function is used instead of wp_kses() for block custom CSS values + * (attrs.style.css) because wp_kses() treats its input as HTML and would + * entity-encode valid CSS characters like & (nesting selector) and > + * (child combinator), or strip content that resembles HTML tags. * - * This reverses only the specific named entities that wp_kses() may - * introduce, intentionally narrower than wp_specialchars_decode() to - * avoid decoding numeric/hex references that KSES intentionally preserved. + * The sanitization approach mirrors what is used for global styles custom CSS: + * 1. Strip any HTML tags from the CSS string. + * 2. Validate that the CSS cannot break out of a `` tag. - * - * Example: - * - * $style_a = 'p { font-weight: bold; add( - 'illegal_markup', - sprintf( - /* translators: %s is the CSS that was provided. */ - __( 'The CSS must not end in "%s".' ), - esc_html( substr( $css, $at ) ) - ) - ); - break; - } - - if ( 1 === strspn( $css, " \t\f\r\n/>", $at + 7, 1 ) ) { - $validity->add( - 'illegal_markup', - sprintf( - /* translators: %s is the CSS that was provided. */ - __( 'The CSS must not contain "%s".' ), - esc_html( substr( $css, $at, 8 ) ) - ) - ); - break; - } - } + if ( is_wp_error( $css_validity ) ) { + $validity = new WP_Error(); + $validity->add( 'illegal_markup', $css_validity->get_error_message() ); + return $validity; } - if ( ! $validity->has_errors() ) { - $validity = parent::validate( $css ); - } - return $validity; + return parent::validate( $css ); } /** diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index 3b546c30eebd0..cd76ec4708009 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -5557,6 +5557,81 @@ function wp_strip_all_tags( $text, $remove_breaks = false ) { return trim( $text ); } +/** + * Validates that a CSS string does not contain markup that could break out of + * an HTML STYLE element. + * + * Custom CSS text is expected to render inside a `` + * closing tag (or a partial prefix of one at the end of the string) must not appear + * within the CSS because it would close the element prematurely and potentially + * introduce security issues. + * + * This function consolidates the validation logic previously duplicated in + * {@see WP_REST_Global_Styles_Controller::validate_custom_css()} and + * {@see WP_Customize_Custom_CSS_Setting::validate()}. + * + * @since 7.0.0 + * + * @param string $css CSS to validate. + * @return true|WP_Error True if the CSS is safe for use inside a `` tag. + * + * @see https://html.spec.whatwg.org/multipage/parsing.html#rawtext-end-tag-name-state + */ + $possible_style_close_tag = 0 === substr_compare( + $css, + ' 400 ) + ); + } + + if ( 1 === strspn( $css, " \t\f\r\n/>", $at + 7, 1 ) ) { + return new WP_Error( + 'illegal_css_markup', + sprintf( + /* translators: %s is the CSS that was provided. */ + __( 'The CSS must not contain "%s".' ), + esc_html( substr( $css, $at, 8 ) ) + ), + array( 'status' => 400 ) + ); + } + } + } + + return true; +} + /** * Sanitizes a string from user input or from the database. * diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php index 11b1478537ed7..4b4355de745e2 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php @@ -674,79 +674,20 @@ public function get_theme_items( $request ) { * either through a STYLE end tag or a prefix of one which might become a * full end tag when combined with the contents of other styles. * - * @see WP_Customize_Custom_CSS_Setting::validate() + * @see wp_validate_css_for_style_element() * * @param string $css CSS to validate. * @return true|WP_Error True if the input was validated, otherwise WP_Error. */ protected function validate_custom_css( $css ) { - $length = strlen( $css ); - for ( - $at = strcspn( $css, '<' ); - $at < $length; - $at += strcspn( $css, '<', ++$at ) - ) { - $remaining_strlen = $length - $at; - /** - * Custom CSS text is expected to render inside an HTML STYLE element. - * A STYLE closing tag must not appear within the CSS text because it - * would close the element prematurely. - * - * The text must also *not* end with a partial closing tag (e.g., `<`, - * `` tag. - * - * Example: - * - * $style_a = 'p { font-weight: bold; 400 ) - ); - } + $validity = wp_validate_css_for_style_element( $css ); - if ( 1 === strspn( $css, " \t\f\r\n/>", $at + 7, 1 ) ) { - return new WP_Error( - 'rest_custom_css_illegal_markup', - sprintf( - /* translators: %s is the CSS that was provided. */ - __( 'The CSS must not contain "%s".' ), - esc_html( substr( $css, $at, 8 ) ) - ), - array( 'status' => 400 ) - ); - } - } + if ( is_wp_error( $validity ) ) { + return new WP_Error( + 'rest_custom_css_illegal_markup', + $validity->get_error_message(), + array( 'status' => 400 ) + ); } return true; From a00735058cd02dcdb2a6d4163a78faa286df9aea Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Tue, 10 Mar 2026 09:45:32 +1300 Subject: [PATCH 4/5] Revert "Sanitise CSS separate to other attribs" This reverts commit 35eab157fe83798f4bb50e21b1d06102d3bd38c2. --- src/wp-includes/block-supports/custom-css.php | 5 +- src/wp-includes/blocks.php | 78 ++++++++--------- .../class-wp-customize-custom-css-setting.php | 83 +++++++++++++++++-- src/wp-includes/formatting.php | 75 ----------------- ...class-wp-rest-global-styles-controller.php | 75 +++++++++++++++-- 5 files changed, 178 insertions(+), 138 deletions(-) diff --git a/src/wp-includes/block-supports/custom-css.php b/src/wp-includes/block-supports/custom-css.php index 02b6ba70e0ef7..9d5b13426f4ef 100644 --- a/src/wp-includes/block-supports/custom-css.php +++ b/src/wp-includes/block-supports/custom-css.php @@ -26,9 +26,8 @@ function wp_render_custom_css_support_styles( $parsed_block ) { return $parsed_block; } - // Validate CSS cannot break out of a ` tag. + * + * Example: + * + * $style_a = 'p { font-weight: bold; add( + 'illegal_markup', + sprintf( + /* translators: %s is the CSS that was provided. */ + __( 'The CSS must not end in "%s".' ), + esc_html( substr( $css, $at ) ) + ) + ); + break; + } + + if ( 1 === strspn( $css, " \t\f\r\n/>", $at + 7, 1 ) ) { + $validity->add( + 'illegal_markup', + sprintf( + /* translators: %s is the CSS that was provided. */ + __( 'The CSS must not contain "%s".' ), + esc_html( substr( $css, $at, 8 ) ) + ) + ); + break; + } + } } - return parent::validate( $css ); + if ( ! $validity->has_errors() ) { + $validity = parent::validate( $css ); + } + return $validity; } /** diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index cd76ec4708009..3b546c30eebd0 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -5557,81 +5557,6 @@ function wp_strip_all_tags( $text, $remove_breaks = false ) { return trim( $text ); } -/** - * Validates that a CSS string does not contain markup that could break out of - * an HTML STYLE element. - * - * Custom CSS text is expected to render inside a `` - * closing tag (or a partial prefix of one at the end of the string) must not appear - * within the CSS because it would close the element prematurely and potentially - * introduce security issues. - * - * This function consolidates the validation logic previously duplicated in - * {@see WP_REST_Global_Styles_Controller::validate_custom_css()} and - * {@see WP_Customize_Custom_CSS_Setting::validate()}. - * - * @since 7.0.0 - * - * @param string $css CSS to validate. - * @return true|WP_Error True if the CSS is safe for use inside a `` tag. - * - * @see https://html.spec.whatwg.org/multipage/parsing.html#rawtext-end-tag-name-state - */ - $possible_style_close_tag = 0 === substr_compare( - $css, - ' 400 ) - ); - } - - if ( 1 === strspn( $css, " \t\f\r\n/>", $at + 7, 1 ) ) { - return new WP_Error( - 'illegal_css_markup', - sprintf( - /* translators: %s is the CSS that was provided. */ - __( 'The CSS must not contain "%s".' ), - esc_html( substr( $css, $at, 8 ) ) - ), - array( 'status' => 400 ) - ); - } - } - } - - return true; -} - /** * Sanitizes a string from user input or from the database. * diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php index 4b4355de745e2..11b1478537ed7 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php @@ -674,20 +674,79 @@ public function get_theme_items( $request ) { * either through a STYLE end tag or a prefix of one which might become a * full end tag when combined with the contents of other styles. * - * @see wp_validate_css_for_style_element() + * @see WP_Customize_Custom_CSS_Setting::validate() * * @param string $css CSS to validate. * @return true|WP_Error True if the input was validated, otherwise WP_Error. */ protected function validate_custom_css( $css ) { - $validity = wp_validate_css_for_style_element( $css ); - - if ( is_wp_error( $validity ) ) { - return new WP_Error( - 'rest_custom_css_illegal_markup', - $validity->get_error_message(), - array( 'status' => 400 ) + $length = strlen( $css ); + for ( + $at = strcspn( $css, '<' ); + $at < $length; + $at += strcspn( $css, '<', ++$at ) + ) { + $remaining_strlen = $length - $at; + /** + * Custom CSS text is expected to render inside an HTML STYLE element. + * A STYLE closing tag must not appear within the CSS text because it + * would close the element prematurely. + * + * The text must also *not* end with a partial closing tag (e.g., `<`, + * `` tag. + * + * Example: + * + * $style_a = 'p { font-weight: bold; 400 ) + ); + } + + if ( 1 === strspn( $css, " \t\f\r\n/>", $at + 7, 1 ) ) { + return new WP_Error( + 'rest_custom_css_illegal_markup', + sprintf( + /* translators: %s is the CSS that was provided. */ + __( 'The CSS must not contain "%s".' ), + esc_html( substr( $css, $at, 8 ) ) + ), + array( 'status' => 400 ) + ); + } + } } return true; From 19537eaf6deda6457b0fecee7d32700419bb4026 Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Tue, 10 Mar 2026 16:27:11 +1300 Subject: [PATCH 5/5] switch to encoded string to stop kses mangling --- src/wp-includes/block-supports/custom-css.php | 5 -- src/wp-includes/blocks.php | 62 ++++++------------- 2 files changed, 20 insertions(+), 47 deletions(-) diff --git a/src/wp-includes/block-supports/custom-css.php b/src/wp-includes/block-supports/custom-css.php index 9d5b13426f4ef..83ab0841e2748 100644 --- a/src/wp-includes/block-supports/custom-css.php +++ b/src/wp-includes/block-supports/custom-css.php @@ -26,11 +26,6 @@ function wp_render_custom_css_support_styles( $parsed_block ) { return $parsed_block; } - // Validate CSS doesn't contain HTML markup (same validation as global styles REST API). - if ( preg_match( '#, + * and & that are valid in CSS but would be mangled by wp_kses(), which treats + * all values as HTML. Encode these characters as JSON unicode escapes before + * KSES runs, then decode afterwards. This is the same approach used for + * Global Styles custom CSS (see r61486). + */ + $has_block_css = isset( $block['attrs']['style']['css'] ); + if ( $has_block_css ) { + // wp_json_encode wraps the string in quotes: "encoded content". + // Trim them to get the raw escaped content as a PHP string. + $block['attrs']['style']['css'] = trim( + wp_json_encode( $block['attrs']['style']['css'], JSON_HEX_TAG | JSON_HEX_AMP ), + '"' + ); + } + $block['attrs'] = filter_block_kses_value( $block['attrs'], $allowed_html, $allowed_protocols, $block ); - // Per-block custom CSS (attrs.style.css) may contain & and > as valid - // CSS selectors. wp_kses() entity-encodes these because it treats the - // value as HTML. Decode them after KSES has already stripped any - // dangerous HTML tags, so the CSS round-trips correctly through - // serialize_block_attributes(). - if ( isset( $block['attrs']['style']['css'] ) ) { - $block['attrs']['style']['css'] = undo_block_custom_css_kses_entities( - $block['attrs']['style']['css'] + if ( $has_block_css && isset( $block['attrs']['style']['css'] ) ) { + $block['attrs']['style']['css'] = json_decode( + '"' . $block['attrs']['style']['css'] . '"' ); } @@ -2135,40 +2147,6 @@ function filter_block_kses_value( $value, $allowed_html, $allowed_protocols = ar return $value; } -/** - * Decodes HTML entities in per-block custom CSS that were incorrectly - * introduced by wp_kses() during the block KSES filtering pipeline. - * - * Per-block custom CSS (stored in attrs.style.css) may contain & and > - * as valid CSS selectors (nesting and child combinator). When wp_kses() - * processes this CSS string as if it were HTML, it entity-encodes these - * characters (&, >). If the block is then re-serialized via - * serialize_block_attributes(), the entity's ampersand is escaped again - * (\u0026amp;), producing a double-encoded value that corrupts the CSS - * on subsequent editor loads. - * - * This reverses only the specific named entities that wp_kses() may - * introduce, intentionally narrower than wp_specialchars_decode() to - * avoid decoding numeric/hex references that KSES intentionally preserved. - * - * @since 7.0 - * - * @param string $value Per-block custom CSS string potentially containing - * KSES-introduced entities. - * @return string CSS string with KSES-introduced entities decoded. - */ -function undo_block_custom_css_kses_entities( $value ) { - if ( ! is_string( $value ) || false === strpos( $value, '&' ) ) { - return $value; - } - - return str_replace( - array( '&', '>', '"', ''' ), - array( '&', '>', '"', "'" ), - $value - ); -} - /** * Sanitizes the value of the Template Part block's `tagName` attribute. *