MediaWiki master
ContentSecurityPolicy.php
Go to the documentation of this file.
1<?php
8
14use UnexpectedValueException;
15
24 public const REPORT_ONLY_MODE = 1;
25 public const FULL_MODE = 2;
26
27 // Used for uploaded files. Keep in sync with images/.htaccess
28 private const UPLOAD_CSP = "default-src 'none'; style-src 'unsafe-inline' data:;" .
29 "font-src data:; img-src data: 'self'; media-src data: 'self'; sandbox";
30 private const UPLOAD_CSP_PDF = "default-src 'none'; style-src 'unsafe-inline' data:; object-src 'self';" .
31 "font-src data:; img-src data: 'self'; media-src data: 'self';";
32
33 // Reporting-Endpoints header name
34 private const REPORTING_ENDPOINTS_HEADER = "Reporting-Endpoints";
35
36 // report-to URI names, as set via Reporting-Endpoints header
37 private const REPORT_TO_NAME = "csp-report-to-endpoint";
38 private const REPORT_TO_REPORT_ONLY_NAME = "csp-report-to-report-only-endpoint";
39
41 private $mwConfig;
43 private $response;
44
46 private $extraDefaultSrc = [];
48 private $extraScriptSrc = [];
50 private $extraStyleSrc = [];
51
53 private $hookRunner;
54
64 public function __construct(
65 WebResponse $response,
66 Config $mwConfig,
67 HookContainer $hookContainer
68 ) {
69 $this->response = $response;
70 $this->mwConfig = $mwConfig;
71 $this->hookRunner = new HookRunner( $hookContainer );
72 }
73
82 public function getDirectives() {
83 $cspConfig = $this->mwConfig->get( MainConfigNames::CSPHeader );
84 $cspConfigReportOnly = $this->mwConfig->get( MainConfigNames::CSPReportOnlyHeader );
85
86 $cspPolicy = $this->makeCSPDirectives( $cspConfig, self::FULL_MODE );
87 $cspReportOnlyPolicy = $this->makeCSPDirectives( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
88
89 return array_filter( [
90 $this->getHeaderName( self::FULL_MODE ) => $cspPolicy,
91 $this->getHeaderName( self::REPORT_ONLY_MODE ) => $cspReportOnlyPolicy,
92 ] );
93 }
94
104 public function sendHeaders() {
105 // send Reporting-Endpoints header
106 // TODO: this should eventually be generalized somewhere else within includes/Request
107 $reportingHeader = $this->getReportingEndpointsHeader();
108 if ( $reportingHeader != '' ) {
109 $this->response->header( $reportingHeader );
110 }
111
112 // send CSP headers
113 $directives = $this->getDirectives();
114 foreach ( $directives as $headerName => $policy ) {
115 $this->response->header( "$headerName: $policy" );
116 }
117
118 // This used to insert a <meta> tag here, per advice at
119 // https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/
120 // The goal was to prevent nonce from working after the page hit onready,
121 // This would help in old browsers that didn't support nonces, and
122 // also assist for Varnish-cached pages which repeat nonces.
123 // However, this is incompatible with how ResourceLoader runs code
124 // from mw.loader.store, so it was removed.
125 }
126
132 private function getHeaderName( $reportOnly ) {
133 if ( $reportOnly === self::REPORT_ONLY_MODE ) {
134 return 'Content-Security-Policy-Report-Only';
135 }
136
137 if ( $reportOnly === self::FULL_MODE ) {
138 return 'Content-Security-Policy';
139 }
140
141 throw new UnexpectedValueException( "Mode '$reportOnly' not recognised" );
142 }
143
147 private function getReportingEndpointsHeader() {
148 $cspConfig = $this->mwConfig->get( MainConfigNames::CSPHeader );
149 $cspConfigReportOnly = $this->mwConfig->get( MainConfigNames::CSPReportOnlyHeader );
150
151 if ( !$cspConfig && !$cspConfigReportOnly ) {
152 return '';
153 }
154
155 $header = self::REPORTING_ENDPOINTS_HEADER . ": ";
156 if ( $cspConfig ) {
157 $header .= self::REPORT_TO_NAME . "='" . $this->getReportToURI( false ) . "'; ";
158 }
159 if ( $cspConfigReportOnly ) {
160 $header .= self::REPORT_TO_REPORT_ONLY_NAME . "='" . $this->getReportToURI( true ) . "'; ";
161 }
162
163 return $header;
164 }
165
174 private function makeCSPDirectives( $policyConfig, $mode ) {
175 if ( $policyConfig === false ) {
176 // CSP is disabled
177 return '';
178 }
179 if ( $policyConfig === true ) {
180 $policyConfig = [];
181 }
182
183 $mwConfig = $this->mwConfig;
184
185 if (
186 self::isNonceRequired( $mwConfig ) ||
187 self::isNonceRequiredArray( [ $policyConfig ] )
188 ) {
189 wfDeprecated( 'wgCSPHeader "useNonces" option', '1.41' );
190 }
191
192 $additionalSelfUrls = $this->getAdditionalSelfUrls();
193 $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
194
195 // If no default-src is sent at all, it seems browsers (or at least some),
196 // interpret that as allow anything, but the spec seems to imply that
197 // "data:" and "blob:" should be blocked.
198 $defaultSrc = [ '*', 'data:', 'blob:' ];
199
200 $imgSrc = false;
201 $scriptSrc = [ "'unsafe-eval'", "blob:", "'self'" ];
202
203 $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
204 if ( isset( $policyConfig['script-src'] )
205 && is_array( $policyConfig['script-src'] )
206 ) {
207 foreach ( $policyConfig['script-src'] as $src ) {
208 $scriptSrc[] = $this->escapeUrlForCSP( $src );
209 }
210 }
211 // Note: default on if unspecified.
212 if ( $policyConfig['unsafeFallback'] ?? true ) {
213 // unsafe-inline should be ignored on browsers that support 'nonce-foo' sources.
214 // Some older versions of firefox don't follow this rule, but new browsers do.
215 // (Should be for at least Firefox 40+).
216 $scriptSrc[] = "'unsafe-inline'";
217 }
218 // If default source option set to true or an array of urls,
219 // set a restrictive default-src.
220 // If set to false, we send a lenient default-src,
221 // see the code above where $defaultSrc is set initially.
222 if ( isset( $policyConfig['default-src'] )
223 && $policyConfig['default-src'] !== false
224 ) {
225 $defaultSrc = [ "'self'", 'data:', 'blob:', ...$additionalSelfUrls ];
226 if ( is_array( $policyConfig['default-src'] ) ) {
227 foreach ( $policyConfig['default-src'] as $src ) {
228 $defaultSrc[] = $this->escapeUrlForCSP( $src );
229 }
230 }
231 }
232
233 if ( $policyConfig['includeCORS'] ?? true ) {
234 $CORSUrls = $this->getCORSSources();
235 if ( !in_array( '*', $defaultSrc ) ) {
236 $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
237 }
238 // Unlikely to have * in scriptSrc, but doesn't
239 // hurt to check.
240 if ( !in_array( '*', $scriptSrc ) ) {
241 $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
242 }
243 }
244
245 $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
246 $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
247
248 $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [ "'unsafe-inline'" ] );
249
250 $this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode );
251 $this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode );
252
253 // TODO: formally deprecate report-uri after MW 1.47
254 $reportUri = false;
256 if ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) {
257 if ( $policyConfig['report-uri'] !== false ) {
258 $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] );
259 }
260 } else {
261 $reportUri = $this->getReportUri( $mode );
262 }
263 }
264
265 if ( isset( $policyConfig['report-to'] ) && $policyConfig['report-to'] !== true ) {
266 if ( $policyConfig['report-to'] === false ) {
267 $reportToName = false;
268 } else {
269 $reportToName = $policyConfig['report-to'];
270 }
271 } else {
272 if ( $mode == self::REPORT_ONLY_MODE ) {
273 $reportToName = self::REPORT_TO_REPORT_ONLY_NAME;
274 } else {
275 $reportToName = self::REPORT_TO_NAME;
276 }
277 }
278
279 // Only send an img-src, if we're sending a restrictive default.
280 if ( !is_array( $defaultSrc )
281 || !in_array( '*', $defaultSrc )
282 || !in_array( 'data:', $defaultSrc )
283 || !in_array( 'blob:', $defaultSrc )
284 ) {
285 // A future todo might be to make the allow options only
286 // add all the allowed sites to the header, instead of
287 // allowing all (Assuming there is a small number of sites).
288 // For now, the external image feature disables the limits
289 // CSP puts on external images.
292 ) {
293 $imgSrc = [ '*', 'data:', 'blob:' ];
294 } elseif ( $mwConfig->get( MainConfigNames::EnableImageWhitelist ) ) {
295 $whitelist = wfMessage( 'external_image_whitelist' )
296 ->inContentLanguage()
297 ->plain();
298 if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) {
299 $imgSrc = [ '*', 'data:', 'blob:' ];
300 }
301 }
302 }
303 // Default value 'none'. true is none, false is nothing, string is single directive,
304 // array is list.
305 if ( !isset( $policyConfig['object-src'] ) || $policyConfig['object-src'] === true ) {
306 $objectSrc = [ "'none'" ];
307 } else {
308 $objectSrc = (array)( $policyConfig['object-src'] ?: [] );
309 }
310 $objectSrc = array_map( $this->escapeUrlForCSP( ... ), $objectSrc );
311
312 $directives = [];
313 if ( $scriptSrc ) {
314 $directives[] = 'script-src ' . implode( ' ', array_unique( $scriptSrc ) );
315 }
316 if ( $defaultSrc ) {
317 $directives[] = 'default-src ' . implode( ' ', array_unique( $defaultSrc ) );
318 }
319 if ( $cssSrc ) {
320 $directives[] = 'style-src ' . implode( ' ', array_unique( $cssSrc ) );
321 }
322 if ( $imgSrc ) {
323 $directives[] = 'img-src ' . implode( ' ', array_unique( $imgSrc ) );
324 }
325 if ( $objectSrc ) {
326 $directives[] = 'object-src ' . implode( ' ', $objectSrc );
327 }
328 if ( $reportUri ) {
329 $directives[] = 'report-uri ' . $reportUri;
330 }
331 if ( $reportToName ) {
332 $directives[] = 'report-to ' . $reportToName;
333 }
334
335 $this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode );
336
337 return implode( '; ', $directives );
338 }
339
347 private function getReportUri( $mode ) {
348 $apiArguments = [
349 'action' => 'cspreport',
350 'format' => 'json'
351 ];
352 if ( $mode === self::REPORT_ONLY_MODE ) {
353 $apiArguments['reportonly'] = '1';
354 }
355 $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments );
356
357 // Per spec, ';' and ',' must be hex-escaped in report URI
358 $reportUri = $this->escapeUrlForCSP( $reportUri );
359 return $reportUri;
360 }
361
370 private function getReportToURI( $cspReportOnlyEnabled ) {
371 $apiArguments = [
372 'action' => 'cspreport',
373 'format' => 'json'
374 ];
375
376 if ( $cspReportOnlyEnabled ) {
377 $apiArguments['reportonly'] = '1';
378 }
379 $reportToURI = wfAppendQuery( wfScript( 'api' ), $apiArguments );
380
381 // Per spec, ';' and ',' must be hex-escaped in report URI
382 $reportToURI = $this->escapeUrlForCSP( $reportToURI );
383 return $reportToURI;
384 }
385
401 private function prepareUrlForCSP( $url ) {
402 $urlUtils = MediaWikiServices::getInstance()->getUrlUtils();
403 $result = false;
404 if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
405 // A schema source (e.g. blob: or data:)
406 return $url;
407 }
408 $bits = $urlUtils->parse( $url );
409 if ( !$bits && !str_contains( $url, '/' ) ) {
410 // probably something like example.com.
411 // try again protocol-relative.
412 $url = '//' . $url;
413 $bits = $urlUtils->parse( $url );
414 }
415 if ( $bits && isset( $bits['host'] )
416 && $bits['host'] !== $this->mwConfig->get( MainConfigNames::ServerName )
417 ) {
418 $result = $bits['host'];
419 if ( $bits['scheme'] !== '' ) {
420 $result = $bits['scheme'] . $bits['delimiter'] . $result;
421 }
422 if ( isset( $bits['port'] ) ) {
423 $result .= ':' . $bits['port'];
424 }
425 $result = $this->escapeUrlForCSP( $result );
426 }
427 return $result;
428 }
429
433 private function getAdditionalSelfUrlsScript() {
434 $additionalUrls = [];
435 // wgExtensionAssetsPath for ?debug=true mode
436 $pathVars = [
440 ];
441
442 foreach ( $pathVars as $path ) {
443 $url = $this->mwConfig->get( $path );
444 $preparedUrl = $this->prepareUrlForCSP( $url );
445 if ( $preparedUrl ) {
446 $additionalUrls[] = $preparedUrl;
447 }
448 }
449 $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
450 foreach ( $RLSources as $sources ) {
451 foreach ( $sources as $value ) {
452 $url = $this->prepareUrlForCSP( $value );
453 if ( $url ) {
454 $additionalUrls[] = $url;
455 }
456 }
457 }
458
459 return array_unique( $additionalUrls );
460 }
461
468 private function getAdditionalSelfUrls() {
469 // XXX on a foreign repo, the included description page can have anything on it,
470 // including inline scripts. But nobody does that.
471
472 // In principle, you can have even more complex configs... (e.g. The urlsByExt option)
473 $pathUrls = [];
474 $additionalSelfUrls = [];
475
476 // Future todo: The zone urls should never go into
477 // style-src. They should either be only in img-src, or if
478 // img-src unspecified they should be in default-src. Similarly,
479 // the DescriptionStylesheetUrl only needs to be in style-src
480 // (or default-src if style-src unspecified).
481 $callback = static function ( $repo, &$urls ) {
482 $urls[] = $repo->getZoneUrl( 'public' );
483 $urls[] = $repo->getZoneUrl( 'transcoded' );
484 $urls[] = $repo->getZoneUrl( 'thumb' );
485 $urls[] = $repo->getDescriptionStylesheetUrl();
486 };
487 $repoGroup = MediaWikiServices::getInstance()->getRepoGroup();
488 $localRepo = $repoGroup->getRepo( 'local' );
489 $callback( $localRepo, $pathUrls );
490 $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] );
491
492 // Globals that might point to a different domain
493 $pathGlobals = [
498 ];
499 foreach ( $pathGlobals as $path ) {
500 $pathUrls[] = $this->mwConfig->get( $path );
501 }
502 foreach ( $pathUrls as $path ) {
503 $preparedUrl = $this->prepareUrlForCSP( $path );
504 if ( $preparedUrl !== false ) {
505 $additionalSelfUrls[] = $preparedUrl;
506 }
507 }
508 $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
509
510 foreach ( $RLSources as $sources ) {
511 foreach ( $sources as $value ) {
512 $url = $this->prepareUrlForCSP( $value );
513 if ( $url ) {
514 $additionalSelfUrls[] = $url;
515 }
516 }
517 }
518
519 return array_unique( $additionalSelfUrls );
520 }
521
534 private function getCORSSources() {
535 $additionalUrls = [];
536 $CORSSources = $this->mwConfig->get( MainConfigNames::CrossSiteAJAXdomains );
537 foreach ( $CORSSources as $source ) {
538 if ( str_contains( $source, '?' ) ) {
539 // CSP doesn't support single char wildcard
540 continue;
541 }
542 $url = $this->prepareUrlForCSP( $source );
543 if ( $url ) {
544 $additionalUrls[] = $url;
545 }
546 }
547 return $additionalUrls;
548 }
549
557 private function escapeUrlForCSP( $url ) {
558 return str_replace(
559 [ ';', ',' ],
560 [ '%3B', '%2C' ],
561 $url
562 );
563 }
564
575 public static function falsePositiveBrowser( $ua ) {
576 return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
577 }
578
585 public static function isNonceRequired( Config $config ) {
586 $configs = [
589 ];
590 return self::isNonceRequiredArray( $configs );
591 }
592
599 private static function isNonceRequiredArray( array $configs ) {
600 foreach ( $configs as $headerConfig ) {
601 if (
602 is_array( $headerConfig ) &&
603 isset( $headerConfig['useNonces'] ) &&
604 $headerConfig['useNonces']
605 ) {
606 return true;
607 }
608 }
609 return false;
610 }
611
620 public function getNonce() {
621 return false;
622 }
623
634 public function addDefaultSrc( $source ) {
635 $this->extraDefaultSrc[] = $this->prepareUrlForCSP( $source );
636 }
637
646 public function addStyleSrc( $source ) {
647 $this->extraStyleSrc[] = $this->prepareUrlForCSP( $source );
648 }
649
659 public function addScriptSrc( $source ) {
660 $this->extraScriptSrc[] = $this->prepareUrlForCSP( $source );
661 }
662
673 public static function getMediaHeader( string $filename ) {
674 $config = MediaWikiServices::getInstance()->getMainConfig();
675 if ( !$config->get( MainConfigNames::CSPUploadEntryPoint ) ) {
676 return null;
677 }
678 // Some browsers (Chrome) require allowing objects to
679 // render PDFs. Generally plugins are slightly higher-risk
680 // so only allow it on pdf files.
681 if ( strtolower( substr( $filename, -4 ) ) === '.pdf' ) {
682 return self::UPLOAD_CSP_PDF;
683 }
684 return self::UPLOAD_CSP;
685 }
686
697 public static function sendRestrictiveHeader() {
698 // Intentionally don't use WebResponse, since we want to use this in
699 // exception handler, so avoid unnecessary dependencies. Still allow
700 // default-src of 'self' for favicon and whatnot. This doesn't include
701 // default-src data or style-src 'unsafe-inline', which would be fairly
702 // safe, but we are trying to be minimal here.
703 header( "Content-Security-Policy: default-src 'self'; script-src 'none'; object-src 'none'" );
704 }
705}
wfAppendQuery( $url, $query)
Append a query string to an existing URL, which may or may not already have query string parameters a...
wfScript( $script='index')
Get the URL path to a MediaWiki entry point.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
A class containing constants representing the names of configuration variables.
const ServerName
Name constant for the ServerName setting, for use with Config::get()
const StylePath
Name constant for the StylePath setting, for use with Config::get()
const CSPUseReportURIDirective
Name constant for the CSPUseReportURIDirective setting, for use with Config::get()
const ExtensionAssetsPath
Name constant for the ExtensionAssetsPath setting, for use with Config::get()
const ResourceBasePath
Name constant for the ResourceBasePath setting, for use with Config::get()
const ResourceLoaderSources
Name constant for the ResourceLoaderSources setting, for use with Config::get()
const CSPHeader
Name constant for the CSPHeader setting, for use with Config::get()
const AllowExternalImages
Name constant for the AllowExternalImages setting, for use with Config::get()
const CSPUploadEntryPoint
Name constant for the CSPUploadEntryPoint setting, for use with Config::get()
const EnableImageWhitelist
Name constant for the EnableImageWhitelist setting, for use with Config::get()
const LoadScript
Name constant for the LoadScript setting, for use with Config::get()
const CSPReportOnlyHeader
Name constant for the CSPReportOnlyHeader setting, for use with Config::get()
const AllowExternalImagesFrom
Name constant for the AllowExternalImagesFrom setting, for use with Config::get()
const CrossSiteAJAXdomains
Name constant for the CrossSiteAJAXdomains setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
Handle sending Content-Security-Policy headers.
static falsePositiveBrowser( $ua)
Does this browser give false positive reports?
addDefaultSrc( $source)
If possible you should use a more specific source type then default.
sendHeaders()
Send CSP and related headers based on wiki config.
static getMediaHeader(string $filename)
Get the CSP header for a specific file.
getNonce()
Get the nonce if nonce is in use.
addStyleSrc( $source)
So for example, if an extension added a special page that loaded external CSS it might call $this->ge...
static isNonceRequired(Config $config)
Should we set nonce attribute.
__construct(WebResponse $response, Config $mwConfig, HookContainer $hookContainer)
addScriptSrc( $source)
So for example, if an extension added a special page that loaded something it might call $this->getOu...
getDirectives()
Get the CSP directives for the wiki.
static sendRestrictiveHeader()
Output a very restrictive CSP header to disallow all active content.
Allow programs to request this object from WebRequest::response() and handle all outputting (or lack ...
Interface for configuration instances.
Definition Config.php:18
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
$source