Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/wp-includes/block-supports/custom-css.php
Original file line number Diff line number Diff line change
Expand Up @@ -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( '#</?\w+#', $custom_css ) ) {
// Validate CSS cannot break out of a <style> 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;
}

Expand Down
57 changes: 57 additions & 0 deletions src/wp-includes/blocks.php
Original file line number Diff line number Diff line change
Expand Up @@ -2075,8 +2075,31 @@ 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 );
Copy link
Copy Markdown
Member

@ramonjd ramonjd Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for getting up a fix!

I was wondering: what is the important sanitization step for block CSS attributes?

wp_kses() treats the CSS string as HTML. But should it?

I'm wondering if an alternative solution would be to target the css attribute and run it through wp_strip_all_tags rather than wp_kses.

Or running through something similar (or reuse this same validation in a helper) that @sirreal and @dmsnell worked on in WP_REST_Global_Styles_Controller::validate_custom_css() for https://core.trac.wordpress.org/ticket/64418

There, for users without unfiltered_html, & and > in block custom CSS were being double-encoded by KSES + JSON, so the CSS broke.

(Sorry Jon and Dennis - you've become my default go-to brains trust for this stuff 😄 )


// 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'] ) ) {
foreach ( $block['innerBlocks'] as $i => $inner_block ) {
$block['innerBlocks'][ $i ] = filter_block_kses( $inner_block, $allowed_html, $allowed_protocols );
Expand Down Expand Up @@ -2124,6 +2147,40 @@ function filter_block_kses_value( $value, $allowed_html, $allowed_protocols = ar
return $value;
}

/**
* Sanitizes per-block custom CSS using CSS-appropriate methods.
*
* 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.
*
* 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 `<style>` element.
*
* @since 7.0.0
*
* @param string $css Per-block custom CSS string to sanitize.
* @return string Sanitized CSS string, or empty string if invalid.
*/
function wp_sanitize_block_custom_css( $css ) {
if ( ! is_string( $css ) ) {
return '';
}

// Strip HTML tags — valid CSS never contains them.
$css = wp_strip_all_tags( $css );
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might have missed this but I think there were doubts about using this for CSS

#11104 (comment)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the changes are just to push the discussion forward at this stage - if someone has ideas for an alternative, they are welcome change this, but it seems like we should be taking steps to remove anything that is obviously not CSS from those strings - but I do not have an opinion on this.


// Validate that the CSS cannot break out of a <style> element.
$validity = wp_validate_css_for_style_element( $css );
if ( is_wp_error( $validity ) ) {
return '';
}

return $css;
}

/**
* Sanitizes the value of the Template Part block's `tagName` attribute.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,90 +157,21 @@
* 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_REST_Global_Styles_Controller::validate_custom_css()
* @see wp_validate_css_for_style_element()
*
* @param string $value CSS to validate.
* @return true|WP_Error True if the input was validated, otherwise WP_Error.
*/
public function validate( $value ) {
// Restores the more descriptive, specific name for use within this method.
$css = $value;

$validity = new WP_Error();
$css_validity = wp_validate_css_for_style_element( $value );

$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., `<`,
* `</`, … `</style`) because subsequent styles which are concatenated
* could complete it, forming a valid `</style>` tag.
*
* Example:
*
* $style_a = 'p { font-weight: bold; </sty';
* $style_b = 'le> gotcha!';
* $combined = "{$style_a}{$style_b}";
*
* $style_a = 'p { font-weight: bold; </style';
* $style_b = 'p > b { color: red; }';
* $combined = "{$style_a}\n{$style_b}";
*
* Note how in the second example, both of the style contents are benign
* when analyzed on their own. The first style was likely the result of
* improper truncation, while the second is perfectly sound. It was only
* through concatenation that these two styles combined to form content
* that would have broken out of the containing STYLE element, thus
* corrupting the page and potentially introducing security issues.
*
* @see https://html.spec.whatwg.org/multipage/parsing.html#rawtext-end-tag-name-state
*/
$possible_style_close_tag = 0 === substr_compare(
$css,
'</style',
$at,
min( 7, $remaining_strlen ),
true
);
if ( $possible_style_close_tag ) {
if ( $remaining_strlen < 8 ) {
$validity->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 );

Check warning on line 174 in src/wp-includes/customize/class-wp-customize-custom-css-setting.php

View workflow job for this annotation

GitHub Actions / PHP static analysis / Run PHP static analysis

Undefined variable: $css
}

/**
Expand Down
75 changes: 75 additions & 0 deletions src/wp-includes/formatting.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<style>` element. A `</style>`
* 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 `<style>` element,
* WP_Error if it contains potentially dangerous markup.
*/
function wp_validate_css_for_style_element( $css ) {
$length = strlen( $css );
for (
$at = strcspn( $css, '<' );
$at < $length;
$at += strcspn( $css, '<', ++$at )
) {
$remaining_strlen = $length - $at;

/*
* 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., `<`,
* `</`, … `</style`) because subsequent styles which are concatenated
* could complete it, forming a valid `</style>` tag.
*
* @see https://html.spec.whatwg.org/multipage/parsing.html#rawtext-end-tag-name-state
*/
$possible_style_close_tag = 0 === substr_compare(
$css,
'</style',
$at,
min( 7, $remaining_strlen ),
true
);
if ( $possible_style_close_tag ) {
if ( $remaining_strlen < 8 ) {
return new WP_Error(
'illegal_css_markup',
sprintf(
/* translators: %s is the CSS that was provided. */
__( 'The CSS must not end in "%s".' ),
esc_html( substr( $css, $at ) )
),
array( 'status' => 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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., `<`,
* `</`, … `</style`) because subsequent styles which are concatenated
* could complete it, forming a valid `</style>` tag.
*
* Example:
*
* $style_a = 'p { font-weight: bold; </sty';
* $style_b = 'le> gotcha!';
* $combined = "{$style_a}{$style_b}";
*
* $style_a = 'p { font-weight: bold; </style';
* $style_b = 'p > b { color: red; }';
* $combined = "{$style_a}\n{$style_b}";
*
* Note how in the second example, both of the style contents are benign
* when analyzed on their own. The first style was likely the result of
* improper truncation, while the second is perfectly sound. It was only
* through concatenation that these two styles combined to form content
* that would have broken out of the containing STYLE element, thus
* corrupting the page and potentially introducing security issues.
*
* @link https://html.spec.whatwg.org/multipage/parsing.html#rawtext-end-tag-name-state
*/
$possible_style_close_tag = 0 === substr_compare(
$css,
'</style',
$at,
min( 7, $remaining_strlen ),
true
);
if ( $possible_style_close_tag ) {
if ( $remaining_strlen < 8 ) {
return new WP_Error(
'rest_custom_css_illegal_markup',
sprintf(
/* translators: %s is the CSS that was provided. */
__( 'The CSS must not end in "%s".' ),
esc_html( substr( $css, $at ) )
),
array( 'status' => 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;
Expand Down
Loading