MediaWiki  master
ContentSecurityPolicy.php
Go to the documentation of this file.
1 <?php
29 
31  public const REPORT_ONLY_MODE = 1;
32  public const FULL_MODE = 2;
33 
35  private $nonce;
37  private $mwConfig;
39  private $response;
40 
42  private $extraDefaultSrc = [];
44  private $extraScriptSrc = [];
46  private $extraStyleSrc = [];
47 
57  $this->response = $response;
58  $this->mwConfig = $mwConfig;
59  }
60 
69  public function sendCSPHeader( $csp, $reportOnly ) {
70  $policy = $this->makeCSPDirectives( $csp, $reportOnly );
71  $headerName = $this->getHeaderName( $reportOnly );
72  if ( $policy ) {
73  $this->response->header(
74  "$headerName: $policy"
75  );
76  }
77  }
78 
88  public function sendHeaders() {
89  $cspConfig = $this->mwConfig->get( 'CSPHeader' );
90  $cspConfigReportOnly = $this->mwConfig->get( 'CSPReportOnlyHeader' );
91 
92  $this->sendCSPHeader( $cspConfig, self::FULL_MODE );
93  $this->sendCSPHeader( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
94 
95  // This used to insert a <meta> tag here, per advice at
96  // https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/
97  // The goal was to prevent nonce from working after the page hit onready,
98  // This would help in old browsers that didn't support nonces, and
99  // also assist for varnish-cached pages which repeat nonces.
100  // However, this is incompatible with how resource loader storage works
101  // via mw.domEval() so it was removed.
102  }
103 
111  private function getHeaderName( $reportOnly ) {
112  if ( $reportOnly === self::REPORT_ONLY_MODE ) {
113  return 'Content-Security-Policy-Report-Only';
114  }
115 
116  if ( $reportOnly === self::FULL_MODE ) {
117  return 'Content-Security-Policy';
118  }
119  throw new UnexpectedValueException( "Mode '$reportOnly' not recognised" );
120  }
121 
130  private function makeCSPDirectives( $policyConfig, $mode ) {
131  if ( $policyConfig === false ) {
132  // CSP is disabled
133  return '';
134  }
135  if ( $policyConfig === true ) {
136  $policyConfig = [];
137  }
138 
140 
141  if (
142  !self::isNonceRequired( $mwConfig ) &&
143  self::isNonceRequiredArray( [ $policyConfig ] )
144  ) {
145  // If the current policy requires a nonce, but the global state
146  // does not, that's bad. Throw an exception. This should never happen.
147  throw new LogicException( "Nonce requirement mismatch" );
148  }
149 
150  $additionalSelfUrls = $this->getAdditionalSelfUrls();
151  $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
152 
153  // If no default-src is sent at all, it
154  // seems browsers (or at least some), interpret
155  // that as allow anything, but the spec seems
156  // to imply that data: and blob: should be
157  // blocked.
158  $defaultSrc = [ '*', 'data:', 'blob:' ];
159 
160  $imgSrc = false;
161  $scriptSrc = [ "'unsafe-eval'", "blob:", "'self'" ];
162  if ( $policyConfig['useNonces'] ?? true ) {
163  $scriptSrc[] = "'nonce-" . $this->getNonce() . "'";
164  }
165 
166  $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
167  if ( isset( $policyConfig['script-src'] )
168  && is_array( $policyConfig['script-src'] )
169  ) {
170  foreach ( $policyConfig['script-src'] as $src ) {
171  $scriptSrc[] = $this->escapeUrlForCSP( $src );
172  }
173  }
174  // Note: default on if unspecified.
175  if ( $policyConfig['unsafeFallback'] ?? true ) {
176  // unsafe-inline should be ignored on browsers
177  // that support 'nonce-foo' sources.
178  // Some older versions of firefox don't follow this
179  // rule, but new browsers do. (Should be for at least
180  // firefox 40+).
181  $scriptSrc[] = "'unsafe-inline'";
182  }
183  // If default source option set to true or
184  // an array of urls, set a restrictive default-src.
185  // If set to false, we send a lenient default-src,
186  // see the code above where $defaultSrc is set initially.
187  if ( isset( $policyConfig['default-src'] )
188  && $policyConfig['default-src'] !== false
189  ) {
190  $defaultSrc = array_merge(
191  [ "'self'", 'data:', 'blob:' ],
192  $additionalSelfUrls
193  );
194  if ( is_array( $policyConfig['default-src'] ) ) {
195  foreach ( $policyConfig['default-src'] as $src ) {
196  $defaultSrc[] = $this->escapeUrlForCSP( $src );
197  }
198  }
199  }
200 
201  if ( $policyConfig['includeCORS'] ?? true ) {
202  $CORSUrls = $this->getCORSSources();
203  if ( !in_array( '*', $defaultSrc ) ) {
204  $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
205  }
206  // Unlikely to have * in scriptSrc, but doesn't
207  // hurt to check.
208  if ( !in_array( '*', $scriptSrc ) ) {
209  $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
210  }
211  }
212 
213  $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
214  $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
215 
216  $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [ "'unsafe-inline'" ] );
217 
218  Hooks::run( 'ContentSecurityPolicyDefaultSource', [ &$defaultSrc, $policyConfig, $mode ] );
219  Hooks::run( 'ContentSecurityPolicyScriptSource', [ &$scriptSrc, $policyConfig, $mode ] );
220 
221  if ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) {
222  if ( $policyConfig['report-uri'] === false ) {
223  $reportUri = false;
224  } else {
225  $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] );
226  }
227  } else {
228  $reportUri = $this->getReportUri( $mode );
229  }
230 
231  // Only send an img-src, if we're sending a restricitve default.
232  if ( !is_array( $defaultSrc )
233  || !in_array( '*', $defaultSrc )
234  || !in_array( 'data:', $defaultSrc )
235  || !in_array( 'blob:', $defaultSrc )
236  ) {
237  // A future todo might be to make the whitelist options only
238  // add all the whitelisted sites to the header, instead of
239  // allowing all (Assuming there is a small number of sites).
240  // For now, the external image feature disables the limits
241  // CSP puts on external images.
242  if ( $mwConfig->get( 'AllowExternalImages' )
243  || $mwConfig->get( 'AllowExternalImagesFrom' )
244  || $mwConfig->get( 'AllowImageTag' )
245  ) {
246  $imgSrc = [ '*', 'data:', 'blob:' ];
247  } elseif ( $mwConfig->get( 'EnableImageWhitelist' ) ) {
248  $whitelist = wfMessage( 'external_image_whitelist' )
249  ->inContentLanguage()
250  ->plain();
251  if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) {
252  $imgSrc = [ '*', 'data:', 'blob:' ];
253  }
254  }
255  }
256  // Default value 'none'. true is none, false is nothing, string is single directive,
257  // array is list.
258  if ( !isset( $policyConfig['object-src'] ) || $policyConfig['object-src'] === true ) {
259  $objectSrc = [ "'none'" ];
260  } else {
261  $objectSrc = (array)( $policyConfig['object-src'] ?: [] );
262  }
263  $objectSrc = array_map( [ $this, 'escapeUrlForCSP' ], $objectSrc );
264 
265  $directives = [];
266  if ( $scriptSrc ) {
267  $directives[] = 'script-src ' . implode( ' ', array_unique( $scriptSrc ) );
268  }
269  if ( $defaultSrc ) {
270  $directives[] = 'default-src ' . implode( ' ', array_unique( $defaultSrc ) );
271  }
272  if ( $cssSrc ) {
273  $directives[] = 'style-src ' . implode( ' ', array_unique( $cssSrc ) );
274  }
275  if ( $imgSrc ) {
276  $directives[] = 'img-src ' . implode( ' ', array_unique( $imgSrc ) );
277  }
278  if ( $objectSrc ) {
279  $directives[] = 'object-src ' . implode( ' ', $objectSrc );
280  }
281  if ( $reportUri ) {
282  $directives[] = 'report-uri ' . $reportUri;
283  }
284 
285  Hooks::run( 'ContentSecurityPolicyDirectives', [ &$directives, $policyConfig, $mode ] );
286 
287  return implode( '; ', $directives );
288  }
289 
297  private function getReportUri( $mode ) {
298  $apiArguments = [
299  'action' => 'cspreport',
300  'format' => 'json'
301  ];
302  if ( $mode === self::REPORT_ONLY_MODE ) {
303  $apiArguments['reportonly'] = '1';
304  }
305  $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments );
306 
307  // Per spec, ';' and ',' must be hex-escaped in report URI
308  $reportUri = $this->escapeUrlForCSP( $reportUri );
309  return $reportUri;
310  }
311 
327  private function prepareUrlForCSP( $url ) {
328  $result = false;
329  if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
330  // A schema source (e.g. blob: or data:)
331  return $url;
332  }
333  $bits = wfParseUrl( $url );
334  if ( !$bits && strpos( $url, '/' ) === false ) {
335  // probably something like example.com.
336  // try again protocol-relative.
337  $url = '//' . $url;
338  $bits = wfParseUrl( $url );
339  }
340  if ( $bits && isset( $bits['host'] )
341  && $bits['host'] !== $this->mwConfig->get( 'ServerName' )
342  ) {
343  $result = $bits['host'];
344  if ( $bits['scheme'] !== '' ) {
345  $result = $bits['scheme'] . $bits['delimiter'] . $result;
346  }
347  if ( isset( $bits['port'] ) ) {
348  $result .= ':' . $bits['port'];
349  }
350  $result = $this->escapeUrlForCSP( $result );
351  }
352  return $result;
353  }
354 
360  private function getAdditionalSelfUrlsScript() {
361  $additionalUrls = [];
362  // wgExtensionAssetsPath for ?debug=true mode
363  $pathVars = [ 'LoadScript', 'ExtensionAssetsPath', 'ResourceBasePath' ];
364 
365  foreach ( $pathVars as $path ) {
366  $url = $this->mwConfig->get( $path );
367  $preparedUrl = $this->prepareUrlForCSP( $url );
368  if ( $preparedUrl ) {
369  $additionalUrls[] = $preparedUrl;
370  }
371  }
372  $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
373  foreach ( $RLSources as $wiki => $sources ) {
374  foreach ( $sources as $id => $value ) {
375  $url = $this->prepareUrlForCSP( $value );
376  if ( $url ) {
377  $additionalUrls[] = $url;
378  }
379  }
380  }
381 
382  return array_unique( $additionalUrls );
383  }
384 
391  private function getAdditionalSelfUrls() {
392  // XXX on a foreign repo, the included description page can have anything on it,
393  // including inline scripts. But nobody sane does that.
394 
395  // In principle, you can have even more complex configs... (e.g. The urlsByExt option)
396  $pathUrls = [];
397  $additionalSelfUrls = [];
398 
399  // Future todo: The zone urls should never go into
400  // style-src. They should either be only in img-src, or if
401  // img-src unspecified they should be in default-src. Similarly,
402  // the DescriptionStylesheetUrl only needs to be in style-src
403  // (or default-src if style-src unspecified).
404  $callback = function ( $repo, &$urls ) {
405  $urls[] = $repo->getZoneUrl( 'public' );
406  $urls[] = $repo->getZoneUrl( 'transcoded' );
407  $urls[] = $repo->getZoneUrl( 'thumb' );
408  $urls[] = $repo->getDescriptionStylesheetUrl();
409  };
410  $repoGroup = MediaWikiServices::getInstance()->getRepoGroup();
411  $localRepo = $repoGroup->getRepo( 'local' );
412  $callback( $localRepo, $pathUrls );
413  $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] );
414 
415  // Globals that might point to a different domain
416  $pathGlobals = [ 'LoadScript', 'ExtensionAssetsPath', 'StylePath', 'ResourceBasePath' ];
417  foreach ( $pathGlobals as $path ) {
418  $pathUrls[] = $this->mwConfig->get( $path );
419  }
420  foreach ( $pathUrls as $path ) {
421  $preparedUrl = $this->prepareUrlForCSP( $path );
422  if ( $preparedUrl !== false ) {
423  $additionalSelfUrls[] = $preparedUrl;
424  }
425  }
426  $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
427 
428  foreach ( $RLSources as $wiki => $sources ) {
429  foreach ( $sources as $id => $value ) {
430  $url = $this->prepareUrlForCSP( $value );
431  if ( $url ) {
432  $additionalSelfUrls[] = $url;
433  }
434  }
435  }
436 
437  return array_unique( $additionalSelfUrls );
438  }
439 
452  private function getCORSSources() {
453  $additionalUrls = [];
454  $CORSSources = $this->mwConfig->get( 'CrossSiteAJAXdomains' );
455  foreach ( $CORSSources as $source ) {
456  if ( strpos( $source, '?' ) !== false ) {
457  // CSP doesn't support single char wildcard
458  continue;
459  }
460  $url = $this->prepareUrlForCSP( $source );
461  if ( $url ) {
462  $additionalUrls[] = $url;
463  }
464  }
465  return $additionalUrls;
466  }
467 
475  private function escapeUrlForCSP( $url ) {
476  return str_replace(
477  [ ';', ',' ],
478  [ '%3B', '%2C' ],
479  $url
480  );
481  }
482 
493  public static function falsePositiveBrowser( $ua ) {
494  return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
495  }
496 
503  public static function isNonceRequired( Config $config ) {
504  $configs = [
505  $config->get( 'CSPHeader' ),
506  $config->get( 'CSPReportOnlyHeader' )
507  ];
508  return self::isNonceRequiredArray( $configs );
509  }
510 
517  private static function isNonceRequiredArray( array $configs ) {
518  foreach ( $configs as $headerConfig ) {
519  if (
520  $headerConfig === true ||
521  ( is_array( $headerConfig ) &&
522  !isset( $headerConfig['useNonces'] ) ) ||
523  ( is_array( $headerConfig ) &&
524  isset( $headerConfig['useNonces'] ) &&
525  $headerConfig['useNonces'] )
526  ) {
527  return true;
528  }
529  }
530  return false;
531  }
532 
539  public function getNonce() {
540  if ( !self::isNonceRequired( $this->mwConfig ) ) {
541  return false;
542  }
543  if ( $this->nonce === null ) {
544  $rand = random_bytes( 15 );
545  $this->nonce = base64_encode( $rand );
546  }
547 
548  return $this->nonce;
549  }
550 
563  public function addDefaultSrc( $source ) {
564  $this->extraDefaultSrc[] = $this->prepareUrlForCSP( $source );
565  }
566 
577  public function addStyleSrc( $source ) {
578  $this->extraStyleSrc[] = $this->prepareUrlForCSP( $source );
579  }
580 
592  public function addScriptSrc( $source ) {
593  $this->extraScriptSrc[] = $this->prepareUrlForCSP( $source );
594  }
595 }
ContentSecurityPolicy\$response
WebResponse $response
Definition: ContentSecurityPolicy.php:39
ContentSecurityPolicy\sendCSPHeader
sendCSPHeader( $csp, $reportOnly)
Send a single CSP header based on a given policy config.
Definition: ContentSecurityPolicy.php:69
ContentSecurityPolicy\isNonceRequiredArray
static isNonceRequiredArray(array $configs)
Does a specific config require a nonce.
Definition: ContentSecurityPolicy.php:517
MediaWiki\MediaWikiServices
MediaWikiServices is the service locator for the application scope of MediaWiki.
Definition: MediaWikiServices.php:144
ContentSecurityPolicy\$extraStyleSrc
array $extraStyleSrc
Definition: ContentSecurityPolicy.php:46
ContentSecurityPolicy\REPORT_ONLY_MODE
const REPORT_ONLY_MODE
Definition: ContentSecurityPolicy.php:31
ContentSecurityPolicy\$extraDefaultSrc
array $extraDefaultSrc
Definition: ContentSecurityPolicy.php:42
ContentSecurityPolicy\falsePositiveBrowser
static falsePositiveBrowser( $ua)
Does this browser give false positive reports?
Definition: ContentSecurityPolicy.php:493
wfMessage
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
Definition: GlobalFunctions.php:1198
ContentSecurityPolicy\getAdditionalSelfUrls
getAdditionalSelfUrls()
Get additional host names for the wiki (e.g.
Definition: ContentSecurityPolicy.php:391
wfAppendQuery
wfAppendQuery( $url, $query)
Append a query string to an existing URL, which may or may not already have query string parameters a...
Definition: GlobalFunctions.php:439
Config
Interface for configuration instances.
Definition: Config.php:28
ContentSecurityPolicy\getCORSSources
getCORSSources()
include domains that are allowed to send us CORS requests.
Definition: ContentSecurityPolicy.php:452
wfParseUrl
wfParseUrl( $url)
parse_url() work-alike, but non-broken.
Definition: GlobalFunctions.php:793
ContentSecurityPolicy\__construct
__construct(WebResponse $response, Config $mwConfig)
Definition: ContentSecurityPolicy.php:56
ContentSecurityPolicy\getNonce
getNonce()
Get the nonce if nonce is in use.
Definition: ContentSecurityPolicy.php:539
wfScript
wfScript( $script='index')
Get the path to a specified script file, respecting file extensions; this is a wrapper around $wgScri...
Definition: GlobalFunctions.php:2530
Config\get
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
ContentSecurityPolicy\addScriptSrc
addScriptSrc( $source)
Add an additional script src.
Definition: ContentSecurityPolicy.php:592
ContentSecurityPolicy\escapeUrlForCSP
escapeUrlForCSP( $url)
CSP spec says ',' and ';' are not allowed to appear in urls.
Definition: ContentSecurityPolicy.php:475
ContentSecurityPolicy\$mwConfig
Config $mwConfig
The site configuration object.
Definition: ContentSecurityPolicy.php:37
ContentSecurityPolicy\getAdditionalSelfUrlsScript
getAdditionalSelfUrlsScript()
Get additional script sources.
Definition: ContentSecurityPolicy.php:360
$urls
$urls
Definition: opensearch_desc.php:82
ContentSecurityPolicy\FULL_MODE
const FULL_MODE
Definition: ContentSecurityPolicy.php:32
ContentSecurityPolicy\getReportUri
getReportUri( $mode)
Get the default report uri.
Definition: ContentSecurityPolicy.php:297
ContentSecurityPolicy\$nonce
string $nonce
The nonce to use for inline scripts (from OutputPage)
Definition: ContentSecurityPolicy.php:35
ContentSecurityPolicy\isNonceRequired
static isNonceRequired(Config $config)
Should we set nonce attribute.
Definition: ContentSecurityPolicy.php:503
ContentSecurityPolicy\prepareUrlForCSP
prepareUrlForCSP( $url)
Given a url, convert to form needed for CSP.
Definition: ContentSecurityPolicy.php:327
$path
$path
Definition: NoLocalSettings.php:25
ContentSecurityPolicy\$extraScriptSrc
array $extraScriptSrc
Definition: ContentSecurityPolicy.php:44
$source
$source
Definition: mwdoc-filter.php:34
ContentSecurityPolicy\makeCSPDirectives
makeCSPDirectives( $policyConfig, $mode)
Determine what CSP policies to set for this page.
Definition: ContentSecurityPolicy.php:130
WebResponse
Allow programs to request this object from WebRequest::response() and handle all outputting (or lack ...
Definition: WebResponse.php:28
ContentSecurityPolicy\addDefaultSrc
addDefaultSrc( $source)
Add an additional default src.
Definition: ContentSecurityPolicy.php:563
Hooks\run
static run( $event, array $args=[], $deprecatedVersion=null)
Call hook functions defined in Hooks::register and $wgHooks.
Definition: Hooks.php:133
ContentSecurityPolicy
Definition: ContentSecurityPolicy.php:30
ContentSecurityPolicy\addStyleSrc
addStyleSrc( $source)
Add an additional CSS src.
Definition: ContentSecurityPolicy.php:577
ContentSecurityPolicy\sendHeaders
sendHeaders()
Send CSP headers based on wiki config.
Definition: ContentSecurityPolicy.php:88
ContentSecurityPolicy\getHeaderName
getHeaderName( $reportOnly)
Get the name of the HTTP header to use.
Definition: ContentSecurityPolicy.php:111