<?php
/*
 * Plugin Name: A/I - Sanitize Custom CSS
 * Description: Sanitize Custom CSS by preventing url() references
 * Version: 0.0.1
 * Author: Autistici/Inventati
 * Author URI: https://autistici.org
 */

/*
 * 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', $this->sub_value, $matches)) {
                $this->sub_value = ai_sanitize_urls_in_css_properties($matches['parenthetical_content'], $this->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(
            'force'        => false,
        )
    );
	
    if ($args['force'] || !current_user_can('unfiltered_html')) {
        $warnings = array();

        ai_safecss_class();
        $csstidy = new csstidy();
        $csstidy->optimise = new safecss( $csstidy );

        $csstidy->set_cfg( 'remove_bslash', false );
        $csstidy->set_cfg( 'compress_colors', false );
        $csstidy->set_cfg( 'compress_font-weight', false );
        $csstidy->set_cfg( 'optimise_shorthands', 0 );
        $csstidy->set_cfg( 'remove_last_;', false );
        $csstidy->set_cfg( 'case_properties', false );
        $csstidy->set_cfg( 'discard_invalid_properties', true );
        $csstidy->set_cfg( 'css_level', 'CSS3.0' );
        $csstidy->set_cfg( 'preserve_css', true );
        $csstidy->set_cfg( 'template', __DIR__ . '/csstidy/wordpress-standard.tpl' );

        // Test for some preg_replace stuff.
        $prev = $css;
        $css  = preg_replace( '/\\\\([0-9a-fA-F]{4})/', '\\\\\\\\$1', $css );
        // prevent content: '\3434' from turning into '\\3434'.
        $css = str_replace( array( '\'\\\\', '"\\\\' ), array( '\'\\', '"\\' ), $css );
        if ( $css !== $prev ) {
            $warnings[] = 'preg_replace found stuff';
        }

        // Some people put weird stuff in their CSS, KSES tends to be greedy.
        $css = str_replace( '<=', '&lt;=', $css );

        // Test for some kses stuff.
        $prev = $css;
        // Why KSES instead of strip_tags?  Who knows?
        $css = wp_kses_split( $css, array(), array() );
        $css = str_replace( '&gt;', '>', $css ); // kses replaces lone '>' with &gt;
        // Why both KSES and strip_tags?  Because we just added some '>'.
        $css = strip_tags( $css );

        if ( $css !== $prev ) {
            $warnings[] = 'kses found stuff';
        }

        $csstidy->parse( $css );
        $css = $csstidy->print->plain();
    }

    return $css;
}

add_filter('update_custom_css_data', function($args) {
    $css = $args['css'];
    $args['css'] = ai_sanitize_css($css);
    return $args;
}, 10, 2);