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