-
Notifications
You must be signed in to change notification settings - Fork 3.2k
Customizer: Allow arbitrary custom CSS #10667
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: trunk
Are you sure you want to change the base?
Customizer: Allow arbitrary custom CSS #10667
Conversation
This reverts commit 6c6a72b.
…er-allow-arbitrary-custom-css
Arbitrary CSS is allowed and escaped when printed.
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Test using WordPress PlaygroundThe changes in this pull request can previewed and tested using a WordPress Playground instance. WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser. Some things to be aware of
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
westonruter
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a couple trivial suggestions, but otherwise this looks great.
Co-authored-by: Weston Ruter <westonruter@gmail.com>
Co-authored-by: Weston Ruter <westonruter@gmail.com>
…er-allow-arbitrary-custom-css
…er-allow-arbitrary-custom-css
|
#10641 includes some protection to ensure bad style tag contents are not saved (including This change should be fine because of the work in #10656. Is it worth including that additional safety here? On one hand, this change should be perfectly safe now. On the other hand, there's not a very good reason to allow possible style tag closers inside of style tags. It will be safely escaped by the HTML API, but I wonder whether it's more helpful to prevent it from ever being stored. |
|
In other words, to restore the if ( preg_match( '#</?\w+#', $css ) ) {To be rather: if ( false !== stripos( $css, '</style' ) ) {Right? That seems fine to me. To be more conservative, and only make it even looser if we find this is actually needed. |
|
Can we get test coverage added for the example CSS in the PR description? |
peterwilsoncc
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit pick inline.
Some users may have the customize capability but not the unfiltered_html capability.
For those users I think it would be good to run urls through the kses protocol check, see this section of code in the safe css portion of kses.
wordpress-develop/src/wp-includes/kses.php
Lines 2885 to 2908 in 6d60f73
| if ( $found && $url_attr ) { | |
| // Simplified: matches the sequence `url(*)`. | |
| preg_match_all( '/url\([^)]+\)/', $parts[1], $url_matches ); | |
| foreach ( $url_matches[0] as $url_match ) { | |
| // Clean up the URL from each of the matches above. | |
| preg_match( '/^url\(\s*([\'\"]?)(.*)(\g1)\s*\)$/', $url_match, $url_pieces ); | |
| if ( empty( $url_pieces[2] ) ) { | |
| $found = false; | |
| break; | |
| } | |
| $url = trim( $url_pieces[2] ); | |
| if ( empty( $url ) || wp_kses_bad_protocol( $url, $allowed_protocols ) !== $url ) { | |
| $found = false; | |
| break; | |
| } else { | |
| // Remove the whole `url(*)` bit that was matched above from the CSS. | |
| $css_test_string = str_replace( $url_match, '', $css_test_string ); | |
| } | |
| } | |
| } |
Apart from that I think it's fine to bypass kses, the allow list it currently uses is currently dated and represents a time that browsers where less secure than they are now.
This may not be entirely relevant here because the Custom CSS requires the
And this is treated the same as wordpress-develop/src/wp-includes/capabilities.php Lines 594 to 595 in 1fdc11a
|
I believe this is covered by
I'm reluctant to add this if avoidable. If there are specific concerns I'm happy to work on addressing them. Do you know whether this filter was already being run on the content? One concern I have is that these functions are working with HTML entities, indicating they're not designed to operate on CSS or the RAWTEXT content of STYLE tags. This type of content-type processing mismatch is why this change is necessary. wordpress-develop/src/wp-includes/kses.php Lines 1995 to 2007 in bee3356
wordpress-develop/src/wp-includes/kses.php Lines 2030 to 2043 in bee3356
|
|
Thanks everyone! I believe all feedback is addressed. |
|
After further discussion, it seemed prudent to restore the validation method and reject CSS that contains a closing style tag I've added that implementation and corresponding tests. |
Sorry, wrong cap but it's still possible someone can edit css without using unfiltered HTML with something like: // Untested so there may be a syntax error.
add_filter( 'map_meta_cap', function ( $cap, $caps ) {
if ( 'unfiltered_html' === $cap ) {
$caps[] = 'do_not_allow';
}
return $caps;
}, 10, 2 );The above would place URL protocol restrictions on all users so I think it's needed for this too. |
|
@sirreal Are such protocol restrictions being enforced in the Site Editor? |
|
I spent a lot of time testing the behavior here. I set up a multisite install for testing testing network admins and site admins and performed the same tests on a single site install. Multisite site admins do not have sufficient capabilities and their additional CSS edits are rejected. The additional CSS panel does not appear in customize for them and AJAX requests are rejected: {
"custom_css[twentyten]": {
"unauthorized": {
"message": "Unauthorized to modify setting due to capability.",
"data": null
}
}
}Multisite network (super) admins have access, just like regular admins on single site installs. I added debugging inside the related filters and I never saw them run when updating this setting. I believe that's because either the user has I've removed the filter changes from this PR because they don't seem to be necessary. I worked on this simultaneously with #10641, and I may have changed the filters here after confusing test results on the two PRs. All tests are passing on this PR without any changes to filters. This supports the idea that any user with sufficient capabilities to modify custom CSS will not have the CSS content filtered by KSES. Discussion related to content filtering should no longer be relevant for this change. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Updates Customizer custom CSS handling to permit arbitrary CSS text (including </> used by modern CSS constructs) while preventing CSS from breaking out of the <style> element, and adds regression tests for the behavior described in Trac #64418.
Changes:
- Relax
WP_Customize_Custom_CSS_Setting::validate()to only reject sequences that can prematurely close a<style>element (or partial end-tags that could become one when concatenated). - Add PHPUnit coverage for safely printing custom CSS and for accepting
@propertyat-rules without KSES mangling. - Add a cross-reference in REST global styles CSS validation docs to the Customizer validator.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| tests/phpunit/tests/customize/custom-css-setting.php | Adds tests for safe <style> output and updated validation behavior (including @property). |
| src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php | Adds @see linking REST CSS validation docs to the Customizer validator. |
| src/wp-includes/customize/class-wp-customize-custom-css-setting.php | Replaces broad markup detection with targeted </style / partial end-tag detection while preserving parent CSS validation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * 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 scripts combined to form content |
Copilot
AI
Jan 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this explanatory block comment, it says "these two scripts combined" but the examples and surrounding text are about concatenating styles (CSS), not scripts. Please update the wording to avoid confusion in this security-relevant rationale.
| * through concatenation that these two scripts combined to form content | |
| * through concatenation that these two styles combined to form content |
| $length = strlen( $css ); | ||
| for ( | ||
| $at = strcspn( $css, '<' ); | ||
| $at < $length; | ||
| $at += strcspn( $css, '<', ++$at ) | ||
| ) { | ||
| $remaining_strlen = $length - $at; |
Copilot
AI
Jan 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This STYLE end-tag scanning logic is now duplicated here and in WP_REST_Global_Styles_Controller::validate_custom_css() (see src/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php:682+). To reduce the chance of the two validators drifting, consider extracting the shared check into a small internal helper function and reusing it from both call sites (mapping to different error codes as needed).
westonruter
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just some nits, but looks good.
| $css = <<<'CSS' | ||
| @property --animate { | ||
| syntax: "<custom-ident>"; | ||
| inherits: true; | ||
| initial-value: false; | ||
| } | ||
| CSS; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can all be indented now for readability, yes?
| $css = <<<'CSS' | |
| @property --animate { | |
| syntax: "<custom-ident>"; | |
| inherits: true; | |
| initial-value: false; | |
| } | |
| CSS; | |
| $css = <<<'CSS' | |
| @property --animate { | |
| syntax: "<custom-ident>"; | |
| inherits: true; | |
| initial-value: false; | |
| } | |
| CSS; |
| $css = <<<'CSS' | ||
| @property --animate { | ||
| syntax: "<custom-ident>"; | ||
| inherits: true; | ||
| initial-value: false; | ||
| } | ||
| CSS; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| $css = <<<'CSS' | |
| @property --animate { | |
| syntax: "<custom-ident>"; | |
| inherits: true; | |
| initial-value: false; | |
| } | |
| CSS; | |
| $css = <<<'CSS' | |
| @property --animate { | |
| syntax: "<custom-ident>"; | |
| inherits: true; | |
| initial-value: false; | |
| } | |
| CSS; |
| $expected = <<<'HTML' | ||
| <style id="wp-custom-css"> | ||
| *::before { content: "\3c\2fstyle><script>alert(1)</script>"; } | ||
| </style> | ||
| HTML; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| $expected = <<<'HTML' | |
| <style id="wp-custom-css"> | |
| *::before { content: "\3c\2fstyle><script>alert(1)</script>"; } | |
| </style> | |
| HTML; | |
| $expected = <<<'HTML' | |
| <style id="wp-custom-css"> | |
| *::before { content: "\3c\2fstyle><script>alert(1)</script>"; } | |
| </style> | |
| HTML; |
| // Restores the more descriptive, specific name for use within this method. | ||
| $css = $value; | ||
|
|
||
| $validity = new WP_Error(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not critical, this could be restored with the suggestion below.
| return new WP_Error( | ||
| '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 ) | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To retain the prior structure, since in theory multiple errors could be added for an invalid setting (although this doesn't currently happen here):
| return new WP_Error( | |
| '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 ) | |
| ); | |
| $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; |
This also removes the 400 status code, which isn't relevant in this context.
| if ( ! $validity->has_errors() ) { | ||
| $validity = parent::validate( $css ); | ||
| } | ||
| return $validity; | ||
| return parent::validate( $css ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change could be reverted with the above suggestions.
Under some circumstances KSES would run post content filters and change the resulting content like this:
@property --animate { - syntax: "<custom-ident>"; + syntax: ""; inherits: true; initial-value: false; }✅ Merged in [61418]. ~This depends on #10656 to ensure styles output is safely printed in the HTML (merged here).`
r61486 relaxed CSS content checks for Global styles with some additional restrictions that do not apply here.
Trac ticket: https://core.trac.wordpress.org/ticket/64418
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.