MediaWiki  master
ContentSecurityPolicy.php
Go to the documentation of this file.
1 <?php
32 
34  public const REPORT_ONLY_MODE = 1;
35  public const FULL_MODE = 2;
36 
38  private $nonce;
40  private $mwConfig;
42  private $response;
43 
45  private $extraDefaultSrc = [];
47  private $extraScriptSrc = [];
49  private $extraStyleSrc = [];
50 
52  private $hookRunner;
53 
63  public function __construct( WebResponse $response, Config $mwConfig,
64  HookContainer $hookContainer
65  ) {
66  $this->response = $response;
67  $this->mwConfig = $mwConfig;
68  $this->hookRunner = new HookRunner( $hookContainer );
69  }
70 
79  public function sendCSPHeader( $csp, $reportOnly ) {
80  $policy = $this->makeCSPDirectives( $csp, $reportOnly );
81  $headerName = $this->getHeaderName( $reportOnly );
82  if ( $policy ) {
83  $this->response->header(
84  "$headerName: $policy"
85  );
86  }
87  }
88 
98  public function sendHeaders() {
99  $cspConfig = $this->mwConfig->get( MainConfigNames::CSPHeader );
100  $cspConfigReportOnly = $this->mwConfig->get( MainConfigNames::CSPReportOnlyHeader );
101 
102  $this->sendCSPHeader( $cspConfig, self::FULL_MODE );
103  $this->sendCSPHeader( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
104 
105  // This used to insert a <meta> tag here, per advice at
106  // https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/
107  // The goal was to prevent nonce from working after the page hit onready,
108  // This would help in old browsers that didn't support nonces, and
109  // also assist for varnish-cached pages which repeat nonces.
110  // However, this is incompatible with how resource loader storage works
111  // via mw.domEval() so it was removed.
112  }
113 
119  private function getHeaderName( $reportOnly ) {
120  if ( $reportOnly === self::REPORT_ONLY_MODE ) {
121  return 'Content-Security-Policy-Report-Only';
122  }
123 
124  if ( $reportOnly === self::FULL_MODE ) {
125  return 'Content-Security-Policy';
126  }
127  throw new UnexpectedValueException( "Mode '$reportOnly' not recognised" );
128  }
129 
138  private function makeCSPDirectives( $policyConfig, $mode ) {
139  if ( $policyConfig === false ) {
140  // CSP is disabled
141  return '';
142  }
143  if ( $policyConfig === true ) {
144  $policyConfig = [];
145  }
146 
147  $mwConfig = $this->mwConfig;
148 
149  if (
150  !self::isNonceRequired( $mwConfig ) &&
151  self::isNonceRequiredArray( [ $policyConfig ] )
152  ) {
153  // If the current policy requires a nonce, but the global state
154  // does not, that's bad. Throw an exception. This should never happen.
155  throw new LogicException( "Nonce requirement mismatch" );
156  }
157 
158  $additionalSelfUrls = $this->getAdditionalSelfUrls();
159  $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
160 
161  // If no default-src is sent at all, it
162  // seems browsers (or at least some), interpret
163  // that as allow anything, but the spec seems
164  // to imply that data: and blob: should be
165  // blocked.
166  $defaultSrc = [ '*', 'data:', 'blob:' ];
167 
168  $imgSrc = false;
169  $scriptSrc = [ "'unsafe-eval'", "blob:", "'self'" ];
170  if ( $policyConfig['useNonces'] ?? true ) {
171  $scriptSrc[] = "'nonce-" . $this->getNonce() . "'";
172  }
173 
174  $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
175  if ( isset( $policyConfig['script-src'] )
176  && is_array( $policyConfig['script-src'] )
177  ) {
178  foreach ( $policyConfig['script-src'] as $src ) {
179  $scriptSrc[] = $this->escapeUrlForCSP( $src );
180  }
181  }
182  // Note: default on if unspecified.
183  if ( $policyConfig['unsafeFallback'] ?? true ) {
184  // unsafe-inline should be ignored on browsers
185  // that support 'nonce-foo' sources.
186  // Some older versions of firefox don't follow this
187  // rule, but new browsers do. (Should be for at least
188  // firefox 40+).
189  $scriptSrc[] = "'unsafe-inline'";
190  }
191  // If default source option set to true or
192  // an array of urls, set a restrictive default-src.
193  // If set to false, we send a lenient default-src,
194  // see the code above where $defaultSrc is set initially.
195  if ( isset( $policyConfig['default-src'] )
196  && $policyConfig['default-src'] !== false
197  ) {
198  $defaultSrc = array_merge(
199  [ "'self'", 'data:', 'blob:' ],
200  $additionalSelfUrls
201  );
202  if ( is_array( $policyConfig['default-src'] ) ) {
203  foreach ( $policyConfig['default-src'] as $src ) {
204  $defaultSrc[] = $this->escapeUrlForCSP( $src );
205  }
206  }
207  }
208 
209  if ( $policyConfig['includeCORS'] ?? true ) {
210  $CORSUrls = $this->getCORSSources();
211  if ( !in_array( '*', $defaultSrc ) ) {
212  $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
213  }
214  // Unlikely to have * in scriptSrc, but doesn't
215  // hurt to check.
216  if ( !in_array( '*', $scriptSrc ) ) {
217  $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
218  }
219  }
220 
221  $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
222  $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
223 
224  $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [ "'unsafe-inline'" ] );
225 
226  $this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode );
227  $this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode );
228 
229  if ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) {
230  if ( $policyConfig['report-uri'] === false ) {
231  $reportUri = false;
232  } else {
233  $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] );
234  }
235  } else {
236  $reportUri = $this->getReportUri( $mode );
237  }
238 
239  // Only send an img-src, if we're sending a restrictive default.
240  if ( !is_array( $defaultSrc )
241  || !in_array( '*', $defaultSrc )
242  || !in_array( 'data:', $defaultSrc )
243  || !in_array( 'blob:', $defaultSrc )
244  ) {
245  // A future todo might be to make the allow options only
246  // add all the allowed sites to the header, instead of
247  // allowing all (Assuming there is a small number of sites).
248  // For now, the external image feature disables the limits
249  // CSP puts on external images.
250  if ( $mwConfig->get( MainConfigNames::AllowExternalImages )
251  || $mwConfig->get( MainConfigNames::AllowExternalImagesFrom )
252  || $mwConfig->get( MainConfigNames::AllowImageTag )
253  ) {
254  $imgSrc = [ '*', 'data:', 'blob:' ];
255  } elseif ( $mwConfig->get( MainConfigNames::EnableImageWhitelist ) ) {
256  $whitelist = wfMessage( 'external_image_whitelist' )
257  ->inContentLanguage()
258  ->plain();
259  if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) {
260  $imgSrc = [ '*', 'data:', 'blob:' ];
261  }
262  }
263  }
264  // Default value 'none'. true is none, false is nothing, string is single directive,
265  // array is list.
266  if ( !isset( $policyConfig['object-src'] ) || $policyConfig['object-src'] === true ) {
267  $objectSrc = [ "'none'" ];
268  } else {
269  $objectSrc = (array)( $policyConfig['object-src'] ?: [] );
270  }
271  $objectSrc = array_map( [ $this, 'escapeUrlForCSP' ], $objectSrc );
272 
273  $directives = [];
274  if ( $scriptSrc ) {
275  $directives[] = 'script-src ' . implode( ' ', array_unique( $scriptSrc ) );
276  }
277  if ( $defaultSrc ) {
278  $directives[] = 'default-src ' . implode( ' ', array_unique( $defaultSrc ) );
279  }
280  if ( $cssSrc ) {
281  $directives[] = 'style-src ' . implode( ' ', array_unique( $cssSrc ) );
282  }
283  if ( $imgSrc ) {
284  $directives[] = 'img-src ' . implode( ' ', array_unique( $imgSrc ) );
285  }
286  if ( $objectSrc ) {
287  $directives[] = 'object-src ' . implode( ' ', $objectSrc );
288  }
289  if ( $reportUri ) {
290  $directives[] = 'report-uri ' . $reportUri;
291  }
292 
293  $this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode );
294 
295  return implode( '; ', $directives );
296  }
297 
305  private function getReportUri( $mode ) {
306  $apiArguments = [
307  'action' => 'cspreport',
308  'format' => 'json'
309  ];
310  if ( $mode === self::REPORT_ONLY_MODE ) {
311  $apiArguments['reportonly'] = '1';
312  }
313  $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments );
314 
315  // Per spec, ';' and ',' must be hex-escaped in report URI
316  $reportUri = $this->escapeUrlForCSP( $reportUri );
317  return $reportUri;
318  }
319 
335  private function prepareUrlForCSP( $url ) {
336  $result = false;
337  if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
338  // A schema source (e.g. blob: or data:)
339  return $url;
340  }
341  $bits = wfParseUrl( $url );
342  if ( !$bits && strpos( $url, '/' ) === false ) {
343  // probably something like example.com.
344  // try again protocol-relative.
345  $url = '//' . $url;
346  $bits = wfParseUrl( $url );
347  }
348  if ( $bits && isset( $bits['host'] )
349  && $bits['host'] !== $this->mwConfig->get( MainConfigNames::ServerName )
350  ) {
351  $result = $bits['host'];
352  if ( $bits['scheme'] !== '' ) {
353  $result = $bits['scheme'] . $bits['delimiter'] . $result;
354  }
355  if ( isset( $bits['port'] ) ) {
356  $result .= ':' . $bits['port'];
357  }
358  $result = $this->escapeUrlForCSP( $result );
359  }
360  return $result;
361  }
362 
366  private function getAdditionalSelfUrlsScript() {
367  $additionalUrls = [];
368  // wgExtensionAssetsPath for ?debug=true mode
369  $pathVars = [ MainConfigNames::LoadScript, MainConfigNames::ExtensionAssetsPath,
370  MainConfigNames::ResourceBasePath ];
371 
372  foreach ( $pathVars as $path ) {
373  $url = $this->mwConfig->get( $path );
374  $preparedUrl = $this->prepareUrlForCSP( $url );
375  if ( $preparedUrl ) {
376  $additionalUrls[] = $preparedUrl;
377  }
378  }
379  $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
380  foreach ( $RLSources as $wiki => $sources ) {
381  foreach ( $sources as $id => $value ) {
382  $url = $this->prepareUrlForCSP( $value );
383  if ( $url ) {
384  $additionalUrls[] = $url;
385  }
386  }
387  }
388 
389  return array_unique( $additionalUrls );
390  }
391 
398  private function getAdditionalSelfUrls() {
399  // XXX on a foreign repo, the included description page can have anything on it,
400  // including inline scripts. But nobody does that.
401 
402  // In principle, you can have even more complex configs... (e.g. The urlsByExt option)
403  $pathUrls = [];
404  $additionalSelfUrls = [];
405 
406  // Future todo: The zone urls should never go into
407  // style-src. They should either be only in img-src, or if
408  // img-src unspecified they should be in default-src. Similarly,
409  // the DescriptionStylesheetUrl only needs to be in style-src
410  // (or default-src if style-src unspecified).
411  $callback = static function ( $repo, &$urls ) {
412  $urls[] = $repo->getZoneUrl( 'public' );
413  $urls[] = $repo->getZoneUrl( 'transcoded' );
414  $urls[] = $repo->getZoneUrl( 'thumb' );
415  $urls[] = $repo->getDescriptionStylesheetUrl();
416  };
417  $repoGroup = MediaWikiServices::getInstance()->getRepoGroup();
418  $localRepo = $repoGroup->getRepo( 'local' );
419  $callback( $localRepo, $pathUrls );
420  $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] );
421 
422  // Globals that might point to a different domain
423  $pathGlobals = [ MainConfigNames::LoadScript, MainConfigNames::ExtensionAssetsPath,
424  MainConfigNames::StylePath, MainConfigNames::ResourceBasePath ];
425  foreach ( $pathGlobals as $path ) {
426  $pathUrls[] = $this->mwConfig->get( $path );
427  }
428  foreach ( $pathUrls as $path ) {
429  $preparedUrl = $this->prepareUrlForCSP( $path );
430  if ( $preparedUrl !== false ) {
431  $additionalSelfUrls[] = $preparedUrl;
432  }
433  }
434  $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
435 
436  foreach ( $RLSources as $wiki => $sources ) {
437  foreach ( $sources as $id => $value ) {
438  $url = $this->prepareUrlForCSP( $value );
439  if ( $url ) {
440  $additionalSelfUrls[] = $url;
441  }
442  }
443  }
444 
445  return array_unique( $additionalSelfUrls );
446  }
447 
460  private function getCORSSources() {
461  $additionalUrls = [];
462  $CORSSources = $this->mwConfig->get( MainConfigNames::CrossSiteAJAXdomains );
463  foreach ( $CORSSources as $source ) {
464  if ( strpos( $source, '?' ) !== false ) {
465  // CSP doesn't support single char wildcard
466  continue;
467  }
468  $url = $this->prepareUrlForCSP( $source );
469  if ( $url ) {
470  $additionalUrls[] = $url;
471  }
472  }
473  return $additionalUrls;
474  }
475 
483  private function escapeUrlForCSP( $url ) {
484  return str_replace(
485  [ ';', ',' ],
486  [ '%3B', '%2C' ],
487  $url
488  );
489  }
490 
501  public static function falsePositiveBrowser( $ua ) {
502  return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
503  }
504 
511  public static function isNonceRequired( Config $config ) {
512  $configs = [
513  $config->get( MainConfigNames::CSPHeader ),
514  $config->get( MainConfigNames::CSPReportOnlyHeader )
515  ];
516  return self::isNonceRequiredArray( $configs );
517  }
518 
525  private static function isNonceRequiredArray( array $configs ) {
526  foreach ( $configs as $headerConfig ) {
527  if (
528  $headerConfig === true ||
529  ( is_array( $headerConfig ) &&
530  !isset( $headerConfig['useNonces'] ) ) ||
531  ( is_array( $headerConfig ) &&
532  isset( $headerConfig['useNonces'] ) &&
533  $headerConfig['useNonces'] )
534  ) {
535  return true;
536  }
537  }
538  return false;
539  }
540 
547  public function getNonce() {
548  if ( !self::isNonceRequired( $this->mwConfig ) ) {
549  return false;
550  }
551  if ( $this->nonce === null ) {
552  $rand = random_bytes( 15 );
553  $this->nonce = base64_encode( $rand );
554  }
555 
556  return $this->nonce;
557  }
558 
569  public function addDefaultSrc( $source ) {
570  $this->extraDefaultSrc[] = $this->prepareUrlForCSP( $source );
571  }
572 
581  public function addStyleSrc( $source ) {
582  $this->extraStyleSrc[] = $this->prepareUrlForCSP( $source );
583  }
584 
594  public function addScriptSrc( $source ) {
595  $this->extraScriptSrc[] = $this->prepareUrlForCSP( $source );
596  }
597 }
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.
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...
sendHeaders()
Send CSP headers based on wiki config.
static falsePositiveBrowser( $ua)
Does this browser give false positive reports?
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.
sendCSPHeader( $csp, $reportOnly)
Send a single CSP header based on a given policy config.
getNonce()
Get the nonce if nonce is in use.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:561
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
Allow programs to request this object from WebRequest::response() and handle all outputting (or lack ...
Definition: WebResponse.php:32
Interface for configuration instances.
Definition: Config.php:30
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
$source