Merge "mediawiki.router: Move hashchange handler to a real method"
[mediawiki.git] / includes / Request / ContentSecurityPolicy.php
blobca5c650aa1669796cbc679c0146170a436f40aa1
1 <?php
2 /**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
18 * @file
21 namespace MediaWiki\Request;
23 use MediaWiki\Config\Config;
24 use MediaWiki\HookContainer\HookContainer;
25 use MediaWiki\HookContainer\HookRunner;
26 use MediaWiki\MainConfigNames;
27 use MediaWiki\MediaWikiServices;
28 use UnexpectedValueException;
30 /**
31 * Handle sending Content-Security-Policy headers
33 * @author Copyright 2015–2018 Brian Wolff
34 * @see https://www.w3.org/TR/CSP2/
35 * @since 1.32
37 class ContentSecurityPolicy {
38 public const REPORT_ONLY_MODE = 1;
39 public const FULL_MODE = 2;
41 /** @var Config The site configuration object */
42 private $mwConfig;
43 /** @var WebResponse */
44 private $response;
46 /** @var string[] */
47 private $extraDefaultSrc = [];
48 /** @var string[] */
49 private $extraScriptSrc = [];
50 /** @var string[] */
51 private $extraStyleSrc = [];
53 /** @var HookRunner */
54 private $hookRunner;
56 /**
57 * @note As a general rule, you would not construct this class directly
58 * but use the instance from OutputPage::getCSP()
59 * @internal
60 * @param WebResponse $response
61 * @param Config $mwConfig
62 * @param HookContainer $hookContainer
63 * @since 1.35 Method signature changed
65 public function __construct(
66 WebResponse $response,
67 Config $mwConfig,
68 HookContainer $hookContainer
69 ) {
70 $this->response = $response;
71 $this->mwConfig = $mwConfig;
72 $this->hookRunner = new HookRunner( $hookContainer );
75 /**
76 * Get the CSP directives for the wiki.
77 * @return string[] Array of CSP directives (header name => header value). The array keys will be
78 * ContentSecurityPolicy::FULL_MODE and ContentSecurityPolicy::REPORT_ONLY_MODE; they might not
79 * be present if the wiki is configured no to use the given type of CSP.
80 * @phan-return array{Content-Security-Policy?:string,Content-Security-Policy-Report-Only?:string}
81 * @since 1.42
83 public function getDirectives() {
84 $cspConfig = $this->mwConfig->get( MainConfigNames::CSPHeader );
85 $cspConfigReportOnly = $this->mwConfig->get( MainConfigNames::CSPReportOnlyHeader );
87 $cspPolicy = $this->makeCSPDirectives( $cspConfig, self::FULL_MODE );
88 $cspReportOnlyPolicy = $this->makeCSPDirectives( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
90 return array_filter( [
91 $this->getHeaderName( self::FULL_MODE ) => $cspPolicy,
92 $this->getHeaderName( self::REPORT_ONLY_MODE ) => $cspReportOnlyPolicy,
93 ] );
96 /**
97 * Send CSP headers based on wiki config
99 * Main method that callers (OutputPage) are expected to use.
100 * As a general rule, you would never call this in an extension unless
101 * you have disabled OutputPage and are fully controlling the output.
103 * @since 1.35
105 public function sendHeaders() {
106 $directives = $this->getDirectives();
107 foreach ( $directives as $headerName => $policy ) {
108 $this->response->header( "$headerName: $policy" );
111 // This used to insert a <meta> tag here, per advice at
112 // https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/
113 // The goal was to prevent nonce from working after the page hit onready,
114 // This would help in old browsers that didn't support nonces, and
115 // also assist for Varnish-cached pages which repeat nonces.
116 // However, this is incompatible with how ResourceLoader runs code
117 // from mw.loader.store, so it was removed.
121 * @param int $reportOnly Either self::REPORT_ONLY_MODE or self::FULL_MODE
122 * @return string Name of http header
123 * @throws UnexpectedValueException
125 private function getHeaderName( $reportOnly ) {
126 if ( $reportOnly === self::REPORT_ONLY_MODE ) {
127 return 'Content-Security-Policy-Report-Only';
130 if ( $reportOnly === self::FULL_MODE ) {
131 return 'Content-Security-Policy';
133 throw new UnexpectedValueException( "Mode '$reportOnly' not recognised" );
137 * Determine what CSP policies to set for this page
139 * @param array|bool $policyConfig Policy configuration
140 * (Either $wgCSPHeader or $wgCSPReportOnlyHeader)
141 * @param int $mode self::REPORT_ONLY_MODE, self::FULL_MODE
142 * @return string Policy directives, or empty string for no policy.
144 private function makeCSPDirectives( $policyConfig, $mode ) {
145 if ( $policyConfig === false ) {
146 // CSP is disabled
147 return '';
149 if ( $policyConfig === true ) {
150 $policyConfig = [];
153 $mwConfig = $this->mwConfig;
155 if (
156 self::isNonceRequired( $mwConfig ) ||
157 self::isNonceRequiredArray( [ $policyConfig ] )
159 wfDeprecated( 'wgCSPHeader "useNonces" option', '1.41' );
162 $additionalSelfUrls = $this->getAdditionalSelfUrls();
163 $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
165 // If no default-src is sent at all, it seems browsers (or at least some),
166 // interpret that as allow anything, but the spec seems to imply that
167 // "data:" and "blob:" should be blocked.
168 $defaultSrc = [ '*', 'data:', 'blob:' ];
170 $imgSrc = false;
171 $scriptSrc = [ "'unsafe-eval'", "blob:", "'self'" ];
173 $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
174 if ( isset( $policyConfig['script-src'] )
175 && is_array( $policyConfig['script-src'] )
177 foreach ( $policyConfig['script-src'] as $src ) {
178 $scriptSrc[] = $this->escapeUrlForCSP( $src );
181 // Note: default on if unspecified.
182 if ( $policyConfig['unsafeFallback'] ?? true ) {
183 // unsafe-inline should be ignored on browsers that support 'nonce-foo' sources.
184 // Some older versions of firefox don't follow this rule, but new browsers do.
185 // (Should be for at least Firefox 40+).
186 $scriptSrc[] = "'unsafe-inline'";
188 // If default source option set to true or an array of urls,
189 // set a restrictive default-src.
190 // If set to false, we send a lenient default-src,
191 // see the code above where $defaultSrc is set initially.
192 if ( isset( $policyConfig['default-src'] )
193 && $policyConfig['default-src'] !== false
195 $defaultSrc = array_merge(
196 [ "'self'", 'data:', 'blob:' ],
197 $additionalSelfUrls
199 if ( is_array( $policyConfig['default-src'] ) ) {
200 foreach ( $policyConfig['default-src'] as $src ) {
201 $defaultSrc[] = $this->escapeUrlForCSP( $src );
206 if ( $policyConfig['includeCORS'] ?? true ) {
207 $CORSUrls = $this->getCORSSources();
208 if ( !in_array( '*', $defaultSrc ) ) {
209 $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
211 // Unlikely to have * in scriptSrc, but doesn't
212 // hurt to check.
213 if ( !in_array( '*', $scriptSrc ) ) {
214 $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
218 $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
219 $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
221 $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [ "'unsafe-inline'" ] );
223 $this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode );
224 $this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode );
226 if ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) {
227 if ( $policyConfig['report-uri'] === false ) {
228 $reportUri = false;
229 } else {
230 $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] );
232 } else {
233 $reportUri = $this->getReportUri( $mode );
236 // Only send an img-src, if we're sending a restrictive default.
237 if ( !is_array( $defaultSrc )
238 || !in_array( '*', $defaultSrc )
239 || !in_array( 'data:', $defaultSrc )
240 || !in_array( 'blob:', $defaultSrc )
242 // A future todo might be to make the allow options only
243 // add all the allowed sites to the header, instead of
244 // allowing all (Assuming there is a small number of sites).
245 // For now, the external image feature disables the limits
246 // CSP puts on external images.
247 if ( $mwConfig->get( MainConfigNames::AllowExternalImages )
248 || $mwConfig->get( MainConfigNames::AllowExternalImagesFrom )
250 $imgSrc = [ '*', 'data:', 'blob:' ];
251 } elseif ( $mwConfig->get( MainConfigNames::EnableImageWhitelist ) ) {
252 $whitelist = wfMessage( 'external_image_whitelist' )
253 ->inContentLanguage()
254 ->plain();
255 if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) {
256 $imgSrc = [ '*', 'data:', 'blob:' ];
260 // Default value 'none'. true is none, false is nothing, string is single directive,
261 // array is list.
262 if ( !isset( $policyConfig['object-src'] ) || $policyConfig['object-src'] === true ) {
263 $objectSrc = [ "'none'" ];
264 } else {
265 $objectSrc = (array)( $policyConfig['object-src'] ?: [] );
267 $objectSrc = array_map( [ $this, 'escapeUrlForCSP' ], $objectSrc );
269 $directives = [];
270 if ( $scriptSrc ) {
271 $directives[] = 'script-src ' . implode( ' ', array_unique( $scriptSrc ) );
273 if ( $defaultSrc ) {
274 $directives[] = 'default-src ' . implode( ' ', array_unique( $defaultSrc ) );
276 if ( $cssSrc ) {
277 $directives[] = 'style-src ' . implode( ' ', array_unique( $cssSrc ) );
279 if ( $imgSrc ) {
280 $directives[] = 'img-src ' . implode( ' ', array_unique( $imgSrc ) );
282 if ( $objectSrc ) {
283 $directives[] = 'object-src ' . implode( ' ', $objectSrc );
285 if ( $reportUri ) {
286 $directives[] = 'report-uri ' . $reportUri;
289 $this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode );
291 return implode( '; ', $directives );
295 * Get the default report uri.
297 * @param int $mode self::*_MODE constant.
298 * @return string The URI to send reports to.
299 * @throws UnexpectedValueException if given invalid mode.
301 private function getReportUri( $mode ) {
302 $apiArguments = [
303 'action' => 'cspreport',
304 'format' => 'json'
306 if ( $mode === self::REPORT_ONLY_MODE ) {
307 $apiArguments['reportonly'] = '1';
309 $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments );
311 // Per spec, ';' and ',' must be hex-escaped in report URI
312 $reportUri = $this->escapeUrlForCSP( $reportUri );
313 return $reportUri;
317 * Given a url, convert to form needed for CSP.
319 * Currently this does either scheme + host, or
320 * if protocol relative, just the host. Future versions
321 * could potentially preserve some of the path, if its determined
322 * that that would be a good idea.
324 * @note This does the extra escaping for CSP, but assumes the url
325 * has already had normal url escaping applied.
326 * @note This discards urls same as server name, as 'self' directive
327 * takes care of that.
328 * @param string $url
329 * @return string|bool Converted url or false on failure
331 private function prepareUrlForCSP( $url ) {
332 $result = false;
333 if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
334 // A schema source (e.g. blob: or data:)
335 return $url;
337 $bits = wfGetUrlUtils()->parse( $url );
338 if ( !$bits && strpos( $url, '/' ) === false ) {
339 // probably something like example.com.
340 // try again protocol-relative.
341 $url = '//' . $url;
342 $bits = wfGetUrlUtils()->parse( $url );
344 if ( $bits && isset( $bits['host'] )
345 && $bits['host'] !== $this->mwConfig->get( MainConfigNames::ServerName )
347 $result = $bits['host'];
348 if ( $bits['scheme'] !== '' ) {
349 $result = $bits['scheme'] . $bits['delimiter'] . $result;
351 if ( isset( $bits['port'] ) ) {
352 $result .= ':' . $bits['port'];
354 $result = $this->escapeUrlForCSP( $result );
356 return $result;
360 * @return string[] Additional sources for loading scripts from
362 private function getAdditionalSelfUrlsScript() {
363 $additionalUrls = [];
364 // wgExtensionAssetsPath for ?debug=true mode
365 $pathVars = [
366 MainConfigNames::LoadScript,
367 MainConfigNames::ExtensionAssetsPath,
368 MainConfigNames::ResourceBasePath,
371 foreach ( $pathVars as $path ) {
372 $url = $this->mwConfig->get( $path );
373 $preparedUrl = $this->prepareUrlForCSP( $url );
374 if ( $preparedUrl ) {
375 $additionalUrls[] = $preparedUrl;
378 $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
379 foreach ( $RLSources as $sources ) {
380 foreach ( $sources as $value ) {
381 $url = $this->prepareUrlForCSP( $value );
382 if ( $url ) {
383 $additionalUrls[] = $url;
388 return array_unique( $additionalUrls );
392 * Get additional host names for the wiki (e.g. if static content loaded elsewhere)
394 * @note These are general load sources, not script sources
395 * @return string[] Array of other urls for wiki (for use in default-src)
397 private function getAdditionalSelfUrls() {
398 // XXX on a foreign repo, the included description page can have anything on it,
399 // including inline scripts. But nobody does that.
401 // In principle, you can have even more complex configs... (e.g. The urlsByExt option)
402 $pathUrls = [];
403 $additionalSelfUrls = [];
405 // Future todo: The zone urls should never go into
406 // style-src. They should either be only in img-src, or if
407 // img-src unspecified they should be in default-src. Similarly,
408 // the DescriptionStylesheetUrl only needs to be in style-src
409 // (or default-src if style-src unspecified).
410 $callback = static function ( $repo, &$urls ) {
411 $urls[] = $repo->getZoneUrl( 'public' );
412 $urls[] = $repo->getZoneUrl( 'transcoded' );
413 $urls[] = $repo->getZoneUrl( 'thumb' );
414 $urls[] = $repo->getDescriptionStylesheetUrl();
416 $repoGroup = MediaWikiServices::getInstance()->getRepoGroup();
417 $localRepo = $repoGroup->getRepo( 'local' );
418 $callback( $localRepo, $pathUrls );
419 $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] );
421 // Globals that might point to a different domain
422 $pathGlobals = [
423 MainConfigNames::LoadScript,
424 MainConfigNames::ExtensionAssetsPath,
425 MainConfigNames::StylePath,
426 MainConfigNames::ResourceBasePath,
428 foreach ( $pathGlobals as $path ) {
429 $pathUrls[] = $this->mwConfig->get( $path );
431 foreach ( $pathUrls as $path ) {
432 $preparedUrl = $this->prepareUrlForCSP( $path );
433 if ( $preparedUrl !== false ) {
434 $additionalSelfUrls[] = $preparedUrl;
437 $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
439 foreach ( $RLSources as $sources ) {
440 foreach ( $sources as $value ) {
441 $url = $this->prepareUrlForCSP( $value );
442 if ( $url ) {
443 $additionalSelfUrls[] = $url;
448 return array_unique( $additionalSelfUrls );
452 * include domains that are allowed to send us CORS requests.
454 * Technically, $wgCrossSiteAJAXdomains lists things that are allowed to talk to us
455 * not things that we are allowed to talk to - but if something is allowed to talk to us,
456 * then there is a good chance that we should probably be allowed to talk to it.
458 * This is configurable with the 'includeCORS' key in the CSP config, and enabled
459 * by default.
460 * @note CORS domains with single character ('?') wildcards, are not included.
461 * @return array Additional hosts
463 private function getCORSSources() {
464 $additionalUrls = [];
465 $CORSSources = $this->mwConfig->get( MainConfigNames::CrossSiteAJAXdomains );
466 foreach ( $CORSSources as $source ) {
467 if ( strpos( $source, '?' ) !== false ) {
468 // CSP doesn't support single char wildcard
469 continue;
471 $url = $this->prepareUrlForCSP( $source );
472 if ( $url ) {
473 $additionalUrls[] = $url;
476 return $additionalUrls;
480 * CSP spec says ',' and ';' are not allowed to appear in urls.
482 * @note This assumes that normal escaping has been applied to the url
483 * @param string $url URL (or possibly just part of one)
484 * @return string
486 private function escapeUrlForCSP( $url ) {
487 return str_replace(
488 [ ';', ',' ],
489 [ '%3B', '%2C' ],
490 $url
495 * Does this browser give false positive reports?
497 * Some versions of firefox (40-42) incorrectly report a CSP
498 * violation for nonce sources, despite allowing them.
500 * @see https://bugzilla.mozilla.org/show_bug.cgi?id=1026520
501 * @param string $ua User-agent header
502 * @return bool
504 public static function falsePositiveBrowser( $ua ) {
505 return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
509 * Should we set nonce attribute
511 * @param Config $config
512 * @return bool
514 public static function isNonceRequired( Config $config ) {
515 $configs = [
516 $config->get( MainConfigNames::CSPHeader ),
517 $config->get( MainConfigNames::CSPReportOnlyHeader ),
519 return self::isNonceRequiredArray( $configs );
523 * Does a specific config require a nonce
525 * @param array $configs An array of CSP config arrays
526 * @return bool
528 private static function isNonceRequiredArray( array $configs ) {
529 foreach ( $configs as $headerConfig ) {
530 if (
531 is_array( $headerConfig ) &&
532 isset( $headerConfig['useNonces'] ) &&
533 $headerConfig['useNonces']
535 return true;
538 return false;
542 * Get the nonce if nonce is in use
544 * Not currently supported or implemented.
546 * @since 1.35
547 * @return false
549 public function getNonce() {
550 return false;
554 * If possible you should use a more specific source type then default.
556 * So for example, if an extension added a special page that loaded something
557 * it might call $this->getOutput()->getCSP()->addDefaultSrc( '*.example.com' );
559 * @since 1.35
560 * @param string $source Source to add.
561 * e.g. blob:, *.example.com, %https://example.com, example.com/foo
563 public function addDefaultSrc( $source ) {
564 $this->extraDefaultSrc[] = $this->prepareUrlForCSP( $source );
568 * So for example, if an extension added a special page that loaded external CSS
569 * it might call $this->getOutput()->getCSP()->addStyleSrc( '*.example.com' );
571 * @since 1.35
572 * @param string $source Source to add.
573 * e.g. blob:, *.example.com, %https://example.com, example.com/foo
575 public function addStyleSrc( $source ) {
576 $this->extraStyleSrc[] = $this->prepareUrlForCSP( $source );
580 * So for example, if an extension added a special page that loaded something
581 * it might call $this->getOutput()->getCSP()->addScriptSrc( '*.example.com' );
583 * @since 1.35
584 * @warning Be careful including external scripts, as they can take over accounts.
585 * @param string $source Source to add.
586 * e.g. blob:, *.example.com, %https://example.com, example.com/foo
588 public function addScriptSrc( $source ) {
589 $this->extraScriptSrc[] = $this->prepareUrlForCSP( $source );
593 /** @deprecated class alias since 1.40 */
594 class_alias( ContentSecurityPolicy::class, 'ContentSecurityPolicy' );