MediaWiki  master
ContentSecurityPolicy.php
Go to the documentation of this file.
1 <?php
28  const REPORT_ONLY_MODE = 1;
29  const FULL_MODE = 2;
30 
32  private $nonce;
34  private $mwConfig;
36  private $response;
37 
39  private $extraDefaultSrc = [];
41  private $extraScriptSrc = [];
43  private $extraStyleSrc = [];
44 
55  $this->response = $response;
56  $this->mwConfig = $mwConfig;
57  }
58 
67  public function sendCSPHeader( $csp, $reportOnly ) {
68  $policy = $this->makeCSPDirectives( $csp, $reportOnly );
69  $headerName = $this->getHeaderName( $reportOnly );
70  if ( $policy ) {
71  $this->response->header(
72  "$headerName: $policy"
73  );
74  }
75  }
76 
86  public function sendHeaders() {
87  $cspConfig = $this->mwConfig->get( 'CSPHeader' );
88  $cspConfigReportOnly = $this->mwConfig->get( 'CSPReportOnlyHeader' );
89 
90  $this->sendCSPHeader( $cspConfig, self::FULL_MODE );
91  $this->sendCSPHeader( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
92 
93  // This used to insert a <meta> tag here, per advice at
94  // https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/
95  // The goal was to prevent nonce from working after the page hit onready,
96  // This would help in old browsers that didn't support nonces, and
97  // also assist for varnish-cached pages which repeat nonces.
98  // However, this is incompatible with how resource loader storage works
99  // via mw.domEval() so it was removed.
100  }
101 
109  private function getHeaderName( $reportOnly ) {
110  if ( $reportOnly === self::REPORT_ONLY_MODE ) {
111  return 'Content-Security-Policy-Report-Only';
112  }
113 
114  if ( $reportOnly === self::FULL_MODE ) {
115  return 'Content-Security-Policy';
116  }
117  throw new UnexpectedValueException( $reportOnly );
118  }
119 
128  private function makeCSPDirectives( $policyConfig, $mode ) {
129  if ( $policyConfig === false ) {
130  // CSP is disabled
131  return '';
132  }
133  if ( $policyConfig === true ) {
134  $policyConfig = [];
135  }
136 
138 
139  if (
140  !self::isNonceRequired( $mwConfig ) &&
141  self::isNonceRequiredArray( [ $policyConfig ] )
142  ) {
143  // If the current policy requires a nonce, but the global state
144  // does not, that's bad. Throw an exception. This should never happen.
145  throw new LogicException( "Nonce requirement mismatch" );
146  }
147 
148  $additionalSelfUrls = $this->getAdditionalSelfUrls();
149  $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
150 
151  // If no default-src is sent at all, it
152  // seems browsers (or at least some), interpret
153  // that as allow anything, but the spec seems
154  // to imply that data: and blob: should be
155  // blocked.
156  $defaultSrc = [ '*', 'data:', 'blob:' ];
157 
158  $cssSrc = false;
159  $imgSrc = false;
160  $scriptSrc = [ "'unsafe-eval'", "'self'" ];
161  if ( !isset( $policyConfig['useNonces'] ) || $policyConfig['useNonces'] ) {
162  $scriptSrc[] = "'nonce-" . $this->getNonce() . "'";
163  }
164 
165  $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
166  if ( isset( $policyConfig['script-src'] )
167  && is_array( $policyConfig['script-src'] )
168  ) {
169  foreach ( $policyConfig['script-src'] as $src ) {
170  $scriptSrc[] = $this->escapeUrlForCSP( $src );
171  }
172  }
173  // Note: default on if unspecified.
174  if ( !isset( $policyConfig['unsafeFallback'] )
175  || $policyConfig['unsafeFallback']
176  ) {
177  // unsafe-inline should be ignored on browsers
178  // that support 'nonce-foo' sources.
179  // Some older versions of firefox don't follow this
180  // rule, but new browsers do. (Should be for at least
181  // firefox 40+).
182  $scriptSrc[] = "'unsafe-inline'";
183  }
184  // If default source option set to true or
185  // an array of urls, set a restrictive default-src.
186  // If set to false, we send a lenient default-src,
187  // see the code above where $defaultSrc is set initially.
188  if ( isset( $policyConfig['default-src'] )
189  && $policyConfig['default-src'] !== false
190  ) {
191  $defaultSrc = array_merge(
192  [ "'self'", 'data:', 'blob:' ],
193  $additionalSelfUrls
194  );
195  if ( is_array( $policyConfig['default-src'] ) ) {
196  foreach ( $policyConfig['default-src'] as $src ) {
197  $defaultSrc[] = $this->escapeUrlForCSP( $src );
198  }
199  }
200  }
201 
202  if ( !isset( $policyConfig['includeCORS'] ) || $policyConfig['includeCORS'] ) {
203  $CORSUrls = $this->getCORSSources();
204  if ( !in_array( '*', $defaultSrc ) ) {
205  $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
206  }
207  // Unlikely to have * in scriptSrc, but doesn't
208  // hurt to check.
209  if ( !in_array( '*', $scriptSrc ) ) {
210  $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
211  }
212  }
213 
214  $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
215  $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
216 
217  $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [ "'unsafe-inline'" ] );
218 
219  Hooks::run( 'ContentSecurityPolicyDefaultSource', [ &$defaultSrc, $policyConfig, $mode ] );
220  Hooks::run( 'ContentSecurityPolicyScriptSource', [ &$scriptSrc, $policyConfig, $mode ] );
221 
222  if ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) {
223  if ( $policyConfig['report-uri'] === false ) {
224  $reportUri = false;
225  } else {
226  $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] );
227  }
228  } else {
229  $reportUri = $this->getReportUri( $mode );
230  }
231 
232  // Only send an img-src, if we're sending a restricitve default.
233  if ( !is_array( $defaultSrc )
234  || !in_array( '*', $defaultSrc )
235  || !in_array( 'data:', $defaultSrc )
236  || !in_array( 'blob:', $defaultSrc )
237  ) {
238  // A future todo might be to make the whitelist options only
239  // add all the whitelisted sites to the header, instead of
240  // allowing all (Assuming there is a small number of sites).
241  // For now, the external image feature disables the limits
242  // CSP puts on external images.
243  if ( $mwConfig->get( 'AllowExternalImages' )
244  || $mwConfig->get( 'AllowExternalImagesFrom' )
245  || $mwConfig->get( 'AllowImageTag' )
246  ) {
247  $imgSrc = [ '*', 'data:', 'blob:' ];
248  } elseif ( $mwConfig->get( 'EnableImageWhitelist' ) ) {
249  $whitelist = wfMessage( 'external_image_whitelist' )
250  ->inContentLanguage()
251  ->plain();
252  if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) {
253  $imgSrc = [ '*', 'data:', 'blob:' ];
254  }
255  }
256  }
257 
258  $directives = [];
259  if ( $scriptSrc ) {
260  $directives[] = 'script-src ' . implode( ' ', array_unique( $scriptSrc ) );
261  }
262  if ( $defaultSrc ) {
263  $directives[] = 'default-src ' . implode( ' ', array_unique( $defaultSrc ) );
264  }
265  if ( $cssSrc ) {
266  $directives[] = 'style-src ' . implode( ' ', array_unique( $cssSrc ) );
267  }
268  if ( $imgSrc ) {
269  $directives[] = 'img-src ' . implode( ' ', array_unique( $imgSrc ) );
270  }
271  if ( $reportUri ) {
272  $directives[] = 'report-uri ' . $reportUri;
273  }
274 
275  Hooks::run( 'ContentSecurityPolicyDirectives', [ &$directives, $policyConfig, $mode ] );
276 
277  return implode( '; ', $directives );
278  }
279 
287  private function getReportUri( $mode ) {
288  $apiArguments = [
289  'action' => 'cspreport',
290  'format' => 'json'
291  ];
292  if ( $mode === self::REPORT_ONLY_MODE ) {
293  $apiArguments['reportonly'] = '1';
294  }
295  $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments );
296 
297  // Per spec, ';' and ',' must be hex-escaped in report URI
298  $reportUri = $this->escapeUrlForCSP( $reportUri );
299  return $reportUri;
300  }
301 
317  private function prepareUrlForCSP( $url ) {
318  $result = false;
319  if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
320  // A schema source (e.g. blob: or data:)
321  return $url;
322  }
323  $bits = wfParseUrl( $url );
324  if ( !$bits && strpos( $url, '/' ) === false ) {
325  // probably something like example.com.
326  // try again protocol-relative.
327  $url = '//' . $url;
328  $bits = wfParseUrl( $url );
329  }
330  if ( $bits && isset( $bits['host'] )
331  && $bits['host'] !== $this->mwConfig->get( 'ServerName' )
332  ) {
333  $result = $bits['host'];
334  if ( $bits['scheme'] !== '' ) {
335  $result = $bits['scheme'] . $bits['delimiter'] . $result;
336  }
337  if ( isset( $bits['port'] ) ) {
338  $result .= ':' . $bits['port'];
339  }
340  $result = $this->escapeUrlForCSP( $result );
341  }
342  return $result;
343  }
344 
350  private function getAdditionalSelfUrlsScript() {
351  $additionalUrls = [];
352  // wgExtensionAssetsPath for ?debug=true mode
353  $pathVars = [ 'LoadScript', 'ExtensionAssetsPath', 'ResourceBasePath' ];
354 
355  foreach ( $pathVars as $path ) {
356  $url = $this->mwConfig->get( $path );
357  $preparedUrl = $this->prepareUrlForCSP( $url );
358  if ( $preparedUrl ) {
359  $additionalUrls[] = $preparedUrl;
360  }
361  }
362  $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
363  foreach ( $RLSources as $wiki => $sources ) {
364  foreach ( $sources as $id => $value ) {
365  $url = $this->prepareUrlForCSP( $value );
366  if ( $url ) {
367  $additionalUrls[] = $url;
368  }
369  }
370  }
371 
372  return array_unique( $additionalUrls );
373  }
374 
381  private function getAdditionalSelfUrls() {
382  // XXX on a foreign repo, the included description page can have anything on it,
383  // including inline scripts. But nobody sane does that.
384 
385  // In principle, you can have even more complex configs... (e.g. The urlsByExt option)
386  $pathUrls = [];
387  $additionalSelfUrls = [];
388 
389  // Future todo: The zone urls should never go into
390  // style-src. They should either be only in img-src, or if
391  // img-src unspecified they should be in default-src. Similarly,
392  // the DescriptionStylesheetUrl only needs to be in style-src
393  // (or default-src if style-src unspecified).
394  $callback = function ( $repo, &$urls ) {
395  $urls[] = $repo->getZoneUrl( 'public' );
396  $urls[] = $repo->getZoneUrl( 'transcoded' );
397  $urls[] = $repo->getZoneUrl( 'thumb' );
398  $urls[] = $repo->getDescriptionStylesheetUrl();
399  };
400  $localRepo = RepoGroup::singleton()->getRepo( 'local' );
401  $callback( $localRepo, $pathUrls );
402  RepoGroup::singleton()->forEachForeignRepo( $callback, [ &$pathUrls ] );
403 
404  // Globals that might point to a different domain
405  $pathGlobals = [ 'LoadScript', 'ExtensionAssetsPath', 'StylePath', 'ResourceBasePath' ];
406  foreach ( $pathGlobals as $path ) {
407  $pathUrls[] = $this->mwConfig->get( $path );
408  }
409  foreach ( $pathUrls as $path ) {
410  $preparedUrl = $this->prepareUrlForCSP( $path );
411  if ( $preparedUrl !== false ) {
412  $additionalSelfUrls[] = $preparedUrl;
413  }
414  }
415  $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
416 
417  foreach ( $RLSources as $wiki => $sources ) {
418  foreach ( $sources as $id => $value ) {
419  $url = $this->prepareUrlForCSP( $value );
420  if ( $url ) {
421  $additionalSelfUrls[] = $url;
422  }
423  }
424  }
425 
426  return array_unique( $additionalSelfUrls );
427  }
428 
441  private function getCORSSources() {
442  $additionalUrls = [];
443  $CORSSources = $this->mwConfig->get( 'CrossSiteAJAXdomains' );
444  foreach ( $CORSSources as $source ) {
445  if ( strpos( $source, '?' ) !== false ) {
446  // CSP doesn't support single char wildcard
447  continue;
448  }
449  $url = $this->prepareUrlForCSP( $source );
450  if ( $url ) {
451  $additionalUrls[] = $url;
452  }
453  }
454  return $additionalUrls;
455  }
456 
464  private function escapeUrlForCSP( $url ) {
465  return str_replace(
466  [ ';', ',' ],
467  [ '%3B', '%2C' ],
468  $url
469  );
470  }
471 
482  public static function falsePositiveBrowser( $ua ) {
483  return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
484  }
485 
492  public static function isNonceRequired( Config $config ) {
493  $configs = [
494  $config->get( 'CSPHeader' ),
495  $config->get( 'CSPReportOnlyHeader' )
496  ];
497  return self::isNonceRequiredArray( $configs );
498  }
499 
506  private static function isNonceRequiredArray( array $configs ) {
507  foreach ( $configs as $headerConfig ) {
508  if (
509  $headerConfig === true ||
510  ( is_array( $headerConfig ) &&
511  !isset( $headerConfig['useNonces'] ) ) ||
512  ( is_array( $headerConfig ) &&
513  isset( $headerConfig['useNonces'] ) &&
514  $headerConfig['useNonces'] )
515  ) {
516  return true;
517  }
518  }
519  return false;
520  }
521 
528  public function getNonce() {
529  if ( !self::isNonceRequired( $this->mwConfig ) ) {
530  return false;
531  }
532  if ( $this->nonce === null ) {
533  $rand = random_bytes( 15 );
534  $this->nonce = base64_encode( $rand );
535  }
536 
537  return $this->nonce;
538  }
539 
552  public function addDefaultSrc( $source ) {
553  $this->extraDefaultSrc[] = $this->prepareUrlForCSP( $source );
554  }
555 
566  public function addStyleSrc( $source ) {
567  $this->extraStyleSrc[] = $this->prepareUrlForCSP( $source );
568  }
569 
581  public function addScriptSrc( $source ) {
582  $this->extraScriptSrc[] = $this->prepareUrlForCSP( $source );
583  }
584 }
getAdditionalSelfUrlsScript()
Get additional script sources.
string $nonce
The nonce to use for inline scripts (from OutputPage)
wfParseUrl( $url)
parse_url() work-alike, but non-broken.
static isNonceRequired(Config $config)
Should we set nonce attribute.
$source
static isNonceRequiredArray(array $configs)
Does a specific config require a nonce.
getCORSSources()
include domains that are allowed to send us CORS requests.
getNonce()
Get the nonce if nonce is in use.
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
wfScript( $script='index')
Get the path to a specified script file, respecting file extensions; this is a wrapper around $wgScri...
wfAppendQuery( $url, $query)
Append a query string to an existing URL, which may or may not already have query string parameters a...
addDefaultSrc( $source)
Add an additional default src.
Interface for configuration instances.
Definition: Config.php:28
escapeUrlForCSP( $url)
CSP spec says &#39;,&#39; and &#39;;&#39; are not allowed to appear in urls.
static singleton()
Definition: RepoGroup.php:60
addStyleSrc( $source)
Add an additional CSS src.
getHeaderName( $reportOnly)
Get the name of the HTTP header to use.
prepareUrlForCSP( $url)
Given a url, convert to form needed for CSP.
static falsePositiveBrowser( $ua)
Does this browser give false positive reports?
getReportUri( $mode)
Get the default report uri.
makeCSPDirectives( $policyConfig, $mode)
Determine what CSP policies to set for this page.
Config $mwConfig
The site configuration object.
sendCSPHeader( $csp, $reportOnly)
Send a single CSP header based on a given policy config.
getAdditionalSelfUrls()
Get additional host names for the wiki (e.g.
addScriptSrc( $source)
Add an additional script src.
sendHeaders()
Send CSP headers based on wiki config.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
__construct(WebResponse $response, Config $mwConfig)
static run( $event, array $args=[], $deprecatedVersion=null)
Call hook functions defined in Hooks::register and $wgHooks.
Definition: Hooks.php:200