custom-css.php 4.99 KB
Newer Older
ale's avatar
ale committed
1
2
3
4
5
6
7
8
9
<?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
 */

10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/*
 * 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;
    }

ale's avatar
ale committed
42
43
    require_once(__DIR__ . '/csstidy/class.csstidy.php');

44
45
46
47
    class safecss extends csstidy_optimise {
        public function subvalue() {
            $this->sub_value = trim($this->sub_value);

ale's avatar
ale committed
48
49
            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);
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80

                // 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()) {
ale's avatar
ale committed
81
82
83
84
85
86
87
88
89
    $args = wp_parse_args(
        $args,
        array(
            'force'        => false,
        )
    );
	
    if ($args['force'] || !current_user_can('unfiltered_html')) {
        $warnings = array();
90
91
92

        ai_safecss_class();
        $csstidy = new csstidy();
ale's avatar
ale committed
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
        $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 '>'.
124
        $css = strip_tags( $css );
ale's avatar
ale committed
125
126
127
128
129
130
131
132

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

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

ale's avatar
ale committed
134
135
136
137
138
139
140
141
    return $css;
}

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