Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.19% covered (warning)
67.19%
43 / 64
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
TemplateStylesContentHandler
67.19% covered (warning)
67.19%
43 / 64
83.33% covered (warning)
83.33%
5 / 6
29.45
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateSave
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContentClass
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fillParserOutput
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 processErrors
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 sanitize
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2
3namespace MediaWiki\Extension\TemplateStyles;
4
5/**
6 * @file
7 * @license GPL-2.0-or-later
8 */
9
10use CodeContentHandler;
11use Content;
12use CSSJanus;
13use MediaWiki\Content\Renderer\ContentParseParams;
14use MediaWiki\Content\ValidationParams;
15use MediaWiki\MediaWikiServices;
16use MediaWiki\Parser\ParserOutput;
17use MediaWiki\Status\Status;
18use Message;
19use StatusValue;
20use Wikimedia\CSS\Parser\Parser as CSSParser;
21use Wikimedia\CSS\Util as CSSUtil;
22
23/**
24 * Content handler for sanitized CSS
25 */
26class TemplateStylesContentHandler extends CodeContentHandler {
27
28    /**
29     * @param string $modelId
30     */
31    public function __construct( $modelId = 'sanitized-css' ) {
32        parent::__construct( $modelId, [ CONTENT_FORMAT_CSS ] );
33    }
34
35    /**
36     * @inheritDoc
37     */
38    public function validateSave(
39        Content $content,
40        ValidationParams $validationParams
41    ) {
42        '@phan-var TemplateStylesContent $content';
43        return $this->sanitize( $content, [ 'novalue' => true, 'severity' => 'fatal' ] );
44    }
45
46    /**
47     * @return string
48     */
49    protected function getContentClass() {
50        return TemplateStylesContent::class;
51    }
52
53    /**
54     * @inheritDoc
55     */
56    protected function fillParserOutput(
57        Content $content,
58        ContentParseParams $cpoParams,
59        ParserOutput &$output
60    ) {
61        '@phan-var TemplateStylesContent $content';
62        $services = MediaWikiServices::getInstance();
63        $page = $cpoParams->getPage();
64        $parserOptions = $cpoParams->getParserOptions();
65
66        // Inject our warnings into the resulting ParserOutput
67        parent::fillParserOutput( $content, $cpoParams, $output );
68
69        if ( $cpoParams->getGenerateHtml() ) {
70            $html = "";
71            $html .= "<pre class=\"mw-code mw-css\" dir=\"ltr\">\n";
72            $html .= htmlspecialchars( $content->getNativeData(), ENT_NOQUOTES );
73            $html .= "\n</pre>\n";
74        } else {
75            $html = '';
76        }
77
78        $output->clearWrapperDivClass();
79        $output->setRawText( $html );
80
81        $status = $this->sanitize( $content, [ 'novalue' => true, 'class' => $parserOptions->getWrapOutputClass() ] );
82        if ( $status->getErrors() ) {
83            foreach ( $status->getErrors() as $error ) {
84                $output->addWarningMsg( $error['message'], $error['params'] );
85            }
86            $services->getTrackingCategories()->addTrackingCategory(
87                $output,
88                'templatestyles-stylesheet-error-category',
89                $page
90            );
91        }
92    }
93
94    /**
95     * Handle errors from the CSS parser and/or sanitizer
96     * @param StatusValue $status Object to add errors to
97     * @param array[] $errors Error array
98     * @param string $severity Whether to consider errors as 'warning' or 'fatal'
99     */
100    protected static function processErrors( StatusValue $status, array $errors, $severity ) {
101        if ( $severity !== 'warning' && $severity !== 'fatal' ) {
102            // @codeCoverageIgnoreStart
103            throw new \InvalidArgumentException( 'Invalid $severity' );
104            // @codeCoverageIgnoreEnd
105        }
106        foreach ( $errors as $error ) {
107            $error[0] = 'templatestyles-error-' . $error[0];
108            call_user_func_array( [ $status, $severity ], $error );
109        }
110    }
111
112    /**
113     * Sanitize the content
114     * @param TemplateStylesContent $content
115     * @param array $options Options are:
116     *  - class: (string) Class to prefix selectors with
117     *  - extraWrapper: (string) Extra simple selector to prefix selectors with
118     *  - flip: (bool) Have CSSJanus flip the stylesheet.
119     *  - minify: (bool) Whether to minify. Default true.
120     *  - novalue: (bool) Don't bother returning the actual stylesheet, just
121     *    fill the Status with warnings.
122     *  - severity: (string) Whether to consider errors as 'warning' or 'fatal'
123     * @return Status
124     */
125    public function sanitize( TemplateStylesContent $content, array $options = [] ) {
126        $options += [
127            'class' => false,
128            'extraWrapper' => null,
129            'flip' => false,
130            'minify' => true,
131            'novalue' => false,
132            'severity' => 'warning',
133        ];
134
135        $status = Status::newGood();
136
137        $style = $content->getText();
138        $maxSize = Hooks::getConfig()->get( 'TemplateStylesMaxStylesheetSize' );
139        if ( $maxSize !== null && strlen( $style ) > $maxSize ) {
140            $status->fatal(
141                // Status::getWikiText() chokes on the Message::sizeParam if we
142                // don't wrap it in a Message ourself.
143                wfMessage( 'templatestyles-size-exceeded', $maxSize, Message::sizeParam( $maxSize ) )
144            );
145            return $status;
146        }
147
148        if ( $options['flip'] ) {
149            $style = CSSJanus::transform( $style, true, false );
150        }
151
152        // Parse it, and collect any errors
153        $cssParser = CSSParser::newFromString( $style );
154        $stylesheet = $cssParser->parseStylesheet();
155        self::processErrors( $status, $cssParser->getParseErrors(), $options['severity'] );
156
157        // Sanitize it, and collect any errors
158        $sanitizer = Hooks::getSanitizer(
159            $options['class'] ?: 'mw-parser-output', $options['extraWrapper']
160        );
161        // Just in case
162        $sanitizer->clearSanitizationErrors();
163        $stylesheet = $sanitizer->sanitize( $stylesheet );
164        self::processErrors( $status, $sanitizer->getSanitizationErrors(), $options['severity'] );
165        $sanitizer->clearSanitizationErrors();
166
167        // Stringify it while minifying
168        $value = CSSUtil::stringify( $stylesheet, [ 'minify' => $options['minify'] ] );
169
170        // Sanity check, don't allow "</style" if one somehow sneaks through the sanitizer
171        if ( preg_match( '!</style!i', $value ) ) {
172            $value = '';
173            $status->fatal( 'templatestyles-end-tag-injection' );
174        }
175
176        if ( !$options['novalue'] ) {
177            $status->value = $value;
178
179            // Sanity check, don't allow raw U+007F if one somehow sneaks through the sanitizer
180            $status->value = strtr( $status->value, [ "\x7f" => '�' ] );
181        }
182
183        return $status;
184    }
185}