Skip to content

Commit d188679

Browse files
committed
Customize: Allow arbitrary custom CSS.
Update custom CSS validation to allow any CSS except `STYLE` close tags. Previously, some valid CSS would be rejected for containing HTML syntax characters, like this example: {{{ @Property --animate { syntax: "<custom-ident>"; /* <-- Validation error on `<` */ inherits: true; initial-value: false; } }}} Developed in WordPress#10667. Follow-up to [61418], [61486]. Props jonsurrell, westonruter, peterwilsoncc, johnbillion, xknown, sabernhardt, dmsnell, soyebsalar01, dlh. Fixes #64418. git-svn-id: https://develop.svn.wordpress.org/trunk@61527 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 14feeb5 commit d188679

3 files changed

Lines changed: 177 additions & 6 deletions

File tree

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

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@ 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.
159+
*
160+
* @see WP_REST_Global_Styles_Controller::validate_custom_css()
156161
*
157162
* @param string $value CSS to validate.
158163
* @return true|WP_Error True if the input was validated, otherwise WP_Error.
@@ -163,8 +168,73 @@ public function validate( $value ) {
163168

164169
$validity = new WP_Error();
165170

166-
if ( preg_match( '#</?\w+#', $css ) ) {
167-
$validity->add( 'illegal_markup', __( 'Markup is not allowed in CSS.' ) );
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+
}
168238
}
169239

