1<?php 2/** 3 * Handle sending Content-Security-Policy headers 4 * 5 * @see https://www.w3.org/TR/CSP2/ 6 * 7 * Copyright © 2015–2018 Brian Wolff 8 * 9 * This program is free software; you can redistribute it and/or modify 10 * it under the terms of the GNU General Public License as published by 11 * the Free Software Foundation; either version 2 of the License, or 12 * (at your option) any later version. 13 * 14 * This program is distributed in the hope that it will be useful, 15 * but WITHOUT ANY WARRANTY; without even the implied warranty of 16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 * GNU General Public License for more details. 18 * 19 * You should have received a copy of the GNU General Public License along 20 * with this program; if not, write to the Free Software Foundation, Inc., 21 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 22 * http://www.gnu.org/copyleft/gpl.html 23 * 24 * @since 1.32 25 * @file 26 */ 27 28use MediaWiki\HookContainer\HookContainer; 29use MediaWiki\HookContainer\HookRunner; 30use MediaWiki\MediaWikiServices; 31 32class ContentSecurityPolicy { 33 public const REPORT_ONLY_MODE = 1; 34 public const FULL_MODE = 2; 35 36 /** @var string The nonce to use for inline scripts (from OutputPage) */ 37 private $nonce; 38 /** @var Config The site configuration object */ 39 private $mwConfig; 40 /** @var WebResponse */ 41 private $response; 42 43 /** @var array */ 44 private $extraDefaultSrc = []; 45 /** @var array */ 46 private $extraScriptSrc = []; 47 /** @var array */ 48 private $extraStyleSrc = []; 49 50 /** @var HookRunner */ 51 private $hookRunner; 52 53 /** 54 * @note As a general rule, you would not construct this class directly 55 * but use the instance from OutputPage::getCSP() 56 * @internal 57 * @param WebResponse $response 58 * @param Config $mwConfig 59 * @param HookContainer $hookContainer 60 * @since 1.35 Method signature changed 61 */ 62 public function __construct( WebResponse $response, Config $mwConfig, 63 HookContainer $hookContainer 64 ) { 65 $this->response = $response; 66 $this->mwConfig = $mwConfig; 67 $this->hookRunner = new HookRunner( $hookContainer ); 68 } 69 70 /** 71 * Send a single CSP header based on a given policy config. 72 * 73 * @note Most callers will probably want ContentSecurityPolicy::sendHeaders() instead. 74 * @internal 75 * @param array $csp ContentSecurityPolicy configuration 76 * @param int $reportOnly self::*_MODE constant 77 */ 78 public function sendCSPHeader( $csp, $reportOnly ) { 79 $policy = $this->makeCSPDirectives( $csp, $reportOnly ); 80 $headerName = $this->getHeaderName( $reportOnly ); 81 if ( $policy ) { 82 $this->response->header( 83 "$headerName: $policy" 84 ); 85 } 86 } 87 88 /** 89 * Send CSP headers based on wiki config 90 * 91 * Main method that callers (OutputPage) are expected to use. 92 * As a general rule, you would never call this in an extension unless 93 * you have disabled OutputPage and are fully controlling the output. 94 * 95 * @since 1.35 96 */ 97 public function sendHeaders() { 98 $cspConfig = $this->mwConfig->get( 'CSPHeader' ); 99 $cspConfigReportOnly = $this->mwConfig->get( 'CSPReportOnlyHeader' ); 100 101 $this->sendCSPHeader( $cspConfig, self::FULL_MODE ); 102 $this->sendCSPHeader( $cspConfigReportOnly, self::REPORT_ONLY_MODE ); 103 104 // This used to insert a <meta> tag here, per advice at 105 // https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/ 106 // The goal was to prevent nonce from working after the page hit onready, 107 // This would help in old browsers that didn't support nonces, and 108 // also assist for varnish-cached pages which repeat nonces. 109 // However, this is incompatible with how resource loader storage works 110 // via mw.domEval() so it was removed. 111 } 112 113 /** 114 * Get the name of the HTTP header to use. 115 * 116 * @param int $reportOnly Either self::REPORT_ONLY_MODE or self::FULL_MODE 117 * @return string Name of http header 118 * @throws UnexpectedValueException 119 */ 120 private function getHeaderName( $reportOnly ) { 121 if ( $reportOnly === self::REPORT_ONLY_MODE ) { 122 return 'Content-Security-Policy-Report-Only'; 123 } 124 125 if ( $reportOnly === self::FULL_MODE ) { 126 return 'Content-Security-Policy'; 127 } 128 throw new UnexpectedValueException( "Mode '$reportOnly' not recognised" ); 129 } 130 131 /** 132 * Determine what CSP policies to set for this page 133 * 134 * @param array|bool $policyConfig Policy configuration 135 * (Either $wgCSPHeader or $wgCSPReportOnlyHeader) 136 * @param int $mode self::REPORT_ONLY_MODE, self::FULL_MODE 137 * @return string Policy directives, or empty string for no policy. 138 */ 139 private function makeCSPDirectives( $policyConfig, $mode ) { 140 if ( $policyConfig === false ) { 141 // CSP is disabled 142 return ''; 143 } 144 if ( $policyConfig === true ) { 145 $policyConfig = []; 146 } 147 148 $mwConfig = $this->mwConfig; 149 150 if ( 151 !self::isNonceRequired( $mwConfig ) && 152 self::isNonceRequiredArray( [ $policyConfig ] ) 153 ) { 154 // If the current policy requires a nonce, but the global state 155 // does not, that's bad. Throw an exception. This should never happen. 156 throw new LogicException( "Nonce requirement mismatch" ); 157 } 158 159 $additionalSelfUrls = $this->getAdditionalSelfUrls(); 160 $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript(); 161 162 // If no default-src is sent at all, it 163 // seems browsers (or at least some), interpret 164 // that as allow anything, but the spec seems 165 // to imply that data: and blob: should be 166 // blocked. 167 $defaultSrc = [ '*', 'data:', 'blob:' ]; 168 169 $imgSrc = false; 170 $scriptSrc = [ "'unsafe-eval'", "blob:", "'self'" ]; 171 if ( $policyConfig['useNonces'] ?? true ) { 172 $scriptSrc[] = "'nonce-" . $this->getNonce() . "'"; 173 } 174 175 $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript ); 176 if ( isset( $policyConfig['script-src'] ) 177 && is_array( $policyConfig['script-src'] ) 178 ) { 179 foreach ( $policyConfig['script-src'] as $src ) { 180 $scriptSrc[] = $this->escapeUrlForCSP( $src ); 181 } 182 } 183 // Note: default on if unspecified. 184 if ( $policyConfig['unsafeFallback'] ?? true ) { 185 // unsafe-inline should be ignored on browsers 186 // that support 'nonce-foo' sources. 187 // Some older versions of firefox don't follow this 188 // rule, but new browsers do. (Should be for at least 189 // firefox 40+). 190 $scriptSrc[] = "'unsafe-inline'"; 191 } 192 // If default source option set to true or 193 // an array of urls, set a restrictive default-src. 194 // If set to false, we send a lenient default-src, 195 // see the code above where $defaultSrc is set initially. 196 if ( isset( $policyConfig['default-src'] ) 197 && $policyConfig['default-src'] !== false 198 ) { 199 $defaultSrc = array_merge( 200 [ "'self'", 'data:', 'blob:' ], 201 $additionalSelfUrls 202 ); 203 if ( is_array( $policyConfig['default-src'] ) ) { 204 foreach ( $policyConfig['default-src'] as $src ) { 205 $defaultSrc[] = $this->escapeUrlForCSP( $src ); 206 } 207 } 208 } 209 210 if ( $policyConfig['includeCORS'] ?? true ) { 211 $CORSUrls = $this->getCORSSources(); 212 if ( !in_array( '*', $defaultSrc ) ) { 213 $defaultSrc = array_merge( $defaultSrc, $CORSUrls ); 214 } 215 // Unlikely to have * in scriptSrc, but doesn't 216 // hurt to check. 217 if ( !in_array( '*', $scriptSrc ) ) { 218 $scriptSrc = array_merge( $scriptSrc, $CORSUrls ); 219 } 220 } 221 222 $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc ); 223 $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc ); 224 225 $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [ "'unsafe-inline'" ] ); 226 227 $this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode ); 228 $this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode ); 229 230 if ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) { 231 if ( $policyConfig['report-uri'] === false ) { 232 $reportUri = false; 233 } else { 234 $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] ); 235 } 236 } else { 237 $reportUri = $this->getReportUri( $mode ); 238 } 239 240 // Only send an img-src, if we're sending a restricitve default. 241 if ( !is_array( $defaultSrc ) 242 || !in_array( '*', $defaultSrc ) 243 || !in_array( 'data:', $defaultSrc ) 244 || !in_array( 'blob:', $defaultSrc ) 245 ) { 246 // A future todo might be to make the whitelist options only 247 // add all the whitelisted sites to the header, instead of 248 // allowing all (Assuming there is a small number of sites). 249 // For now, the external image feature disables the limits 250 // CSP puts on external images. 251 if ( $mwConfig->get( 'AllowExternalImages' ) 252 || $mwConfig->get( 'AllowExternalImagesFrom' ) 253 || $mwConfig->get( 'AllowImageTag' ) 254 ) { 255 $imgSrc = [ '*', 'data:', 'blob:' ]; 256 } elseif ( $mwConfig->get( 'EnableImageWhitelist' ) ) { 257 $whitelist = wfMessage( 'external_image_whitelist' ) 258 ->inContentLanguage() 259 ->plain(); 260 if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) { 261 $imgSrc = [ '*', 'data:', 'blob:' ]; 262 } 263 } 264 } 265 // Default value 'none'. true is none, false is nothing, string is single directive, 266 // array is list. 267 if ( !isset( $policyConfig['object-src'] ) || $policyConfig['object-src'] === true ) { 268 $objectSrc = [ "'none'" ]; 269 } else { 270 $objectSrc = (array)( $policyConfig['object-src'] ?: [] ); 271 } 272 $objectSrc = array_map( [ $this, 'escapeUrlForCSP' ], $objectSrc ); 273 274 $directives = []; 275 if ( $scriptSrc ) { 276 $directives[] = 'script-src ' . implode( ' ', array_unique( $scriptSrc ) ); 277 } 278 if ( $defaultSrc ) { 279 $directives[] = 'default-src ' . implode( ' ', array_unique( $defaultSrc ) ); 280 } 281 if ( $cssSrc ) { 282 $directives[] = 'style-src ' . implode( ' ', array_unique( $cssSrc ) ); 283 } 284 if ( $imgSrc ) { 285 $directives[] = 'img-src ' . implode( ' ', array_unique( $imgSrc ) ); 286 } 287 if ( $objectSrc ) { 288 $directives[] = 'object-src ' . implode( ' ', $objectSrc ); 289 } 290 if ( $reportUri ) { 291 $directives[] = 'report-uri ' . $reportUri; 292 } 293 294 $this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode ); 295 296 return implode( '; ', $directives ); 297 } 298 299 /** 300 * Get the default report uri. 301 * 302 * @param int $mode self::*_MODE constant. 303 * @return string The URI to send reports to. 304 * @throws UnexpectedValueException if given invalid mode. 305 */ 306 private function getReportUri( $mode ) { 307 $apiArguments = [ 308 'action' => 'cspreport', 309 'format' => 'json' 310 ]; 311 if ( $mode === self::REPORT_ONLY_MODE ) { 312 $apiArguments['reportonly'] = '1'; 313 } 314 $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments ); 315 316 // Per spec, ';' and ',' must be hex-escaped in report URI 317 $reportUri = $this->escapeUrlForCSP( $reportUri ); 318 return $reportUri; 319 } 320 321 /** 322 * Given a url, convert to form needed for CSP. 323 * 324 * Currently this does either scheme + host, or 325 * if protocol relative, just the host. Future versions 326 * could potentially preserve some of the path, if its determined 327 * that that would be a good idea. 328 * 329 * @note This does the extra escaping for CSP, but assumes the url 330 * has already had normal url escaping applied. 331 * @note This discards urls same as server name, as 'self' directive 332 * takes care of that. 333 * @param string $url 334 * @return string|bool Converted url or false on failure 335 */ 336 private function prepareUrlForCSP( $url ) { 337 $result = false; 338 if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) { 339 // A schema source (e.g. blob: or data:) 340 return $url; 341 } 342 $bits = wfParseUrl( $url ); 343 if ( !$bits && strpos( $url, '/' ) === false ) { 344 // probably something like example.com. 345 // try again protocol-relative. 346 $url = '//' . $url; 347 $bits = wfParseUrl( $url ); 348 } 349 if ( $bits && isset( $bits['host'] ) 350 && $bits['host'] !== $this->mwConfig->get( 'ServerName' ) 351 ) { 352 $result = $bits['host']; 353 if ( $bits['scheme'] !== '' ) { 354 $result = $bits['scheme'] . $bits['delimiter'] . $result; 355 } 356 if ( isset( $bits['port'] ) ) { 357 $result .= ':' . $bits['port']; 358 } 359 $result = $this->escapeUrlForCSP( $result ); 360 } 361 return $result; 362 } 363 364 /** 365 * Get additional script sources 366 * 367 * @return array Additional sources for loading scripts from 368 */ 369 private function getAdditionalSelfUrlsScript() { 370 $additionalUrls = []; 371 // wgExtensionAssetsPath for ?debug=true mode 372 $pathVars = [ 'LoadScript', 'ExtensionAssetsPath', 'ResourceBasePath' ]; 373 374 foreach ( $pathVars as $path ) { 375 $url = $this->mwConfig->get( $path ); 376 $preparedUrl = $this->prepareUrlForCSP( $url ); 377 if ( $preparedUrl ) { 378 $additionalUrls[] = $preparedUrl; 379 } 380 } 381 $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' ); 382 foreach ( $RLSources as $wiki => $sources ) { 383 foreach ( $sources as $id => $value ) { 384 $url = $this->prepareUrlForCSP( $value ); 385 if ( $url ) { 386 $additionalUrls[] = $url; 387 } 388 } 389 } 390 391 return array_unique( $additionalUrls ); 392 } 393 394 /** 395 * Get additional host names for the wiki (e.g. if static content loaded elsewhere) 396 * 397 * @note These are general load sources, not script sources 398 * @return string[] Array of other urls for wiki (for use in default-src) 399 */ 400 private function getAdditionalSelfUrls() { 401 // XXX on a foreign repo, the included description page can have anything on it, 402 // including inline scripts. But nobody sane does that. 403 404 // In principle, you can have even more complex configs... (e.g. The urlsByExt option) 405 $pathUrls = []; 406 $additionalSelfUrls = []; 407 408 // Future todo: The zone urls should never go into 409 // style-src. They should either be only in img-src, or if 410 // img-src unspecified they should be in default-src. Similarly, 411 // the DescriptionStylesheetUrl only needs to be in style-src 412 // (or default-src if style-src unspecified). 413 $callback = static function ( $repo, &$urls ) { 414 $urls[] = $repo->getZoneUrl( 'public' ); 415 $urls[] = $repo->getZoneUrl( 'transcoded' ); 416 $urls[] = $repo->getZoneUrl( 'thumb' ); 417 $urls[] = $repo->getDescriptionStylesheetUrl(); 418 }; 419 $repoGroup = MediaWikiServices::getInstance()->getRepoGroup(); 420 $localRepo = $repoGroup->getRepo( 'local' ); 421 $callback( $localRepo, $pathUrls ); 422 $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] ); 423 424 // Globals that might point to a different domain 425 $pathGlobals = [ 'LoadScript', 'ExtensionAssetsPath', 'StylePath', 'ResourceBasePath' ]; 426 foreach ( $pathGlobals as $path ) { 427 $pathUrls[] = $this->mwConfig->get( $path ); 428 } 429 foreach ( $pathUrls as $path ) { 430 $preparedUrl = $this->prepareUrlForCSP( $path ); 431 if ( $preparedUrl !== false ) { 432 $additionalSelfUrls[] = $preparedUrl; 433 } 434 } 435 $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' ); 436 437 foreach ( $RLSources as $wiki => $sources ) { 438 foreach ( $sources as $id => $value ) { 439 $url = $this->prepareUrlForCSP( $value ); 440 if ( $url ) { 441 $additionalSelfUrls[] = $url; 442 } 443 } 444 } 445 446 return array_unique( $additionalSelfUrls ); 447 } 448 449 /** 450 * include domains that are allowed to send us CORS requests. 451 * 452 * Technically, $wgCrossSiteAJAXdomains lists things that are allowed to talk to us 453 * not things that we are allowed to talk to - but if something is allowed to talk to us, 454 * then there is a good chance that we should probably be allowed to talk to it. 455 * 456 * This is configurable with the 'includeCORS' key in the CSP config, and enabled 457 * by default. 458 * @note CORS domains with single character ('?') wildcards, are not included. 459 * @return array Additional hosts 460 */ 461 private function getCORSSources() { 462 $additionalUrls = []; 463 $CORSSources = $this->mwConfig->get( 'CrossSiteAJAXdomains' ); 464 foreach ( $CORSSources as $source ) { 465 if ( strpos( $source, '?' ) !== false ) { 466 // CSP doesn't support single char wildcard 467 continue; 468 } 469 $url = $this->prepareUrlForCSP( $source ); 470 if ( $url ) { 471 $additionalUrls[] = $url; 472 } 473 } 474 return $additionalUrls; 475 } 476 477 /** 478 * CSP spec says ',' and ';' are not allowed to appear in urls. 479 * 480 * @note This assumes that normal escaping has been applied to the url 481 * @param string $url URL (or possibly just part of one) 482 * @return string 483 */ 484 private function escapeUrlForCSP( $url ) { 485 return str_replace( 486 [ ';', ',' ], 487 [ '%3B', '%2C' ], 488 $url 489 ); 490 } 491 492 /** 493 * Does this browser give false positive reports? 494 * 495 * Some versions of firefox (40-42) incorrectly report a csp 496 * violation for nonce sources, despite allowing them. 497 * 498 * @see https://bugzilla.mozilla.org/show_bug.cgi?id=1026520 499 * @param string $ua User-agent header 500 * @return bool 501 */ 502 public static function falsePositiveBrowser( $ua ) { 503 return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua ); 504 } 505 506 /** 507 * Should we set nonce attribute 508 * 509 * @param Config $config Configuration object 510 * @return bool 511 */ 512 public static function isNonceRequired( Config $config ) { 513 $configs = [ 514 $config->get( 'CSPHeader' ), 515 $config->get( 'CSPReportOnlyHeader' ) 516 ]; 517 return self::isNonceRequiredArray( $configs ); 518 } 519 520 /** 521 * Does a specific config require a nonce 522 * 523 * @param array $configs An array of CSP config arrays 524 * @return bool 525 */ 526 private static function isNonceRequiredArray( array $configs ) { 527 foreach ( $configs as $headerConfig ) { 528 if ( 529 $headerConfig === true || 530 ( is_array( $headerConfig ) && 531 !isset( $headerConfig['useNonces'] ) ) || 532 ( is_array( $headerConfig ) && 533 isset( $headerConfig['useNonces'] ) && 534 $headerConfig['useNonces'] ) 535 ) { 536 return true; 537 } 538 } 539 return false; 540 } 541 542 /** 543 * Get the nonce if nonce is in use 544 * 545 * @since 1.35 546 * @return bool|string A random (base64) string or false if not used. 547 */ 548 public function getNonce() { 549 if ( !self::isNonceRequired( $this->mwConfig ) ) { 550 return false; 551 } 552 if ( $this->nonce === null ) { 553 $rand = random_bytes( 15 ); 554 $this->nonce = base64_encode( $rand ); 555 } 556 557 return $this->nonce; 558 } 559 560 /** 561 * Add an additional default src 562 * 563 * If possible you should use a more specific source type then default. 564 * 565 * So for example, if an extension added a special page that loaded something 566 * it might call $this->getOutput()->getCSP()->addDefaultSrc( '*.example.com' ); 567 * 568 * @since 1.35 569 * @param string $source Source to add. 570 * e.g. blob:, *.example.com, %https://example.com, example.com/foo 571 */ 572 public function addDefaultSrc( $source ) { 573 $this->extraDefaultSrc[] = $this->prepareUrlForCSP( $source ); 574 } 575 576 /** 577 * Add an additional CSS src 578 * 579 * So for example, if an extension added a special page that loaded external CSS 580 * it might call $this->getOutput()->getCSP()->addStyleSrc( '*.example.com' ); 581 * 582 * @since 1.35 583 * @param string $source Source to add. 584 * e.g. blob:, *.example.com, %https://example.com, example.com/foo 585 */ 586 public function addStyleSrc( $source ) { 587 $this->extraStyleSrc[] = $this->prepareUrlForCSP( $source ); 588 } 589 590 /** 591 * Add an additional script src 592 * 593 * So for example, if an extension added a special page that loaded something 594 * it might call $this->getOutput()->getCSP()->addScriptSrc( '*.example.com' ); 595 * 596 * @since 1.35 597 * @warning Be careful including external scripts, as they can take over accounts. 598 * @param string $source Source to add. 599 * e.g. blob:, *.example.com, %https://example.com, example.com/foo 600 */ 601 public function addScriptSrc( $source ) { 602 $this->extraScriptSrc[] = $this->prepareUrlForCSP( $source ); 603 } 604} 605