MediaWiki  master
ContentSecurityPolicy.php
Go to the documentation of this file.
1 <?php
29 
30 use Config;
31 use LogicException;
36 use UnexpectedValueException;
37 
39  public const REPORT_ONLY_MODE = 1;
40  public const FULL_MODE = 2;
41 
43  private $nonce;
45  private $mwConfig;
47  private $response;
48 
50  private $extraDefaultSrc = [];
52  private $extraScriptSrc = [];
54  private $extraStyleSrc = [];
55 
57  private $hookRunner;
58 
68  public function __construct(
69  WebResponse $response,
70  Config $mwConfig,
71  HookContainer $hookContainer
72  ) {
73  $this->response = $response;
74  $this->mwConfig = $mwConfig;
75  $this->hookRunner = new HookRunner( $hookContainer );
76  }
77 
86  public function sendCSPHeader( $csp, $reportOnly ) {
87  $policy = $this->makeCSPDirectives( $csp, $reportOnly );
88  $headerName = $this->getHeaderName( $reportOnly );
89  if ( $policy ) {
90  $this->response->header(
91  "$headerName: $policy"
92  );
93  }
94  }
95 
105  public function sendHeaders() {
106  $cspConfig = $this->mwConfig->get( MainConfigNames::CSPHeader );
107  $cspConfigReportOnly = $this->mwConfig->get( MainConfigNames::CSPReportOnlyHeader );
108 
109  $this->sendCSPHeader( $cspConfig, self::FULL_MODE );
110  $this->sendCSPHeader( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
111 
112  // This used to insert a <meta> tag here, per advice at
113  // https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/
114  // The goal was to prevent nonce from working after the page hit onready,
115  // This would help in old browsers that didn't support nonces, and
116  // also assist for varnish-cached pages which repeat nonces.
117  // However, this is incompatible with how resource loader storage works
118  // via mw.domEval() so it was removed.
119  }
120 
126  private function getHeaderName( $reportOnly ) {
127  if ( $reportOnly === self::REPORT_ONLY_MODE ) {
128  return 'Content-Security-Policy-Report-Only';
129  }
130 
131  if ( $reportOnly === self::FULL_MODE ) {
132  return 'Content-Security-Policy';
133  }
134  throw new UnexpectedValueException( "Mode '$reportOnly' not recognised" );
135  }
136 
145  private function makeCSPDirectives( $policyConfig, $mode ) {
146  if ( $policyConfig === false ) {
147  // CSP is disabled
148  return '';
149  }
150  if ( $policyConfig === true ) {
151  $policyConfig = [];
152  }
153 
154  $mwConfig = $this->mwConfig;
155 
156  if (
157  !self::isNonceRequired( $mwConfig ) &&
158  self::isNonceRequiredArray( [ $policyConfig ] )
159  ) {
160  // If the current policy requires a nonce, but the global state
161  // does not, that's bad. Throw an exception. This should never happen.
162  throw new LogicException( "Nonce requirement mismatch" );
163  }
164 
165  $additionalSelfUrls = $this->getAdditionalSelfUrls();
166  $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
167 
168  // If no default-src is sent at all, it
169  // seems browsers (or at least some), interpret
170  // that as allow anything, but the spec seems
171  // to imply that data: and blob: should be
172  // blocked.
173  $defaultSrc = [ '*', 'data:', 'blob:' ];
174 
175  $imgSrc = false;
176  $scriptSrc = [ "'unsafe-eval'", "blob:", "'self'" ];
177  if ( $policyConfig['useNonces'] ?? true ) {
178  $scriptSrc[] = "'nonce-" . $this->getNonce() . "'";
179  }
180 
181  $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
182  if ( isset( $policyConfig['script-src'] )
183  && is_array( $policyConfig['script-src'] )
184  ) {
185  foreach ( $policyConfig['script-src'] as $src ) {
186  $scriptSrc[] = $this->escapeUrlForCSP( $src );
187  }
188  }
189  // Note: default on if unspecified.
190  if ( $policyConfig['unsafeFallback'] ?? true ) {
191  // unsafe-inline should be ignored on browsers
192  // that support 'nonce-foo' sources.
193  // Some older versions of firefox don't follow this
194  // rule, but new browsers do. (Should be for at least
195  // firefox 40+).
196  $scriptSrc[] = "'unsafe-inline'";
197  }
198  // If default source option set to true or
199  // an array of urls, set a restrictive default-src.
200  // If set to false, we send a lenient default-src,
201  // see the code above where $defaultSrc is set initially.
202  if ( isset( $policyConfig['default-src'] )
203  && $policyConfig['default-src'] !== false
204  ) {
205  $defaultSrc = array_merge(
206  [ "'self'", 'data:', 'blob:' ],
207  $additionalSelfUrls
208  );
209  if ( is_array( $policyConfig['default-src'] ) ) {
210  foreach ( $policyConfig['default-src'] as $src ) {
211  $defaultSrc[] = $this->escapeUrlForCSP( $src );
212  }
213  }
214  }
215 
216  if ( $policyConfig['includeCORS'] ?? true ) {
217  $CORSUrls = $this->getCORSSources();
218  if ( !in_array( '*', $defaultSrc ) ) {
219  $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
220  }
221  // Unlikely to have * in scriptSrc, but doesn't
222  // hurt to check.
223  if ( !in_array( '*', $scriptSrc ) ) {
224  $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
225  }
226  }
227 
228  $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
229  $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
230 
231  $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [ "'unsafe-inline'" ] );
232 
233  $this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode );
234  $this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode );
235 
236  if ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) {
237  if ( $policyConfig['report-uri'] === false ) {
238  $reportUri = false;
239  } else {
240  $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] );
241  }
242  } else {
243  $reportUri = $this->getReportUri( $mode );
244  }
245 
246  // Only send an img-src, if we're sending a restrictive default.
247  if ( !is_array( $defaultSrc )
248  || !in_array( '*', $defaultSrc )
249  || !in_array( 'data:', $defaultSrc )
250  || !in_array( 'blob:', $defaultSrc )
251  ) {
252  // A future todo might be to make the allow options only
253  // add all the allowed sites to the header, instead of
254  // allowing all (Assuming there is a small number of sites).
255  // For now, the external image feature disables the limits
256  // CSP puts on external images.
257  if ( $mwConfig->get( MainConfigNames::AllowExternalImages )
259  || $mwConfig->get( MainConfigNames::AllowImageTag )
260  ) {
261  $imgSrc = [ '*', 'data:', 'blob:' ];
262  } elseif ( $mwConfig->get( MainConfigNames::EnableImageWhitelist ) ) {
263  $whitelist = wfMessage( 'external_image_whitelist' )
264  ->inContentLanguage()
265  ->plain();
266  if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) {
267  $imgSrc = [ '*', 'data:', 'blob:' ];
268  }
269  }
270  }
271  // Default value 'none'. true is none, false is nothing, string is single directive,
272  // array is list.
273  if ( !isset( $policyConfig['object-src'] ) || $policyConfig['object-src'] === true ) {
274  $objectSrc = [ "'none'" ];
275  } else {
276  $objectSrc = (array)( $policyConfig['object-src'] ?: [] );
277  }
278  $objectSrc = array_map( [ $this, 'escapeUrlForCSP' ], $objectSrc );
279 
280  $directives = [];
281  if ( $scriptSrc ) {
282  $directives[] = 'script-src ' . implode( ' ', array_unique( $scriptSrc ) );
283  }
284  if ( $defaultSrc ) {
285  $directives[] = 'default-src ' . implode( ' ', array_unique( $defaultSrc ) );
286  }
287  if ( $cssSrc ) {
288  $directives[] = 'style-src ' . implode( ' ', array_unique( $cssSrc ) );
289  }
290  if ( $imgSrc ) {
291  $directives[] = 'img-src ' . implode( ' ', array_unique( $imgSrc ) );
292  }
293  if ( $objectSrc ) {
294  $directives[] = 'object-src ' . implode( ' ', $objectSrc );
295  }
296  if ( $reportUri ) {
297  $directives[] = 'report-uri ' . $reportUri;
298  }
299 
300  $this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode );
301 
302  return implode( '; ', $directives );
303  }
304 
312  private function getReportUri( $mode ) {
313  $apiArguments = [
314  'action' => 'cspreport',
315  'format' => 'json'
316  ];
317  if ( $mode === self::REPORT_ONLY_MODE ) {
318  $apiArguments['reportonly'] = '1';
319  }
320  $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments );
321 
322  // Per spec, ';' and ',' must be hex-escaped in report URI
323  $reportUri = $this->escapeUrlForCSP( $reportUri );
324  return $reportUri;
325  }
326 
342  private function prepareUrlForCSP( $url ) {
343  $result = false;
344  if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
345  // A schema source (e.g. blob: or data:)
346  return $url;
347  }
348  $bits = wfParseUrl( $url );
349  if ( !$bits && strpos( $url, '/' ) === false ) {
350  // probably something like example.com.
351  // try again protocol-relative.
352  $url = '//' . $url;
353  $bits = wfParseUrl( $url );
354  }
355  if ( $bits && isset( $bits['host'] )
356  && $bits['host'] !== $this->mwConfig->get( MainConfigNames::ServerName )
357  ) {
358  $result = $bits['host'];
359  if ( $bits['scheme'] !== '' ) {
360  $result = $bits['scheme'] . $bits['delimiter'] . $result;
361  }
362  if ( isset( $bits['port'] ) ) {
363  $result .= ':' . $bits['port'];
364  }
365  $result = $this->escapeUrlForCSP( $result );
366  }
367  return $result;
368  }
369 
373  private function getAdditionalSelfUrlsScript() {
374  $additionalUrls = [];
375  // wgExtensionAssetsPath for ?debug=true mode
376  $pathVars = [
380  ];
381 
382  foreach ( $pathVars as $path ) {
383  $url = $this->mwConfig->get( $path );
384  $preparedUrl = $this->prepareUrlForCSP( $url );
385  if ( $preparedUrl ) {
386  $additionalUrls[] = $preparedUrl;
387  }
388  }
389  $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
390  foreach ( $RLSources as $sources ) {
391  foreach ( $sources as $value ) {
392  $url = $this->prepareUrlForCSP( $value );
393  if ( $url ) {
394  $additionalUrls[] = $url;
395  }
396  }
397  }
398 
399  return array_unique( $additionalUrls );
400  }
401 
408  private function getAdditionalSelfUrls() {
409  // XXX on a foreign repo, the included description page can have anything on it,
410  // including inline scripts. But nobody does that.
411 
412  // In principle, you can have even more complex configs... (e.g. The urlsByExt option)
413  $pathUrls = [];
414  $additionalSelfUrls = [];
415 
416  // Future todo: The zone urls should never go into
417  // style-src. They should either be only in img-src, or if
418  // img-src unspecified they should be in default-src. Similarly,
419  // the DescriptionStylesheetUrl only needs to be in style-src
420  // (or default-src if style-src unspecified).
421  $callback = static function ( $repo, &$urls ) {
422  $urls[] = $repo->getZoneUrl( 'public' );
423  $urls[] = $repo->getZoneUrl( 'transcoded' );
424  $urls[] = $repo->getZoneUrl( 'thumb' );
425  $urls[] = $repo->getDescriptionStylesheetUrl();
426  };
427  $repoGroup = MediaWikiServices::getInstance()->getRepoGroup();
428  $localRepo = $repoGroup->getRepo( 'local' );
429  $callback( $localRepo, $pathUrls );
430  $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] );
431 
432  // Globals that might point to a different domain
433  $pathGlobals = [
438  ];
439  foreach ( $pathGlobals as $path ) {
440  $pathUrls[] = $this->mwConfig->get( $path );
441  }
442  foreach ( $pathUrls as $path ) {
443  $preparedUrl = $this->prepareUrlForCSP( $path );
444  if ( $preparedUrl !== false ) {
445  $additionalSelfUrls[] = $preparedUrl;
446  }
447  }
448  $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
449 
450  foreach ( $RLSources as $sources ) {
451  foreach ( $sources as $value ) {
452  $url = $this->prepareUrlForCSP( $value );
453  if ( $url ) {
454  $additionalSelfUrls[] = $url;
455  }
456  }
457  }
458 
459  return array_unique( $additionalSelfUrls );
460  }
461 
474  private function getCORSSources() {
475  $additionalUrls = [];
476  $CORSSources = $this->mwConfig->get( MainConfigNames::CrossSiteAJAXdomains );
477  foreach ( $CORSSources as $source ) {
478  if ( strpos( $source, '?' ) !== false ) {
479  // CSP doesn't support single char wildcard
480  continue;
481  }
482  $url = $this->prepareUrlForCSP( $source );
483  if ( $url ) {
484  $additionalUrls[] = $url;
485  }
486  }
487  return $additionalUrls;
488  }
489 
497  private function escapeUrlForCSP( $url ) {
498  return str_replace(
499  [ ';', ',' ],
500  [ '%3B', '%2C' ],
501  $url
502  );
503  }
504 
515  public static function falsePositiveBrowser( $ua ) {
516  return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
517  }
518 
525  public static function isNonceRequired( Config $config ) {
526  $configs = [
527  $config->get( MainConfigNames::CSPHeader ),
529  ];
530  return self::isNonceRequiredArray( $configs );
531  }
532 
539  private static function isNonceRequiredArray( array $configs ) {
540  foreach ( $configs as $headerConfig ) {
541  if (
542  $headerConfig === true ||
543  ( is_array( $headerConfig ) &&
544  !isset( $headerConfig['useNonces'] ) ) ||
545  ( is_array( $headerConfig ) &&
546  isset( $headerConfig['useNonces'] ) &&
547  $headerConfig['useNonces'] )
548  ) {
549  return true;
550  }
551  }
552  return false;
553  }
554 
561  public function getNonce() {
562  if ( !self::isNonceRequired( $this->mwConfig ) ) {
563  return false;
564  }
565  $this->nonce ??= base64_encode( random_bytes( 15 ) );
566 
567  return $this->nonce;
568  }
569 
580  public function addDefaultSrc( $source ) {
581  $this->extraDefaultSrc[] = $this->prepareUrlForCSP( $source );
582  }
583 
592  public function addStyleSrc( $source ) {
593  $this->extraStyleSrc[] = $this->prepareUrlForCSP( $source );
594  }
595 
605  public function addScriptSrc( $source ) {
606  $this->extraScriptSrc[] = $this->prepareUrlForCSP( $source );
607  }
608 }
609 
610 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 path to a specified script file, respecting file extensions; this is a wrapper around $wgScri...
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:565
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.
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:30
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
$source