Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
132 / 132 |
|
100.00% |
7 / 7 |
CRAP | |
100.00% |
1 / 1 |
| Hooks | |
100.00% |
132 / 132 |
|
100.00% |
7 / 7 |
37 | |
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% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| getSanitizer | |
100.00% |
68 / 68 |
|
100.00% |
1 / 1 |
10 | |||
| onRegistration | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
3 | |||
| onParserFirstCallInit | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| onContentHandlerDefaultModelFor | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
| handleTag | |
100.00% |
38 / 38 |
|
100.00% |
1 / 1 |
12 | |||
| formatTagError | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace MediaWiki\Extension\TemplateStyles; |
| 4 | |
| 5 | /** |
| 6 | * @file |
| 7 | * @license GPL-2.0-or-later |
| 8 | */ |
| 9 | |
| 10 | use InvalidArgumentException; |
| 11 | use MediaWiki\Config\Config; |
| 12 | use MediaWiki\Extension\TemplateStyles\Hooks\HookRunner; |
| 13 | use MediaWiki\Html\Html; |
| 14 | use MediaWiki\MediaWikiServices; |
| 15 | use MediaWiki\Parser\Hook\ParserFirstCallInitHook; |
| 16 | use MediaWiki\Parser\Parser; |
| 17 | use MediaWiki\Parser\PPFrame; |
| 18 | use MediaWiki\Registration\ExtensionRegistry; |
| 19 | use MediaWiki\Revision\Hook\ContentHandlerDefaultModelForHook; |
| 20 | use MediaWiki\Title\Title; |
| 21 | use Wikimedia\CSS\Grammar\CheckedMatcher; |
| 22 | use Wikimedia\CSS\Grammar\GrammarMatch; |
| 23 | use Wikimedia\CSS\Grammar\MatcherFactory; |
| 24 | use Wikimedia\CSS\Objects\ComponentValue; |
| 25 | use Wikimedia\CSS\Objects\ComponentValueList; |
| 26 | use Wikimedia\CSS\Objects\Token; |
| 27 | use Wikimedia\CSS\Parser\Parser as CSSParser; |
| 28 | use Wikimedia\CSS\Sanitizer\KeyframesAtRuleSanitizer; |
| 29 | use Wikimedia\CSS\Sanitizer\MediaAtRuleSanitizer; |
| 30 | use Wikimedia\CSS\Sanitizer\NamespaceAtRuleSanitizer; |
| 31 | use Wikimedia\CSS\Sanitizer\PageAtRuleSanitizer; |
| 32 | use Wikimedia\CSS\Sanitizer\Sanitizer; |
| 33 | use Wikimedia\CSS\Sanitizer\StylePropertySanitizer; |
| 34 | use Wikimedia\CSS\Sanitizer\StyleRuleSanitizer; |
| 35 | use Wikimedia\CSS\Sanitizer\StylesheetSanitizer; |
| 36 | use Wikimedia\CSS\Sanitizer\SupportsAtRuleSanitizer; |
| 37 | |
| 38 | /** |
| 39 | * TemplateStyles extension hooks |
| 40 | */ |
| 41 | class 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 | } |