Skip to content

Commit a76d016

Browse files
committed
Skip KSES for block custom CSS; add wp_validate_css_for_style_element()
Per-block attrs.style.css is sanitized with strip_tags and the shared STYLE-element validator instead of KSES so it isn't entity-encoded. Customizer and REST global styles now use the same validation helper.
1 parent 9ce5419 commit a76d016

5 files changed

Lines changed: 91 additions & 152 deletions

File tree

src/wp-includes/block-supports/custom-css.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ function wp_render_custom_css_support_styles( $parsed_block ) {
2626
return $parsed_block;
2727
}
2828

29-
// Validate CSS doesn't contain HTML markup (same validation as global styles REST API).
30-
if ( preg_match( '#</?\w+#', $custom_css ) ) {
29+
// Validate CSS is safe for a STYLE element (same rules as global styles and Customizer).
30+
if ( is_wp_error( wp_validate_css_for_style_element( $custom_css ) ) ) {
3131
return $parsed_block;
3232
}
3333

src/wp-includes/blocks.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2075,8 +2075,20 @@ function _filter_block_content_callback( $matches ) {
20752075
* @return array The filtered and sanitized block object result.
20762076
*/
20772077
function filter_block_kses( $block, $allowed_html, $allowed_protocols = array() ) {
2078+
// Per-block custom CSS is not HTML; skip KSES and sanitize with strip_tags + validation instead.
2079+
$block_custom_css = null;
2080+
if ( isset( $block['attrs']['style']['css'] ) && is_string( $block['attrs']['style']['css'] ) ) {
2081+
$block_custom_css = $block['attrs']['style']['css'];
2082+
$block['attrs']['style']['css'] = '';
2083+
}
2084+
20782085
$block['attrs'] = filter_block_kses_value( $block['attrs'], $allowed_html, $allowed_protocols, $block );
20792086

2087+
if ( null !== $block_custom_css ) {
2088+
$css = wp_strip_all_tags( $block_custom_css );
2089+
$block['attrs']['style']['css'] = is_wp_error( wp_validate_css_for_style_element( $css ) ) ? '' : $css;
2090+
}
2091+
20802092
if ( is_array( $block['innerBlocks'] ) ) {
20812093
foreach ( $block['innerBlocks'] as $i => $inner_block ) {
20822094
$block['innerBlocks'][ $i ] = filter_block_kses( $inner_block, $allowed_html, $allowed_protocols );
@@ -2092,6 +2104,7 @@ function filter_block_kses( $block, $allowed_html, $allowed_protocols = array()
20922104
*
20932105
* @since 5.3.1
20942106
* @since 6.5.5 Added the `$block_context` parameter.
2107+
* @since 7.0.0 Per-block custom CSS (attrs.style.css) is no longer passed here; see filter_block_kses().
20952108
*
20962109
* @param string[]|string $value The attribute value to filter.
20972110
* @param array[]|string $allowed_html An array of allowed HTML elements and attributes,

src/wp-includes/customize/class-wp-customize-custom-css-setting.php

Lines changed: 8 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,9 @@ public function value() {
153153
* @since 4.7.0
154154
* @since 4.9.0 Checking for balanced characters has been moved client-side via linting in code editor.
155155
* @since 5.9.0 Renamed `$css` to `$value` for PHP 8 named parameter support.
156-
* @since 7.0.0 Only restricts contents which risk prematurely closing the STYLE element,
157-
* either through a STYLE end tag or a prefix of one which might become a
158-
* full end tag when combined with the contents of other styles.
156+
* @since 7.0.0 Delegates to wp_validate_css_for_style_element().
159157
*
158+
* @see wp_validate_css_for_style_element()
160159
* @see WP_REST_Global_Styles_Controller::validate_custom_css()
161160
*
162161
* @param string $value CSS to validate.
@@ -166,81 +165,14 @@ public function validate( $value ) {
166165
// Restores the more descriptive, specific name for use within this method.
167166
$css = $value;
168167

169-
$validity = new WP_Error();
170-
171-
$length = strlen( $css );
172-
for (
173-
$at = strcspn( $css, '<' );
174-
$at < $length;
175-
$at += strcspn( $css, '<', ++$at )
176-
) {
177-
$remaining_strlen = $length - $at;
178-
/**
179-
* Custom CSS text is expected to render inside an HTML STYLE element.
180-
* A STYLE closing tag must not appear within the CSS text because it
181-
* would close the element prematurely.
182-
*
183-
* The text must also *not* end with a partial closing tag (e.g., `<`,
184-
* `</`, … `</style`) because subsequent styles which are concatenated
185-
* could complete it, forming a valid `</style>` tag.
186-
*
187-
* Example:
188-
*
189-
* $style_a = 'p { font-weight: bold; </sty';
190-
* $style_b = 'le> gotcha!';
191-
* $combined = "{$style_a}{$style_b}";
192-
*
193-
* $style_a = 'p { font-weight: bold; </style';
194-
* $style_b = 'p > b { color: red; }';
195-
* $combined = "{$style_a}\n{$style_b}";
196-
*
197-
* Note how in the second example, both of the style contents are benign
198-
* when analyzed on their own. The first style was likely the result of
199-
* improper truncation, while the second is perfectly sound. It was only
200-
* through concatenation that these two styles combined to form content
201-
* that would have broken out of the containing STYLE element, thus
202-
* corrupting the page and potentially introducing security issues.
203-
*
204-
* @see https://html.spec.whatwg.org/multipage/parsing.html#rawtext-end-tag-name-state
205-
*/
206-
$possible_style_close_tag = 0 === substr_compare(
207-
$css,
208-
'</style',
209-
$at,
210-
min( 7, $remaining_strlen ),
211-
true
212-
);
213-
if ( $possible_style_close_tag ) {
214-
if ( $remaining_strlen < 8 ) {
215-
$validity->add(
216-
'illegal_markup',
217-
sprintf(
218-
/* translators: %s is the CSS that was provided. */
219-
__( 'The CSS must not end in "%s".' ),
220-
esc_html( substr( $css, $at ) )
221-
)
222-
);
223-
break;
224-
}
225-
226-
if ( 1 === strspn( $css, " \t\f\r\n/>", $at + 7, 1 ) ) {
227-
$validity->add(
228-
'illegal_markup',
229-
sprintf(
230-
/* translators: %s is the CSS that was provided. */
231-
__( 'The CSS must not contain "%s".' ),
232-
esc_html( substr( $css, $at, 8 ) )
233-
)
234-
);
235-
break;
236-
}
237-
}
168+
$result = wp_validate_css_for_style_element( $css );
169+
if ( is_wp_error( $result ) ) {
170+
$validity = new WP_Error();
171+
$validity->add( 'illegal_markup', $result->get_error_message() );
172+
return $validity;
238173
}
239174

240-
if ( ! $validity->has_errors() ) {
241-
$validity = parent::validate( $css );
242-
}
243-
return $validity;
175+
return parent::validate( $css );
244176
}
245177

246178
/**

src/wp-includes/formatting.php

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5557,6 +5557,69 @@ function wp_strip_all_tags( $text, $remove_breaks = false ) {
55575557
return trim( $text );
55585558
}
55595559

5560+
/**
5561+
* Validates that CSS is safe to output inside an HTML STYLE element.
5562+
*
5563+
* Rejects CSS that contains `</style>` or a partial closing tag (e.g. `</sty`,
5564+
* `</style`) that could become a full closing tag when concatenated with other
5565+
* styles, which would break out of the STYLE element and risk XSS.
5566+
*
5567+
* Used by the Global Styles REST API, the Customizer custom CSS setting, and
5568+
* per-block custom CSS so all CSS-in-style-element flows share the same rules.
5569+
*
5570+
* @since 7.0.0
5571+
*
5572+
* @see WP_REST_Global_Styles_Controller::validate_custom_css()
5573+
* @see WP_Customize_Custom_CSS_Setting::validate()
5574+
*
5575+
* @param string $css CSS to validate.
5576+
* @return true|WP_Error True if the CSS is safe for a STYLE element, otherwise WP_Error.
5577+
*/
5578+
function wp_validate_css_for_style_element( $css ) {
5579+
$length = strlen( $css );
5580+
for (
5581+
$at = strcspn( $css, '<' );
5582+
$at < $length;
5583+
$at += 1 + strcspn( $css, '<', $at + 1 )
5584+
) {
5585+
$remaining_strlen = $length - $at;
5586+
$possible_style_close_tag = 0 === substr_compare(
5587+
$css,
5588+
'</style',
5589+
$at,
5590+
min( 7, $remaining_strlen ),
5591+
true
5592+
);
5593+
if ( $possible_style_close_tag ) {
5594+
if ( $remaining_strlen < 8 ) {
5595+
return new WP_Error(
5596+
'rest_custom_css_illegal_markup',
5597+
sprintf(
5598+
/* translators: %s is the CSS that was provided. */
5599+
__( 'The CSS must not end in "%s".' ),
5600+
esc_html( substr( $css, $at ) )
5601+
),
5602+
array( 'status' => 400 )
5603+
);
5604+
}
5605+
5606+
if ( 1 === strspn( $css, " \t\f\r\n/>", $at + 7, 1 ) ) {
5607+
return new WP_Error(
5608+
'rest_custom_css_illegal_markup',
5609+
sprintf(
5610+
/* translators: %s is the CSS that was provided. */
5611+
__( 'The CSS must not contain "%s".' ),
5612+
esc_html( substr( $css, $at, 8 ) )
5613+
),
5614+
array( 'status' => 400 )
5615+
);
5616+
}
5617+
}
5618+
}
5619+
5620+
return true;
5621+
}
5622+
55605623
/**
55615624
* Sanitizes a string from user input or from the database.
55625625
*

src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php

Lines changed: 5 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -666,89 +666,20 @@ public function get_theme_items( $request ) {
666666
/**
667667
* Validate style.css as valid CSS.
668668
*
669-
* Currently just checks that CSS will not break an HTML STYLE tag.
669+
* Delegates to wp_validate_css_for_style_element() so global styles, Customizer,
670+
* and per-block custom CSS share the same validation rules.
670671
*
671672
* @since 6.2.0
672673
* @since 6.4.0 Changed method visibility to protected.
673-
* @since 7.0.0 Only restricts contents which risk prematurely closing the STYLE element,
674-
* either through a STYLE end tag or a prefix of one which might become a
675-
* full end tag when combined with the contents of other styles.
674+
* @since 7.0.0 Delegates to wp_validate_css_for_style_element().
676675
*
676+
* @see wp_validate_css_for_style_element()
677677
* @see WP_Customize_Custom_CSS_Setting::validate()
678678
*
679679
* @param string $css CSS to validate.
680680
* @return true|WP_Error True if the input was validated, otherwise WP_Error.
681681
*/
682682
protected function validate_custom_css( $css ) {
683-
$length = strlen( $css );
684-
for (
685-
$at = strcspn( $css, '<' );
686-
$at < $length;
687-
$at += strcspn( $css, '<', ++$at )
688-
) {
689-
$remaining_strlen = $length - $at;
690-
/**
691-
* Custom CSS text is expected to render inside an HTML STYLE element.
692-
* A STYLE closing tag must not appear within the CSS text because it
693-
* would close the element prematurely.
694-
*
695-
* The text must also *not* end with a partial closing tag (e.g., `<`,
696-
* `</`, … `</style`) because subsequent styles which are concatenated
697-
* could complete it, forming a valid `</style>` tag.
698-
*
699-
* Example:
700-
*
701-
* $style_a = 'p { font-weight: bold; </sty';
702-
* $style_b = 'le> gotcha!';
703-
* $combined = "{$style_a}{$style_b}";
704-
*
705-
* $style_a = 'p { font-weight: bold; </style';
706-
* $style_b = 'p > b { color: red; }';
707-
* $combined = "{$style_a}\n{$style_b}";
708-
*
709-
* Note how in the second example, both of the style contents are benign
710-
* when analyzed on their own. The first style was likely the result of
711-
* improper truncation, while the second is perfectly sound. It was only
712-
* through concatenation that these two styles combined to form content
713-
* that would have broken out of the containing STYLE element, thus
714-
* corrupting the page and potentially introducing security issues.
715-
*
716-
* @link https://html.spec.whatwg.org/multipage/parsing.html#rawtext-end-tag-name-state
717-
*/
718-
$possible_style_close_tag = 0 === substr_compare(
719-
$css,
720-
'</style',
721-
$at,
722-
min( 7, $remaining_strlen ),
723-
true
724-
);
725-
if ( $possible_style_close_tag ) {
726-
if ( $remaining_strlen < 8 ) {
727-
return new WP_Error(
728-
'rest_custom_css_illegal_markup',
729-
sprintf(
730-
/* translators: %s is the CSS that was provided. */
731-
__( 'The CSS must not end in "%s".' ),
732-
esc_html( substr( $css, $at ) )
733-
),
734-
array( 'status' => 400 )
735-
);
736-
}
737-
738-
if ( 1 === strspn( $css, " \t\f\r\n/>", $at + 7, 1 ) ) {
739-
return new WP_Error(
740-
'rest_custom_css_illegal_markup',
741-
sprintf(
742-
/* translators: %s is the CSS that was provided. */
743-
__( 'The CSS must not contain "%s".' ),
744-
esc_html( substr( $css, $at, 8 ) )
745-
),
746-
array( 'status' => 400 )
747-
);
748-
}
749-
}
750-
}
751-
752-
return true;
683+
return wp_validate_css_for_style_element( $css );
753684
}
754685
}

0 commit comments

Comments
 (0)