Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
132 / 132
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
Hooks
100.00% covered (success)
100.00%
132 / 132
100.00% covered (success)
100.00%
7 / 7
37
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
1
 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%
1 / 1
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
 handleTag
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
12
 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 InvalidArgumentException;
11use MediaWiki\Config\Config;
12use MediaWiki\Extension\TemplateStyles\Hooks\HookRunner;
13use MediaWiki\Html\Html;
14use MediaWiki\MediaWikiServices;
15use MediaWiki\Parser\Hook\ParserFirstCallInitHook;
16use MediaWiki\Parser\Parser;
17use MediaWiki\Parser\PPFrame;
18use MediaWiki\Registration\ExtensionRegistry;
19use MediaWiki\Revision\Hook\ContentHandlerDefaultModelForHook;
20use MediaWiki\Title\Title;
21use Wikimedia\CSS\Grammar\CheckedMatcher;
22use Wikimedia\CSS\Grammar\GrammarMatch;
23use Wikimedia\CSS\Grammar\MatcherFactory;
24use Wikimedia\CSS\Objects\ComponentValue;
25use Wikimedia\CSS\Objects\ComponentValueList;
26use Wikimedia\CSS\Objects\Token;
27use Wikimedia\CSS\Parser\Parser as CSSParser;
28use Wikimedia\CSS\Sanitizer\KeyframesAtRuleSanitizer;
29use Wikimedia\CSS\Sanitizer\MediaAtRuleSanitizer;
30use Wikimedia\CSS\Sanitizer\NamespaceAtRuleSanitizer;
31use Wikimedia\CSS\Sanitizer\PageAtRuleSanitizer;
32use Wikimedia\CSS\Sanitizer\Sanitizer;
33use Wikimedia\CSS\Sanitizer\StylePropertySanitizer;
34use Wikimedia\CSS\Sanitizer\StyleRuleSanitizer;
35use Wikimedia\CSS\Sanitizer\StylesheetSanitizer;
36use Wikimedia\CSS\Sanitizer\SupportsAtRuleSanitizer;
37
38/**
39 * TemplateStyles extension hooks
40 */
41class Hooks implements
42    ParserFirstCallInitHook,
43    ContentHandlerDefaultModelForHook
44{
45
46    private static ?MatcherFactory $matcherFactory = null;
47
48    /** @var array<string,Sanitizer> */
49    private static array $sanitizers = [];
50
51    /** @var array<string,false|Token[]> */
52    private static array $wrappers = [];
53
54    /**
55     * @codeCoverageIgnore
56     */
57    public static function getConfig(): Config {
58        return MediaWikiServices::getInstance()->getConfigFactory()
59            ->makeConfig( 'templatestyles' );
60    }
61
62    /**
63     * @codeCoverageIgnore
64     */
65    public static function getMatcherFactory(): TemplateStylesMatcherFactory {
66        self::$matcherFactory ??= new TemplateStylesMatcherFactory(
67            self::getConfig()->get( 'TemplateStylesAllowedUrls' )
68        );
69        return self::$matcherFactory;
70    }
71
72    /**
73     * Validate an extra wrapper-selector
74     * @param string $wrapper
75     * @return ComponentValue[]|false Representation of the selector, or false on failure
76     */
77    private static function validateExtraWrapper( $wrapper ) {
78        if ( !isset( self::$wrappers[$wrapper] ) ) {
79            $cssParser = CSSParser::newFromString( $wrapper );
80            $components = $cssParser->parseComponentValueList();
81            if ( $cssParser->getParseErrors() ) {
82                $match = false;
83            } else {
84                $match = self::getMatcherFactory()->cssSimpleSelectorSeq()
85                    ->matchAgainst( $components, [ 'mark-significance' => true ] );
86            }
87            self::$wrappers[$wrapper] = $match ? $components->toComponentValueArray() : false;
88        }
89        return self::$wrappers[$wrapper];
90    }
91
92    /**
93     * @param string $class Class to limit selectors to
94     * @param string|null $extraWrapper Extra selector to limit selectors to
95     * @return Sanitizer
96     */
97    public static function getSanitizer( $class, $extraWrapper = null ) {
98        $key = $extraWrapper !== null ? "$class $extraWrapper" : $class;
99        if ( !isset( self::$sanitizers[$key] ) ) {
100            $config = self::getConfig();
101            $matcherFactory = self::getMatcherFactory();
102
103            $propertySanitizer = new StylePropertySanitizer( $matcherFactory );
104            $propertySanitizer->setKnownProperties( array_diff_key(
105                $propertySanitizer->getKnownProperties(),
106                array_flip( $config->get( 'TemplateStylesDisallowedProperties' ) )
107            ) );
108            $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
109            $hookRunner->onTemplateStylesPropertySanitizer( $propertySanitizer, $matcherFactory );
110
111            $htmlOrBodySimpleSelectorSeqMatcher = new CheckedMatcher(
112                $matcherFactory->cssSimpleSelectorSeq(),
113                static function ( ComponentValueList $values, GrammarMatch $match, array $options ) {
114                    foreach ( $match->getCapturedMatches() as $m ) {
115                        if ( $m->getName() !== 'element' ) {
116                            continue;
117                        }
118                        $str = (string)$m;
119                        return $str === 'html' || $str === 'body';
120                    }
121                    return false;
122                }
123            );
124
125            $prependSelectors = [
126                new Token( Token::T_DELIM, '.' ),
127                new Token( Token::T_IDENT, $class ),
128            ];
129            if ( $extraWrapper !== null ) {
130                $extraComponentValues = self::validateExtraWrapper( $extraWrapper );
131                if ( !$extraComponentValues ) {
132                    throw new InvalidArgumentException( "Invalid value for \$extraWrapper: $extraWrapper" );
133                }
134                $prependSelectors = array_merge(
135                    $prependSelectors,
136                    [ new Token( Token::T_WHITESPACE, [ 'significant' => true ] ) ],
137                    $extraComponentValues
138                );
139            }
140
141            $disallowedAtRules = $config->get( 'TemplateStylesDisallowedAtRules' );
142
143            $ruleSanitizers = [
144                'styles' => new StyleRuleSanitizer(
145                    $matcherFactory->cssSelectorList(),
146                    $propertySanitizer,
147                    [
148                        'prependSelectors' => $prependSelectors,
149                        'hoistableComponentMatcher' => $htmlOrBodySimpleSelectorSeqMatcher,
150                    ]
151                ),
152                '@font-face' => new TemplateStylesFontFaceAtRuleSanitizer( $matcherFactory ),
153                '@keyframes' => new KeyframesAtRuleSanitizer( $matcherFactory, $propertySanitizer ),
154                '@page' => new PageAtRuleSanitizer( $matcherFactory, $propertySanitizer ),
155                '@media' => new MediaAtRuleSanitizer( $matcherFactory->cssMediaQueryList() ),
156                '@supports' => new SupportsAtRuleSanitizer( $matcherFactory, [
157                    'declarationSanitizer' => $propertySanitizer,
158                ] ),
159            ];
160            $ruleSanitizers = array_diff_key( $ruleSanitizers, array_flip( $disallowedAtRules ) );
161            if ( isset( $ruleSanitizers['@media'] ) ) {
162                // In case @media was disallowed
163                $ruleSanitizers['@media']->setRuleSanitizers( $ruleSanitizers );
164            }
165            if ( isset( $ruleSanitizers['@supports'] ) ) {
166                // In case @supports was disallowed
167                $ruleSanitizers['@supports']->setRuleSanitizers( $ruleSanitizers );
168            }
169
170            $allRuleSanitizers = $ruleSanitizers + [
171                // Omit @import, it's not secure. Maybe someday we'll make an "@-mw-import" or something.
172                '@namespace' => new NamespaceAtRuleSanitizer( $matcherFactory ),
173            ];
174            $allRuleSanitizers = array_diff_key( $allRuleSanitizers, $disallowedAtRules );
175            $sanitizer = new StylesheetSanitizer( $allRuleSanitizers );
176            $hookRunner->onTemplateStylesStylesheetSanitizer(
177                $sanitizer, $propertySanitizer, $matcherFactory
178            );
179            self::$sanitizers[$key] = $sanitizer;
180        }
181        return self::$sanitizers[$key];
182    }
183
184    /**
185     * Update $wgTextModelsToParse
186     */
187    public static function onRegistration() {
188        // This gets called before ConfigFactory is set up, so I guess we need
189        // to use globals.
190        global $wgTextModelsToParse, $wgTemplateStylesAutoParseContent;
191
192        if ( in_array( CONTENT_MODEL_CSS, $wgTextModelsToParse, true ) &&
193            $wgTemplateStylesAutoParseContent
194        ) {
195            $wgTextModelsToParse[] = 'sanitized-css';
196        }
197    }
198
199    /**
200     * Add `<templatestyles>` to the parser.
201     * @param Parser $parser Parser object being cleared
202     */
203    public function onParserFirstCallInit( $parser ) {
204        $parser->setHook( 'templatestyles', [ __CLASS__, 'handleTag' ] );
205    }
206
207    /**
208     * Set the default content model to 'sanitized-css' when appropriate.
209     * @param Title $title the Title in question
210     * @param string &$model The model name
211     * @return bool
212     */
213    public function onContentHandlerDefaultModelFor( $title, &$model ) {
214        // Allow overwriting attributes with config settings.
215        // Attributes can not use namespaces as keys, as processing them does not preserve
216        // integer keys.
217        $enabledNamespaces = self::getConfig()->get( 'TemplateStylesNamespaces' ) +
218            array_fill_keys(
219                ExtensionRegistry::getInstance()->getAttribute( 'TemplateStylesNamespaces' ),
220                true
221            );
222
223        if ( !empty( $enabledNamespaces[$title->getNamespace()] ) &&
224            $title->isSubpage() && str_ends_with( $title->getText(), '.css' )
225        ) {
226            $model = 'sanitized-css';
227            return false;
228        }
229        return true;
230    }
231
232    /**
233     * Parser hook for `<templatestyles>`
234     * @param string $text Contents of the tag (ignored).
235     * @param string[] $params Tag attributes
236     * @param Parser $parser
237     * @param PPFrame $frame
238     * @return string HTML
239     * @suppress SecurityCheck-XSS
240     */
241    public static function handleTag( $text, $params, $parser, $frame ) {
242        $config = self::getConfig();
243        if ( $config->get( 'TemplateStylesDisable' ) ) {
244            return '';
245        }
246
247        if ( !isset( $params['src'] ) || trim( $params['src'] ) === '' ) {
248            return self::formatTagError( $parser, [ 'templatestyles-missing-src' ] );
249        }
250
251        $extraWrapper = null;
252        if ( isset( $params['wrapper'] ) && trim( $params['wrapper'] ) !== '' ) {
253            $extraWrapper = trim( $params['wrapper'] );
254            if ( !self::validateExtraWrapper( $extraWrapper ) ) {
255                return self::formatTagError( $parser, [ 'templatestyles-invalid-wrapper' ] );
256            }
257        }
258
259        // Default to the Template namespace because that's the most likely
260        // situation. We can't allow for subpage syntax like src="/styles.css"
261        // or the like, though, because stuff like substing and Parsoid would
262        // wind up wanting to make that relative to the wrong page.
263        $title = Title::newFromText( $params['src'], $config->get( 'TemplateStylesDefaultNamespace' ) );
264        if ( !$title || $title->isExternal() ) {
265            return self::formatTagError( $parser, [ 'templatestyles-invalid-src' ] );
266        }
267
268        $revRecord = $parser->fetchCurrentRevisionRecordOfTitle( $title );
269
270        // It's not really a "template", but it has the same implications
271        // for needing reparse when the stylesheet is edited.
272        $parser->getOutput()->addTemplate(
273            $title,
274            $title->getArticleId(),
275            $revRecord ? $revRecord->getId() : 0
276        );
277
278        if ( !$revRecord ) {
279            $titleText = $title->getPrefixedText();
280            return self::formatTagError( $parser, [
281                'templatestyles-bad-src-missing',
282                $titleText,
283                wfEscapeWikiText( $titleText )
284            ] );
285        }
286
287        $contentProvider = MediaWikiServices::getInstance()->get( 'TemplateStyles.ContentProvider' );
288
289        $status = $contentProvider->getStyle( $revRecord, $parser, $extraWrapper );
290        if ( !$status->isGood() ) {
291            return self::formatTagError( $parser, [ $status->getMessage() ] );
292        }
293        [ $cacheKey, $style ] = $status->getValue();
294
295        // Hide the CSS from Parser::doBlockLevels
296        $marker = Parser::MARKER_PREFIX . '-templatestyles-' .
297            sprintf( '%08X', $parser->mMarkerIndex++ ) . Parser::MARKER_SUFFIX;
298        $parser->getStripState()->addNoWiki( $marker, $style );
299
300        // Return the inline <style>, which the Parser will wrap in a 'general'
301        // strip marker.
302        $ret = Html::inlineStyle( $marker, 'all', [
303            'data-mw-deduplicate' => "TemplateStyles:$cacheKey",
304        ] );
305        return $ret;
306    }
307
308    /**
309     * Format an error in the `<templatestyles>` tag
310     * @param Parser $parser
311     * @param array $msg Arguments to wfMessage()
312     * @phan-param non-empty-array $msg
313     * @return string HTML
314     */
315    private static function formatTagError( Parser $parser, array $msg ) {
316        $parser->addTrackingCategory( 'templatestyles-page-error-category' );
317        return '<strong class="error">' .
318            wfMessage( ...$msg )->inContentLanguage()->parse() .
319            '</strong>';
320    }
321
322}