170240
if ( ! $validity->has_errors() ) {

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,8 @@ public function get_theme_items( $request ) {
674674
* either through a STYLE end tag or a prefix of one which might become a
675675
* full end tag when combined with the contents of other styles.
676676
*
677+
* @see WP_Customize_Custom_CSS_Setting::validate()
678+
*
677679
* @param string $css CSS to validate.
678680
* @return true|WP_Error True if the input was validated, otherwise WP_Error.
679681
*/
@@ -707,7 +709,7 @@ protected function validate_custom_css( $css ) {
707709
* Note how in the second example, both of the style contents are benign
708710
* when analyzed on their own. The first style was likely the result of
709711
* improper truncation, while the second is perfectly sound. It was only
710-
* through concatenation that these two scripts combined to form content
712+
* through concatenation that these two styles combined to form content
711713
* that would have broken out of the containing STYLE element, thus
712714
* corrupting the page and potentially introducing security issues.
713715
*

tests/phpunit/tests/customize/custom-css-setting.php

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,31 @@ public function filter_update_custom_css_data( $data, $args ) {
374374
return $data;
375375
}
376376

377+
/**
378+
* Ensure that dangerous STYLE tag contents do not break HTML output.
379+
*
380+
* @ticket 64418
381+
* @covers ::wp_update_custom_css_post
382+
* @covers ::wp_custom_css_cb
383+
*/
384+
public function test_wp_custom_css_cb_escapes_dangerous_html() {
385+
wp_update_custom_css_post(
386+
'*::before { content: "</style><script>alert(1)</script>"; }',
387+
array(
388+
'stylesheet' => $this->setting->stylesheet,
389+
)
390+
);
391+
$output = get_echo( 'wp_custom_css_cb' );
392+
$expected =
393+
<<<'HTML'
394+
<style id="wp-custom-css">
395+
*::before { content: "\3c\2fstyle><script>alert(1)</script>"; }
396+
</style>
397+
398+
HTML;
399+
$this->assertEqualHTML( $expected, $output );
400+
}
401+
377402
/**
378403
* Tests that validation errors are caught appropriately.
379404
*
@@ -382,8 +407,7 @@ public function filter_update_custom_css_data( $data, $args ) {
382407
*
383408
* @covers WP_Customize_Custom_CSS_Setting::validate
384409
*/
385-
public function test_validate() {
386-
410+
public function test_validate_basic_css() {
387411
// Empty CSS throws no errors.
388412
$result = $this->setting->validate( '' );
389413
$this->assertTrue( $result );
@@ -393,9 +417,84 @@ public function test_validate() {
393417
$result = $this->setting->validate( $basic_css );
394418
$this->assertTrue( $result );
395419

396-
// Check for markup.
420+
// Check for illegal closing STYLE tag.
397421
$unclosed_comment = $basic_css . '</style>';
398422
$result = $this->setting->validate( $unclosed_comment );
399423
$this->assertArrayHasKey( 'illegal_markup', $result->errors );
400424
}
425+
426+
/**
427+
* @ticket 64418
428+
* @covers WP_Customize_Custom_CSS_Setting::validate
429+
*/
430+
public function test_validate_accepts_css_property_at_rule() {
431+
$css =
432+
<<<'CSS'
433+
@property --animate {
434+
syntax: "<custom-ident>";
435+
inherits: true;
436+
initial-value: false;
437+
}
438+
CSS;
439+
$this->assertTrue( $this->setting->validate( $css ) );
440+
}
441+
442+
/**
443+
* @ticket 64418
444+
* @covers ::wp_update_custom_css_post
445+
* @covers ::wp_custom_css_cb
446+
*/
447+
public function test_save_and_print_property_at_rule() {
448+
$css =
449+
<<<'CSS'
450+
@property --animate {
451+
syntax: "<custom-ident>";
452+
inherits: true;
453+
initial-value: false;
454+
}
455+
CSS;
456+
wp_update_custom_css_post( $css, array( 'stylesheet' => $this->setting->stylesheet ) );
457+
$output = get_echo( 'wp_custom_css_cb' );
458+
$expected = "<style id='wp-custom-css'>\n{$css}\n</style>\n";
459+
$this->assertEqualHTML( $expected, $output );
460+
}
461+
462+
/**
463+
* @dataProvider data_custom_css_disallowed
464+
*
465+
* @ticket 64418
466+
* @covers WP_Customize_Custom_CSS_Setting::validate
467+
*/
468+
public function test_validate_prevents( $css, $expected_error_message ) {
469+
$result = $this->setting->validate( $css );
470+
$this->assertWPError( $result );
471+
$this->assertSame( $expected_error_message, $result->get_error_message() );
472+
}
473+
474+
/**
475+
* Data provider.
476+
*
477+
* @return array<string, string[]>
478+
*/
479+
public static function data_custom_css_disallowed(): array {
480+
return array(
481+
'style close tag' => array( 'css…</style>…css', 'The CSS must not contain "&lt;/style&gt;".' ),
482+
'style close tag upper case' => array( '</STYLE>', 'The CSS must not contain "&lt;/STYLE&gt;".' ),
483+
'style close tag mixed case' => array( '</sTyLe>', 'The CSS must not contain "&lt;/sTyLe&gt;".' ),
484+
'style close tag in comment' => array( '/*</style>*/', 'The CSS must not contain "&lt;/style&gt;".' ),
485+
'style close tag (/)' => array( '</style/', 'The CSS must not contain "&lt;/style/".' ),
486+
'style close tag (\t)' => array( "</style\t", "The CSS must not contain \"&lt;/style\t\"." ),
487+
'style close tag (\f)' => array( "</style\f", "The CSS must not contain \"&lt;/style\f\"." ),
488+
'style close tag (\r)' => array( "</style\r", "The CSS must not contain \"&lt;/style\r\"." ),
489+
'style close tag (\n)' => array( "</style\n", "The CSS must not contain \"&lt;/style\n\"." ),
490+
'style close tag (" ")' => array( '</style ', 'The CSS must not contain "&lt;/style ".' ),
491+
'truncated "<"' => array( '<', 'The CSS must not end in "&lt;".' ),
492+
'truncated "</"' => array( '</', 'The CSS must not end in "&lt;/".' ),
493+
'truncated "</s"' => array( '</s', 'The CSS must not end in "&lt;/s".' ),
494+
'truncated "</ST"' => array( '</ST', 'The CSS must not end in "&lt;/ST".' ),
495+
'truncated "</sty"' => array( '</sty', 'The CSS must not end in "&lt;/sty".' ),
496+
'truncated "</STYL"' => array( '</STYL', 'The CSS must not end in "&lt;/STYL".' ),
497+
'truncated "</stYle"' => array( '</stYle', 'The CSS must not end in "&lt;/stYle".' ),
498+
);
499+
}
401500
}

0 commit comments

Comments
 (0)