Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
176 / 176
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
Hooks
100.00% covered (success)
100.00%
176 / 176
100.00% covered (success)
100.00%
8 / 8
48
100.00% covered (success)
100.00%
1 / 1
 getConfig
n/a
0 / 0
n/a
0 / 0
1
 getMatcherFactory
n/a
0 / 0
n/a
0 / 0
2
 validateExtraWrapper
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getSanitizer
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
1 / 1
10
 onRegistration
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 onParserFirstCallInit
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 onContentHandlerDefaultModelFor
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 onParserClearState
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handleTag
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
1 / 1
21
 formatTagError
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\TemplateStyles;
4
5/**
6 * @file
7 * @license GPL-2.0-or-later
8 */
9
10use ContentHandler;
11use ExtensionRegistry;
12use InvalidArgumentException;
13use MapCacheLRU;
14use MediaWiki\Config\Config;
15use MediaWiki\Extension\TemplateStyles\Hooks\HookRunner;
16use MediaWiki\Hook\ParserClearStateHook;
17use MediaWiki\Hook\ParserFirstCallInitHook;
18use MediaWiki\Html\Html;
19use MediaWiki\MediaWikiServices;
20use MediaWiki\Parser\Parser;
21use MediaWiki\Revision\Hook\ContentHandlerDefaultModelForHook;
22use MediaWiki\Revision\SlotRecord;
23use MediaWiki\Title\Title;
24use PPFrame;
25use Wikimedia\CSS\Grammar\CheckedMatcher;
26use Wikimedia\CSS\Grammar\GrammarMatch;
27use Wikimedia\CSS\Grammar\MatcherFactory;
28use Wikimedia\CSS\Objects\ComponentValue;
29use Wikimedia\CSS\Objects\ComponentValueList;
30use Wikimedia\CSS\Objects\Token;
31use Wikimedia\CSS\Parser\Parser as CSSParser;
32use Wikimedia\CSS\Sanitizer\KeyframesAtRuleSanitizer;
33use Wikimedia\CSS\Sanitizer\MediaAtRuleSanitizer;
34use Wikimedia\CSS\Sanitizer\NamespaceAtRuleSanitizer;
35use Wikimedia\CSS\Sanitizer\PageAtRuleSanitizer;
36use Wikimedia\CSS\Sanitizer\Sanitizer;
37use Wikimedia\CSS\Sanitizer\StylePropertySanitizer;
38use Wikimedia\CSS\Sanitizer\StyleRuleSanitizer;
39use Wikimedia\CSS\Sanitizer\StylesheetSanitizer;
40use Wikimedia\CSS\Sanitizer\SupportsAtRuleSanitizer;
41
42/**
43 * TemplateStyles extension hooks
44 */
45class Hooks implements
46    ParserFirstCallInitHook,
47    ParserClearStateHook,
48    ContentHandlerDefaultModelForHook
49{
50
51    /** @var MatcherFactory|null */
52    private static $matcherFactory = null;
53
54    /** @var Sanitizer[] */
55    private static $sanitizers = [];
56
57    /** @var (false|Token[])[] */
58    private static $wrappers = [];
59
60    /**
61     * @return Config
62     * @codeCoverageIgnore
63     */
64    public static function getConfig() {
65        return MediaWikiServices::getInstance()->getConfigFactory()
66            ->makeConfig( 'templatestyles' );
67    }
68
69    /**
70     * @return MatcherFactory
71     * @codeCoverageIgnore
72     */
73    private static function getMatcherFactory() {
74        if ( !self::$matcherFactory ) {
75            self::$matcherFactory = new TemplateStylesMatcherFactory(
76                self::getConfig()->get( 'TemplateStylesAllowedUrls' )
77            );
78        }
79        return self::$matcherFactory;
80    }
81
82    /**
83     * Validate an extra wrapper-selector
84     * @param string $wrapper
85     * @return ComponentValue[]|false Representation of the selector, or false on failure
86     */
87    private static function validateExtraWrapper( $wrapper ) {
88        if ( !isset( self::$wrappers[$wrapper] ) ) {
89            $cssParser = CSSParser::newFromString( $wrapper );
90            $components = $cssParser->parseComponentValueList();
91            if ( $cssParser->getParseErrors() ) {
92                $match = false;
93            } else {
94                $match = self::getMatcherFactory()->cssSimpleSelectorSeq()
95                    ->matchAgainst( $components, [ 'mark-significance' => true ] );
96            }
97            self::$wrappers[$wrapper] = $match ? $components->toComponentValueArray() : false;
98        }
99        return self::$wrappers[$wrapper];
100    }
101
102    /**
103     * @param string $class Class to limit selectors to
104     * @param string|null $extraWrapper Extra selector to limit selectors to
105     * @return Sanitizer
106     */
107    public static function getSanitizer( $class, $extraWrapper = null ) {
108        $key = $extraWrapper !== null ? "$class $extraWrapper" : $class;
109        if ( !isset( self::$sanitizers[$key] ) ) {
110            $config = self::getConfig();
111            $matcherFactory = self::getMatcherFactory();
112
113            $propertySanitizer = new StylePropertySanitizer( $matcherFactory );
114            $propertySanitizer->setKnownProperties( array_diff_key(
115                $propertySanitizer->getKnownProperties(),
116                array_flip( $config->get( 'TemplateStylesDisallowedProperties' ) )
117            ) );
118            $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
119            $hookRunner->onTemplateStylesPropertySanitizer( $propertySanitizer, $matcherFactory );
120
121            $htmlOrBodySimpleSelectorSeqMatcher = new CheckedMatcher(
122                $matcherFactory->cssSimpleSelectorSeq(),
123                static function ( ComponentValueList $values, GrammarMatch $match, array $options ) {
124                    foreach ( $match->getCapturedMatches() as $m ) {
125                        if ( $m->getName() !== 'element' ) {
126                            continue;
127                        }
128                        $str = (string)$m;
129                        return $str === 'html' || $str === 'body';
130                    }
131                    return false;
132                }
133            );
134
135            $prependSelectors = [
136                new Token( Token::T_DELIM, '.' ),
137                new Token( Token::T_IDENT, $class ),
138            ];
139            if ( $extraWrapper !== null ) {
140                $extraComponentValues = self::validateExtraWrapper( $extraWrapper );
141                if ( !$extraComponentValues ) {
142                    throw new InvalidArgumentException( "Invalid value for \$extraWrapper: $extraWrapper" );
143                }
144                $prependSelectors = array_merge(
145                    $prependSelectors,
146                    [ new Token( Token::T_WHITESPACE, [ 'significant' => true ] ) ],
147                    $extraComponentValues
148                );
149            }
150
151            $disallowedAtRules = $config->get( 'TemplateStylesDisallowedAtRules' );
152
153            $ruleSanitizers = [
154                'styles' => new StyleRuleSanitizer(
155                    $matcherFactory->cssSelectorList(),
156                    $propertySanitizer,
157                    [
158                        'prependSelectors' => $prependSelectors,
159                        'hoistableComponentMatcher' => $htmlOrBodySimpleSelectorSeqMatcher,
160                    ]
161                ),
162                '@font-face' => new TemplateStylesFontFaceAtRuleSanitizer( $matcherFactory ),
163                '@keyframes' => new KeyframesAtRuleSanitizer( $matcherFactory, $propertySanitizer ),
164                '@page' => new PageAtRuleSanitizer( $matcherFactory, $propertySanitizer ),
165                '@media' => new MediaAtRuleSanitizer( $matcherFactory->cssMediaQueryList() ),
166                '@supports' => new SupportsAtRuleSanitizer( $matcherFactory, [
167                    'declarationSanitizer' => $propertySanitizer,
168                ] ),
169            ];
170            $ruleSanitizers = array_diff_key( $ruleSanitizers, array_flip( $disallowedAtRules ) );
171            if ( isset( $ruleSanitizers['@media'] ) ) {
172                // In case @media was disallowed
173                $ruleSanitizers['@media']->setRuleSanitizers( $ruleSanitizers );
174            }
175            if ( isset( $ruleSanitizers['@supports'] ) ) {
176                // In case @supports was disallowed
177                $ruleSanitizers['@supports']->setRuleSanitizers( $ruleSanitizers );
178            }
179
180            $allRuleSanitizers = $ruleSanitizers + [
181                // Omit @import, it's not secure. Maybe someday we'll make an "@-mw-import" or something.
182                '@namespace' => new NamespaceAtRuleSanitizer( $matcherFactory ),
183            ];
184            $allRuleSanitizers = array_diff_key( $allRuleSanitizers, $disallowedAtRules );
185            $sanitizer = new StylesheetSanitizer( $allRuleSanitizers );
186            $hookRunner->onTemplateStylesStylesheetSanitizer(
187                $sanitizer, $propertySanitizer, $matcherFactory
188            );
189            self::$sanitizers[$key] = $sanitizer;
190        }
191        return self::$sanitizers[$key];
192    }
193
194    /**
195     * Update $wgTextModelsToParse
196     */
197    public static function onRegistration() {
198        // This gets called before ConfigFactory is set up, so I guess we need
199        // to use globals.
200        global $wgTextModelsToParse, $wgTemplateStylesAutoParseContent;
201
202        if ( in_array( CONTENT_MODEL_CSS, $wgTextModelsToParse, true ) &&
203            $wgTemplateStylesAutoParseContent
204        ) {
205            $wgTextModelsToParse[] = 'sanitized-css';
206        }
207    }
208
209    /**
210     * Add `<templatestyles>` to the parser.
211     * @param Parser $parser Parser object being cleared
212     */
213    public function onParserFirstCallInit( $parser ) {
214        $parser->setHook( 'templatestyles', [ __CLASS__, 'handleTag' ] );
215        // 100 is arbitrary
216        $parser->extTemplateStylesCache = new MapCacheLRU( 100 );
217    }
218
219    /**
220     * Set the default content model to 'sanitized-css' when appropriate.
221     * @param Title $title the Title in question
222     * @param string &$model The model name
223     * @return bool
224     */
225    public function onContentHandlerDefaultModelFor( $title, &$model ) {
226        // Allow overwriting attributes with config settings.
227        // Attributes can not use namespaces as keys, as processing them does not preserve
228        // integer keys.
229        $enabledNamespaces = self::getConfig()->get( 'TemplateStylesNamespaces' ) +
230            array_fill_keys(
231                ExtensionRegistry::getInstance()->getAttribute( 'TemplateStylesNamespaces' ),
232                true
233            );
234
235        if ( !empty( $enabledNamespaces[$title->getNamespace()] ) &&
236            $title->isSubpage() && substr( $title->getText(), -4 ) === '.css'
237        ) {
238            $model = 'sanitized-css';
239            return false;
240        }
241        return true;
242    }
243
244    /**
245     * Clear our cache when the parser is reset
246     * @param Parser $parser
247     */
248    public function onParserClearState( $parser ) {
249        $parser->extTemplateStylesCache->clear();
250    }
251
252    /**
253     * Parser hook for `<templatestyles>`
254     * @param string $text Contents of the tag (ignored).
255     * @param string[] $params Tag attributes
256     * @param Parser $parser
257     * @param PPFrame $frame
258     * @return string HTML
259     * @suppress SecurityCheck-XSS
260     */
261    public static function handleTag( $text, $params, $parser, $frame ) {
262        $config = self::getConfig();
263        if ( $config->get( 'TemplateStylesDisable' ) ) {
264            return '';
265        }
266
267        if ( !isset( $params['src'] ) || trim( $params['src'] ) === '' ) {
268            return self::formatTagError( $parser, [ 'templatestyles-missing-src' ] );
269        }
270
271        $extraWrapper = null;
272        if ( isset( $params['wrapper'] ) && trim( $params['wrapper'] ) !== '' ) {
273            $extraWrapper = trim( $params['wrapper'] );
274            if ( !self::validateExtraWrapper( $extraWrapper ) ) {
275                return self::formatTagError( $parser, [ 'templatestyles-invalid-wrapper' ] );
276            }
277        }
278
279        // Default to the Template namespace because that's the most likely
280        // situation. We can't allow for subpage syntax like src="/styles.css"
281        // or the like, though, because stuff like substing and Parsoid would
282        // wind up wanting to make that relative to the wrong page.
283        $title = Title::newFromText( $params['src'], $config->get( 'TemplateStylesDefaultNamespace' ) );
284        if ( !$title || $title->isExternal() ) {
285            return self::formatTagError( $parser, [ 'templatestyles-invalid-src' ] );
286        }
287
288        $revRecord = $parser->fetchCurrentRevisionRecordOfTitle( $title );
289
290        // It's not really a "template", but it has the same implications
291        // for needing reparse when the stylesheet is edited.
292        $parser->getOutput()->addTemplate(
293            $title,
294            $title->getArticleId(),
295            $revRecord ? $revRecord->getId() : null
296        );
297
298        $content = $revRecord ? $revRecord->getContent( SlotRecord::MAIN ) : null;
299        if ( !$content ) {
300            $titleText = $title->getPrefixedText();
301            return self::formatTagError( $parser, [
302                'templatestyles-bad-src-missing',
303                $titleText,
304                wfEscapeWikiText( $titleText )
305            ] );
306        }
307        if ( !$content instanceof TemplateStylesContent ) {
308            $titleText = $title->getPrefixedText();
309            return self::formatTagError( $parser, [
310                'templatestyles-bad-src',
311                $titleText,
312                wfEscapeWikiText( $titleText ),
313                ContentHandler::getLocalizedName( $content->getModel() )
314            ] );
315        }
316
317        // If the revision actually has an ID, cache based on that.
318        // Otherwise, cache by hash.
319        if ( $revRecord->getId() ) {
320            $cacheKey = 'r' . $revRecord->getId();
321        } else {
322            $cacheKey = sha1( $content->getText() );
323        }
324
325        // Include any non-default wrapper class in the cache key too
326        $wrapClass = $parser->getOptions()->getWrapOutputClass();
327        if ( $wrapClass === false ) {
328            // deprecated
329            $wrapClass = 'mw-parser-output';
330        }
331        if ( $wrapClass !== 'mw-parser-output' || $extraWrapper !== null ) {
332            $cacheKey .= '/' . $wrapClass;
333            if ( $extraWrapper !== null ) {
334                $cacheKey .= '/' . $extraWrapper;
335            }
336        }
337
338        // Already cached?
339        if ( $parser->extTemplateStylesCache->has( $cacheKey ) ) {
340            return $parser->extTemplateStylesCache->get( $cacheKey );
341        }
342
343        $targetDir = $parser->getTargetLanguage()->getDir();
344        $contentDir = $parser->getContentLanguage()->getDir();
345
346        $contentHandlerFactory = MediaWikiServices::getInstance()->getContentHandlerFactory();
347        $contentHandler = $contentHandlerFactory->getContentHandler( $content->getModel() );
348        '@phan-var TemplateStylesContentHandler $contentHandler';
349        $status = $contentHandler->sanitize(
350            $content,
351            [
352                'flip' => $targetDir !== $contentDir,
353                'minify' => true,
354                'class' => $wrapClass,
355                'extraWrapper' => $extraWrapper,
356            ]
357        );
358        $style = $status->isOk() ? $status->getValue() : '/* Fatal error, no CSS will be output */';
359
360        // Prepend errors. This should normally never happen, but might if an
361        // update or configuration change causes something that was formerly
362        // valid to become invalid or something like that.
363        if ( !$status->isGood() ) {
364            $comment = wfMessage(
365                'templatestyles-errorcomment',
366                $title->getPrefixedText(),
367                $revRecord->getId(),
368                $status->getWikiText( false, 'rawmessage' )
369            )->text();
370            $comment = trim( strtr( $comment, [
371                // Use some lookalike unicode characters to avoid things that might
372                // otherwise confuse browsers.
373                '*' => '•', '-' => '‐', '<' => '⧼', '>' => '⧽',
374            ] ) );
375            $style = "/*\n$comment\n*/\n$style";
376        }
377
378        // Hide the CSS from Parser::doBlockLevels
379        $marker = Parser::MARKER_PREFIX . '-templatestyles-' .
380            sprintf( '%08X', $parser->mMarkerIndex++ ) . Parser::MARKER_SUFFIX;
381        $parser->getStripState()->addNoWiki( $marker, $style );
382
383        // Return the inline <style>, which the Parser will wrap in a 'general'
384        // strip marker.
385        $ret = Html::inlineStyle( $marker, 'all', [
386            'data-mw-deduplicate' => "TemplateStyles:$cacheKey",
387        ] );
388        $parser->extTemplateStylesCache->set( $cacheKey, $ret );
389        return $ret;
390    }
391
392    /**
393     * Format an error in the `<templatestyles>` tag
394     * @param Parser $parser
395     * @param array $msg Arguments to wfMessage()
396     * @phan-param non-empty-array $msg
397     * @return string HTML
398     */
399    private static function formatTagError( Parser $parser, array $msg ) {
400        $parser->addTrackingCategory( 'templatestyles-page-error-category' );
401        return '<strong class="error">' .
402            wfMessage( ...$msg )->inContentLanguage()->parse() .
403            '</strong>';
404    }
405
406}