MediaWiki  master
ContentSecurityPolicy.php
Go to the documentation of this file.
1 <?php
22 
28 use UnexpectedValueException;
29 
38  public const REPORT_ONLY_MODE = 1;
39  public const FULL_MODE = 2;
40 
42  private $mwConfig;
44  private $response;
45 
47  private $extraDefaultSrc = [];
49  private $extraScriptSrc = [];
51  private $extraStyleSrc = [];
52 
54  private $hookRunner;
55 
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 );
73  }
74 
83  public function sendCSPHeader( $csp, $reportOnly ) {
84  $policy = $this->makeCSPDirectives( $csp, $reportOnly );
85  $headerName = $this->getHeaderName( $reportOnly );
86  if ( $policy ) {
87  $this->response->header(
88  "$headerName: $policy"
89  );
90  }
91  }
92 
102  public function sendHeaders() {
103  $cspConfig = $this->mwConfig->get( MainConfigNames::CSPHeader );
104  $cspConfigReportOnly = $this->mwConfig->get( MainConfigNames::CSPReportOnlyHeader );
105 
106  $this->sendCSPHeader( $cspConfig, self::FULL_MODE );
107  $this->sendCSPHeader( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
108 
109  // This used to insert a <meta> tag here, per advice at
110  // https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/
111  // The goal was to prevent nonce from working after the page hit onready,
112  // This would help in old browsers that didn't support nonces, and
113  // also assist for Varnish-cached pages which repeat nonces.
114  // However, this is incompatible with how ResourceLoader runs code
115  // from mw.loader.store, so it was removed.
116  }
117 
123  private function getHeaderName( $reportOnly ) {
124  if ( $reportOnly === self::REPORT_ONLY_MODE ) {
125  return 'Content-Security-Policy-Report-Only';
126  }
127 
128  if ( $reportOnly === self::FULL_MODE ) {
129  return 'Content-Security-Policy';
130  }
131  throw new UnexpectedValueException( "Mode '$reportOnly' not recognised" );
132  }
133 
142  private function makeCSPDirectives( $policyConfig, $mode ) {
143  if ( $policyConfig === false ) {
144  // CSP is disabled
145  return '';
146  }
147  if ( $policyConfig === true ) {
148  $policyConfig = [];
149  }
150 
151  $mwConfig = $this->mwConfig;
152 
153  if (
154  self::isNonceRequired( $mwConfig ) ||
155  self::isNonceRequiredArray( [ $policyConfig ] )
156  ) {
157  wfDeprecated( 'wgCSPHeader "useNonces" option', '1.41' );
158  }
159 
160  $additionalSelfUrls = $this->getAdditionalSelfUrls();
161  $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
162 
163  // If no default-src is sent at all, it seems browsers (or at least some),
164  // interpret that as allow anything, but the spec seems to imply that
165  // "data:" and "blob:" should be blocked.
166  $defaultSrc = [ '*', 'data:', 'blob:' ];
167 
168  $imgSrc = false;
169  $scriptSrc = [ "'unsafe-eval'", "blob:", "'self'" ];
170 
171  $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
172  if ( isset( $policyConfig['script-src'] )
173  && is_array( $policyConfig['script-src'] )
174  ) {
175  foreach ( $policyConfig['script-src'] as $src ) {
176  $scriptSrc[] = $this->escapeUrlForCSP( $src );
177  }
178  }
179  // Note: default on if unspecified.
180  if ( $policyConfig['unsafeFallback'] ?? true ) {
181  // unsafe-inline should be ignored on browsers that support 'nonce-foo' sources.
182  // Some older versions of firefox don't follow this rule, but new browsers do.
183  // (Should be for at least Firefox 40+).
184  $scriptSrc[] = "'unsafe-inline'";
185  }
186  // If default source option set to true or an array of urls,
187  // set a restrictive default-src.
188  // If set to false, we send a lenient default-src,
189  // see the code above where $defaultSrc is set initially.
190  if ( isset( $policyConfig['default-src'] )
191  && $policyConfig['default-src'] !== false
192  ) {
193  $defaultSrc = array_merge(
194  [ "'self'", 'data:', 'blob:' ],
195  $additionalSelfUrls
196  );
197  if ( is_array( $policyConfig['default-src'] ) ) {
198  foreach ( $policyConfig['default-src'] as $src ) {
199  $defaultSrc[] = $this->escapeUrlForCSP( $src );
200  }
201  }
202  }
203 
204  if ( $policyConfig['includeCORS'] ?? true ) {
205  $CORSUrls = $this->getCORSSources();
206  if ( !in_array( '*', $defaultSrc ) ) {
207  $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
208  }
209  // Unlikely to have * in scriptSrc, but doesn't
210  // hurt to check.
211  if ( !in_array( '*', $scriptSrc ) ) {
212  $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
213  }
214  }
215 
216  $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
217  $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
218 
219  $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [ "'unsafe-inline'" ] );
220 
221  $this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode );
222  $this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode );
223 
224  if ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) {
225  if ( $policyConfig['report-uri'] === false ) {
226  $reportUri = false;
227  } else {
228  $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] );
229  }
230  } else {
231  $reportUri = $this->getReportUri( $mode );
232  }
233 
234  // Only send an img-src, if we're sending a restrictive default.
235  if ( !is_array( $defaultSrc )
236  || !in_array( '*', $defaultSrc )
237  || !in_array( 'data:', $defaultSrc )
238  || !in_array( 'blob:', $defaultSrc )
239  ) {
240  // A future todo might be to make the allow options only
241  // add all the allowed sites to the header, instead of
242  // allowing all (Assuming there is a small number of sites).
243  // For now, the external image feature disables the limits
244  // CSP puts on external images.
245  if ( $mwConfig->get( MainConfigNames::AllowExternalImages )
246  || $mwConfig->get( MainConfigNames::AllowExternalImagesFrom )
247  || $mwConfig->get( MainConfigNames::AllowImageTag )
248  ) {
249  $imgSrc = [ '*', 'data:', 'blob:' ];
250  } elseif ( $mwConfig->get( MainConfigNames::EnableImageWhitelist ) ) {
251  $whitelist = wfMessage( 'external_image_whitelist' )
252  ->inContentLanguage()
253  ->plain();
254  if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) {
255  $imgSrc = [ '*', 'data:', 'blob:' ];
256  }
257  }
258  }
259  // Default value 'none'. true is none, false is nothing, string is single directive,
260  // array is list.
261  if ( !isset( $policyConfig['object-src'] ) || $policyConfig['object-src'] === true ) {
262  $objectSrc = [ "'none'" ];
263  } else {
264  $objectSrc = (array)( $policyConfig['object-src'] ?: [] );
265  }
266  $objectSrc = array_map( [ $this, 'escapeUrlForCSP' ], $objectSrc );
267 
268  $directives = [];
269  if ( $scriptSrc ) {
270  $directives[] = 'script-src ' . implode( ' ', array_unique( $scriptSrc ) );
271  }
272  if ( $defaultSrc ) {
273  $directives[] = 'default-src ' . implode( ' ', array_unique( $defaultSrc ) );
274  }
275  if ( $cssSrc ) {
276  $directives[] = 'style-src ' . implode( ' ', array_unique( $cssSrc ) );
277  }
278  if ( $imgSrc ) {
279  $directives[] = 'img-src ' . implode( ' ', array_unique( $imgSrc ) );
280  }
281  if ( $objectSrc ) {
282  $directives[] = 'object-src ' . implode( ' ', $objectSrc );
283  }
284  if ( $reportUri ) {
285  $directives[] = 'report-uri ' . $reportUri;
286  }
287 
288  $this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode );
289 
290  return implode( '; ', $directives );
291  }
292 
300  private function getReportUri( $mode ) {
301  $apiArguments = [
302  'action' => 'cspreport',
303  'format' => 'json'
304  ];
305  if ( $mode === self::REPORT_ONLY_MODE ) {
306  $apiArguments['reportonly'] = '1';
307  }
308  $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments );
309 
310  // Per spec, ';' and ',' must be hex-escaped in report URI
311  $reportUri = $this->escapeUrlForCSP( $reportUri );
312  return $reportUri;
313  }
314 
330  private function prepareUrlForCSP( $url ) {
331  $result = false;
332  if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
333  // A schema source (e.g. blob: or data:)
334  return $url;
335  }
336  $bits = wfParseUrl( $url );
337  if ( !$bits && strpos( $url, '/' ) === false ) {
338  // probably something like example.com.
339  // try again protocol-relative.
340  $url = '//' . $url;
341  $bits = wfParseUrl( $url );
342  }
343  if ( $bits && isset( $bits['host'] )
344  && $bits['host'] !== $this->mwConfig->get( MainConfigNames::ServerName )
345  ) {
346  $result = $bits['host'];
347  if ( $bits['scheme'] !== '' ) {
348  $result = $bits['scheme'] . $bits['delimiter'] . $result;
349  }
350  if ( isset( $bits['port'] ) ) {
351  $result .= ':' . $bits['port'];
352  }
353  $result = $this->escapeUrlForCSP( $result );
354  }
355  return $result;
356  }
357 
361  private function getAdditionalSelfUrlsScript() {
362  $additionalUrls = [];
363  // wgExtensionAssetsPath for ?debug=true mode
364  $pathVars = [
368  ];
369 
370  foreach ( $pathVars as $path ) {
371  $url = $this->mwConfig->get( $path );
372  $preparedUrl = $this->prepareUrlForCSP( $url );
373  if ( $preparedUrl ) {
374  $additionalUrls[] = $preparedUrl;
375  }
376  }
377  $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
378  foreach ( $RLSources as $sources ) {
379  foreach ( $sources as $value ) {
380  $url = $this->prepareUrlForCSP( $value );
381  if ( $url ) {
382  $additionalUrls[] = $url;
383  }
384  }
385  }
386 
387  return array_unique( $additionalUrls );
388  }
389 
396  private function getAdditionalSelfUrls() {
397  // XXX on a foreign repo, the included description page can have anything on it,
398  // including inline scripts. But nobody does that.
399 
400  // In principle, you can have even more complex configs... (e.g. The urlsByExt option)
401  $pathUrls = [];
402  $additionalSelfUrls = [];
403 
404  // Future todo: The zone urls should never go into
405  // style-src. They should either be only in img-src, or if
406  // img-src unspecified they should be in default-src. Similarly,
407  // the DescriptionStylesheetUrl only needs to be in style-src
408  // (or default-src if style-src unspecified).
409  $callback = static function ( $repo, &$urls ) {
410  $urls[] = $repo->getZoneUrl( 'public' );
411  $urls[] = $repo->getZoneUrl( 'transcoded' );
412  $urls[] = $repo->getZoneUrl( 'thumb' );
413  $urls[] = $repo->getDescriptionStylesheetUrl();
414  };
415  $repoGroup = MediaWikiServices::getInstance()->getRepoGroup();
416  $localRepo = $repoGroup->getRepo( 'local' );
417  $callback( $localRepo, $pathUrls );
418  $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] );
419 
420  // Globals that might point to a different domain
421  $pathGlobals = [
426  ];
427  foreach ( $pathGlobals as $path ) {
428  $pathUrls[] = $this->mwConfig->get( $path );
429  }
430  foreach ( $pathUrls as $path ) {
431  $preparedUrl = $this->prepareUrlForCSP( $path );
432  if ( $preparedUrl !== false ) {
433  $additionalSelfUrls[] = $preparedUrl;
434  }
435  }
436  $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
437 
438  foreach ( $RLSources as $sources ) {
439  foreach ( $sources as $value ) {
440  $url = $this->prepareUrlForCSP( $value );
441  if ( $url ) {
442  $additionalSelfUrls[] = $url;
443  }
444  }
445  }
446 
447  return array_unique( $additionalSelfUrls );
448  }
449 
462  private function getCORSSources() {
463  $additionalUrls = [];
464  $CORSSources = $this->mwConfig->get( MainConfigNames::CrossSiteAJAXdomains );
465  foreach ( $CORSSources as $source ) {
466  if ( strpos( $source, '?' ) !== false ) {
467  // CSP doesn't support single char wildcard
468  continue;
469  }
470  $url = $this->prepareUrlForCSP( $source );
471  if ( $url ) {
472  $additionalUrls[] = $url;
473  }
474  }
475  return $additionalUrls;
476  }
477 
485  private function escapeUrlForCSP( $url ) {
486  return str_replace(
487  [ ';', ',' ],
488  [ '%3B', '%2C' ],
489  $url
490  );
491  }
492 
503  public static function falsePositiveBrowser( $ua ) {
504  return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
505  }
506 
513  public static function isNonceRequired( Config $config ) {
514  $configs = [
515  $config->get( MainConfigNames::CSPHeader ),
516  $config->get( MainConfigNames::CSPReportOnlyHeader ),
517  ];
518  return self::isNonceRequiredArray( $configs );
519  }
520 
527  private static function isNonceRequiredArray( array $configs ) {
528  foreach ( $configs as $headerConfig ) {
529  if (
530  is_array( $headerConfig ) &&
531  isset( $headerConfig['useNonces'] ) &&
532  $headerConfig['useNonces']
533  ) {
534  return true;
535  }
536  }
537  return false;
538  }
539 
548  public function getNonce() {
549  return false;
550  }
551 
562  public function addDefaultSrc( $source ) {
563  $this->extraDefaultSrc[] = $this->prepareUrlForCSP( $source );
564  }
565 
574  public function addStyleSrc( $source ) {
575  $this->extraStyleSrc[] = $this->prepareUrlForCSP( $source );
576  }
577 
587  public function addScriptSrc( $source ) {
588  $this->extraScriptSrc[] = $this->prepareUrlForCSP( $source );
589  }
590 }
591 
595 class_alias( ContentSecurityPolicy::class, 'ContentSecurityPolicy' );
wfParseUrl( $url)
parse_url() work-alike, but non-broken.
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...
Definition: HookRunner.php:568
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 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 EnableImageWhitelist
Name constant for the EnableImageWhitelist setting, for use with Config::get()
const AllowImageTag
Name constant for the AllowImageTag 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?
static isNonceRequired(Config $config)
Should we set nonce attribute.
addStyleSrc( $source)
So for example, if an extension added a special page that loaded external CSS it might call $this->ge...
addDefaultSrc( $source)
If possible you should use a more specific source type then default.
sendHeaders()
Send CSP headers based on wiki config.
addScriptSrc( $source)
So for example, if an extension added a special page that loaded something it might call $this->getOu...
sendCSPHeader( $csp, $reportOnly)
Send a single CSP header based on a given policy config.
__construct(WebResponse $response, Config $mwConfig, HookContainer $hookContainer)
Allow programs to request this object from WebRequest::response() and handle all outputting (or lack ...
Definition: WebResponse.php:36
Interface for configuration instances.
Definition: Config.php:32
$source