diff --git a/custom-css.php b/custom-css.php index f186afb61642fa3e3fb3ed0dd9a766501f65670f..b8821c5ab2d46d9b32d4c6dcc14f0dd7cbea2a07 100644 --- a/custom-css.php +++ b/custom-css.php @@ -7,10 +7,77 @@ * Author URI: https://autistici.org */ -// The following code is heavily inspired by the Jetpack plugin custom-css module. -function ai_sanitize_css( $css, $args = array() ) { +/* + * This plugin sanitizes the custom CSS that admins can add to their + * websites, by removing constructs referencing URLs that can lead to + * XSS (@import, url refs, etc). The point of it is to allow all + * admins on a multisite instance to edit CSS (not just super-admins), + * via something like the multisite-custom-css plugin. + * + * It uses CSSTidy to parse the custom CSS, then inspects the CSS + * structure and removes unwanted directives, before finally rendering + * the CSS again. It does so by overriding some methods in our + * "safecss" custom subclass of the CSSTidy optimizer. + * + * The custom CSS hook structure that invokes CSSTidy is lifted from + * the Jetpack plugin, modules/custom-css/custom-css-4.7.php, with the + * sanitization part taken from + * https://meta.trac.wordpress.org/changeset/2085. + */ + +function ai_sanitize_urls_in_css_properties($url, $property) { + $property = trim($property); + $url = trim($url, "' \" \r \n"); + + // The current policy is: no URLs at all. + return ''; +} + +// Lazy load csstidy and our custom subclasses. +function ai_safecss_class() { + if (class_exists('safecss')) { + return; + } + require_once(__DIR__ . '/csstidy/class.csstidy.php'); + class safecss extends csstidy_optimise { + public function subvalue() { + $this->sub_value = trim($this->sub_value); + + if (preg_match('!^\s*(?P<url_expression>url\s*(?P<opening_paren>\(|\\0028)(?P<parenthetical_content>.*)(?P<closing_paren>\)|\\0029))(.*)$!Dis', $safecss->sub_value, $matches)) { + $this->sub_value = ai_sanitize_urls_in_css_properties($matches['parenthetical_content'], $safecss->property); + + // Only replace the url([...]) portion of the sub_value so we don't + // lose things like trailing commas or !important declarations. + if ($this->sub_value) { + $this->sub_value = str_replace($matches['url_expression'], $this->sub_value, $matches[0]); + } + } + + // Strip any expressions + if (preg_match('!^\\s*expression!Dis', $this->sub_value)) { + $this->sub_value = ''; + } + + return parent::subvalue(); + } + + public function postparse() { + // @import rules are stripped because they can enable XSS. + $this->parser->import = array(); + + // @charset rules are stripped because manipulating the charset + // can allow an attacker to introduce XSS vulnerabilities by + // tricking the browser into interpreting the CSS as HTML. + $this->parser->charset = array(); + + return parent::postparse(); + } + } +} + +function ai_sanitize_css($css, $args = array()) { $args = wp_parse_args( $args, array( @@ -20,9 +87,9 @@ function ai_sanitize_css( $css, $args = array() ) { if ($args['force'] || !current_user_can('unfiltered_html')) { $warnings = array(); - - safecss_class(); - $csstidy = new csstidy(); + + ai_safecss_class(); + $csstidy = new csstidy(); $csstidy->optimise = new safecss( $csstidy ); $csstidy->set_cfg( 'remove_bslash', false ); @@ -54,7 +121,7 @@ function ai_sanitize_css( $css, $args = array() ) { $css = wp_kses_split( $css, array(), array() ); $css = str_replace( '>', '>', $css ); // kses replaces lone '>' with > // Why both KSES and strip_tags? Because we just added some '>'. - $css = strip_tags( $css ); // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- scared to update this to wp_strip_all_tags since we're building a CSS file here. + $css = strip_tags( $css ); if ( $css !== $prev ) { $warnings[] = 'kses found stuff'; @@ -63,6 +130,7 @@ function ai_sanitize_css( $css, $args = array() ) { $csstidy->parse( $css ); $css = $csstidy->print->plain(); } + return $css; }