MediaWiki  1.34.0
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 
44  $this->nonce = $nonce;
45  $this->response = $response;
46  $this->mwConfig = $mwConfig;
47  }
48 
56  public function sendCSPHeader( $csp, $reportOnly ) {
57  $policy = $this->makeCSPDirectives( $csp, $reportOnly );
58  $headerName = $this->getHeaderName( $reportOnly );
59  if ( $policy ) {
60  $this->response->header(
61  "$headerName: $policy"
62  );
63  }
64  }
65 
73  public static function sendHeaders( IContextSource $context ) {
74  $out = $context->getOutput();
75  $csp = new ContentSecurityPolicy(
76  $out->getCSPNonce(),
77  $context->getRequest()->response(),
78  $context->getConfig()
79  );
80 
81  $cspConfig = $context->getConfig()->get( 'CSPHeader' );
82  $cspConfigReportOnly = $context->getConfig()->get( 'CSPReportOnlyHeader' );
83 
84  $csp->sendCSPHeader( $cspConfig, self::FULL_MODE );
85  $csp->sendCSPHeader( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
86 
87  // This used to insert a <meta> tag here, per advice at
88  // https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/
89  // The goal was to prevent nonce from working after the page hit onready,
90  // This would help in old browsers that didn't support nonces, and
91  // also assist for varnish-cached pages which repeat nonces.
92  // However, this is incompatible with how resource loader storage works
93  // via mw.domEval() so it was removed.
94  }
95 
103  private function getHeaderName( $reportOnly ) {
104  if ( $reportOnly === self::REPORT_ONLY_MODE ) {
105  return 'Content-Security-Policy-Report-Only';
106  }
107 
108  if ( $reportOnly === self::FULL_MODE ) {
109  return 'Content-Security-Policy';
110  }
111  throw new UnexpectedValueException( $reportOnly );
112  }
113 
122  private function makeCSPDirectives( $policyConfig, $mode ) {
123  if ( $policyConfig === false ) {
124  // CSP is disabled
125  return '';
126  }
127  if ( $policyConfig === true ) {
128  $policyConfig = [];
129  }
130 
132 
133  $additionalSelfUrls = $this->getAdditionalSelfUrls();
134  $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
135 
136  // If no default-src is sent at all, it
137  // seems browsers (or at least some), interpret
138  // that as allow anything, but the spec seems
139  // to imply that data: and blob: should be
140  // blocked.
141  $defaultSrc = [ '*', 'data:', 'blob:' ];
142 
143  $cssSrc = false;
144  $imgSrc = false;
145  $scriptSrc = [ "'unsafe-eval'", "'self'" ];
146  if ( !isset( $policyConfig['useNonces'] ) || $policyConfig['useNonces'] ) {
147  $scriptSrc[] = "'nonce-" . $this->nonce . "'";
148  }
149 
150  $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
151  if ( isset( $policyConfig['script-src'] )
152  && is_array( $policyConfig['script-src'] )
153  ) {
154  foreach ( $policyConfig['script-src'] as $src ) {
155  $scriptSrc[] = $this->escapeUrlForCSP( $src );
156  }
157  }
158  // Note: default on if unspecified.
159  if ( !isset( $policyConfig['unsafeFallback'] )
160  || $policyConfig['unsafeFallback']
161  ) {
162  // unsafe-inline should be ignored on browsers
163  // that support 'nonce-foo' sources.
164  // Some older versions of firefox don't follow this
165  // rule, but new browsers do. (Should be for at least
166  // firefox 40+).
167  $scriptSrc[] = "'unsafe-inline'";
168  }
169  // If default source option set to true or
170  // an array of urls, set a restrictive default-src.
171  // If set to false, we send a lenient default-src,
172  // see the code above where $defaultSrc is set initially.
173  if ( isset( $policyConfig['default-src'] )
174  && $policyConfig['default-src'] !== false
175  ) {
176  $defaultSrc = array_merge(
177  [ "'self'", 'data:', 'blob:' ],
178  $additionalSelfUrls
179  );
180  if ( is_array( $policyConfig['default-src'] ) ) {
181  foreach ( $policyConfig['default-src'] as $src ) {
182  $defaultSrc[] = $this->escapeUrlForCSP( $src );
183  }
184  }
185  }
186 
187  if ( !isset( $policyConfig['includeCORS'] ) || $policyConfig['includeCORS'] ) {
188  $CORSUrls = $this->getCORSSources();
189  if ( !in_array( '*', $defaultSrc ) ) {
190  $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
191  }
192  // Unlikely to have * in scriptSrc, but doesn't
193  // hurt to check.
194  if ( !in_array( '*', $scriptSrc ) ) {
195  $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
196  }
197  }
198 
199  Hooks::run( 'ContentSecurityPolicyDefaultSource', [ &$defaultSrc, $policyConfig, $mode ] );
200  Hooks::run( 'ContentSecurityPolicyScriptSource', [ &$scriptSrc, $policyConfig, $mode ] );
201 
202  // Check if array just in case the hook made it false
203  if ( is_array( $defaultSrc ) ) {
204  $cssSrc = array_merge( $defaultSrc, [ "'unsafe-inline'" ] );
205  }
206 
207  if ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) {
208  if ( $policyConfig['report-uri'] === false ) {
209  $reportUri = false;
210  } else {
211  $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] );
212  }
213  } else {
214  $reportUri = $this->getReportUri( $mode );
215  }
216 
217  // Only send an img-src, if we're sending a restricitve default.
218  if ( !is_array( $defaultSrc )
219  || !in_array( '*', $defaultSrc )
220  || !in_array( 'data:', $defaultSrc )
221  || !in_array( 'blob:', $defaultSrc )
222  ) {
223  // A future todo might be to make the whitelist options only
224  // add all the whitelisted sites to the header, instead of
225  // allowing all (Assuming there is a small number of sites).
226  // For now, the external image feature disables the limits
227  // CSP puts on external images.
228  if ( $mwConfig->get( 'AllowExternalImages' )
229  || $mwConfig->get( 'AllowExternalImagesFrom' )
230  || $mwConfig->get( 'AllowImageTag' )
231  ) {
232  $imgSrc = [ '*', 'data:', 'blob:' ];
233  } elseif ( $mwConfig->get( 'EnableImageWhitelist' ) ) {
234  $whitelist = wfMessage( 'external_image_whitelist' )
235  ->inContentLanguage()
236  ->plain();
237  if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) {
238  $imgSrc = [ '*', 'data:', 'blob:' ];
239  }
240  }
241  }
242 
243  $directives = [];
244  if ( $scriptSrc ) {
245  $directives[] = 'script-src ' . implode( ' ', $scriptSrc );
246  }
247  if ( $defaultSrc ) {
248  $directives[] = 'default-src ' . implode( ' ', $defaultSrc );
249  }
250  if ( $cssSrc ) {
251  $directives[] = 'style-src ' . implode( ' ', $cssSrc );
252  }
253  if ( $imgSrc ) {
254  $directives[] = 'img-src ' . implode( ' ', $imgSrc );
255  }
256  if ( $reportUri ) {
257  $directives[] = 'report-uri ' . $reportUri;
258  }
259 
260  Hooks::run( 'ContentSecurityPolicyDirectives', [ &$directives, $policyConfig, $mode ] );
261 
262  return implode( '; ', $directives );
263  }
264 
272  private function getReportUri( $mode ) {
273  $apiArguments = [
274  'action' => 'cspreport',
275  'format' => 'json'
276  ];
277  if ( $mode === self::REPORT_ONLY_MODE ) {
278  $apiArguments['reportonly'] = '1';
279  }
280  $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments );
281 
282  // Per spec, ';' and ',' must be hex-escaped in report uri
283  // Also add an & at the end of url to work around bug in hhvm
284  // with handling of POST parameters when always_decode_post_data
285  // is set to true. See https://github.com/facebook/hhvm/issues/6676
286  $reportUri = $this->escapeUrlForCSP( $reportUri ) . '&';
287  return $reportUri;
288  }
289 
305  private function prepareUrlForCSP( $url ) {
306  $result = false;
307  if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
308  // A schema source (e.g. blob: or data:)
309  return $url;
310  }
311  $bits = wfParseUrl( $url );
312  if ( !$bits && strpos( $url, '/' ) === false ) {
313  // probably something like example.com.
314  // try again protocol-relative.
315  $url = '//' . $url;
316  $bits = wfParseUrl( $url );
317  }
318  if ( $bits && isset( $bits['host'] )
319  && $bits['host'] !== $this->mwConfig->get( 'ServerName' )
320  ) {
321  $result = $bits['host'];
322  if ( $bits['scheme'] !== '' ) {
323  $result = $bits['scheme'] . $bits['delimiter'] . $result;
324  }
325  if ( isset( $bits['port'] ) ) {
326  $result .= ':' . $bits['port'];
327  }
328  $result = $this->escapeUrlForCSP( $result );
329  }
330  return $result;
331  }
332 
338  private function getAdditionalSelfUrlsScript() {
339  $additionalUrls = [];
340  // wgExtensionAssetsPath for ?debug=true mode
341  $pathVars = [ 'LoadScript', 'ExtensionAssetsPath', 'ResourceBasePath' ];
342 
343  foreach ( $pathVars as $path ) {
344  $url = $this->mwConfig->get( $path );
345  $preparedUrl = $this->prepareUrlForCSP( $url );
346  if ( $preparedUrl ) {
347  $additionalUrls[] = $preparedUrl;
348  }
349  }
350  $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
351  foreach ( $RLSources as $wiki => $sources ) {
352  foreach ( $sources as $id => $value ) {
353  $url = $this->prepareUrlForCSP( $value );
354  if ( $url ) {
355  $additionalUrls[] = $url;
356  }
357  }
358  }
359 
360  return array_unique( $additionalUrls );
361  }
362 
369  private function getAdditionalSelfUrls() {
370  // XXX on a foreign repo, the included description page can have anything on it,
371  // including inline scripts. But nobody sane does that.
372 
373  // In principle, you can have even more complex configs... (e.g. The urlsByExt option)
374  $pathUrls = [];
375  $additionalSelfUrls = [];
376 
377  // Future todo: The zone urls should never go into
378  // style-src. They should either be only in img-src, or if
379  // img-src unspecified they should be in default-src. Similarly,
380  // the DescriptionStylesheetUrl only needs to be in style-src
381  // (or default-src if style-src unspecified).
382  $callback = function ( $repo, &$urls ) {
383  $urls[] = $repo->getZoneUrl( 'public' );
384  $urls[] = $repo->getZoneUrl( 'transcoded' );
385  $urls[] = $repo->getZoneUrl( 'thumb' );
386  $urls[] = $repo->getDescriptionStylesheetUrl();
387  };
388  $localRepo = RepoGroup::singleton()->getRepo( 'local' );
389  $callback( $localRepo, $pathUrls );
390  RepoGroup::singleton()->forEachForeignRepo( $callback, [ &$pathUrls ] );
391 
392  // Globals that might point to a different domain
393  $pathGlobals = [ 'LoadScript', 'ExtensionAssetsPath', 'StylePath', 'ResourceBasePath' ];
394  foreach ( $pathGlobals as $path ) {
395  $pathUrls[] = $this->mwConfig->get( $path );
396  }
397  foreach ( $pathUrls as $path ) {
398  $preparedUrl = $this->prepareUrlForCSP( $path );
399  if ( $preparedUrl !== false ) {
400  $additionalSelfUrls[] = $preparedUrl;
401  }
402  }
403  $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
404 
405  foreach ( $RLSources as $wiki => $sources ) {
406  foreach ( $sources as $id => $value ) {
407  $url = $this->prepareUrlForCSP( $value );
408  if ( $url ) {
409  $additionalSelfUrls[] = $url;
410  }
411  }
412  }
413 
414  return array_unique( $additionalSelfUrls );
415  }
416 
429  private function getCORSSources() {
430  $additionalUrls = [];
431  $CORSSources = $this->mwConfig->get( 'CrossSiteAJAXdomains' );
432  foreach ( $CORSSources as $source ) {
433  if ( strpos( $source, '?' ) !== false ) {
434  // CSP doesn't support single char wildcard
435  continue;
436  }
437  $url = $this->prepareUrlForCSP( $source );
438  if ( $url ) {
439  $additionalUrls[] = $url;
440  }
441  }
442  return $additionalUrls;
443  }
444 
452  private function escapeUrlForCSP( $url ) {
453  return str_replace(
454  [ ';', ',' ],
455  [ '%3B', '%2C' ],
456  $url
457  );
458  }
459 
470  public static function falsePositiveBrowser( $ua ) {
471  return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
472  }
473 
480  public static function isNonceRequired( Config $config ) {
481  $configs = [
482  $config->get( 'CSPHeader' ),
483  $config->get( 'CSPReportOnlyHeader' )
484  ];
485  foreach ( $configs as $headerConfig ) {
486  if (
487  $headerConfig === true ||
488  ( is_array( $headerConfig ) &&
489  !isset( $headerConfig['useNonces'] ) ) ||
490  ( is_array( $headerConfig ) &&
491  isset( $headerConfig['useNonces'] ) &&
492  $headerConfig['useNonces'] )
493  ) {
494  return true;
495  }
496  }
497  return false;
498  }
499 }
ContentSecurityPolicy\$response
WebResponse $response
Definition: ContentSecurityPolicy.php:36
ContentSecurityPolicy\sendCSPHeader
sendCSPHeader( $csp, $reportOnly)
Send a single CSP header based on a given policy config.
Definition: ContentSecurityPolicy.php:56
RepoGroup\singleton
static singleton()
Definition: RepoGroup.php:60
ContentSecurityPolicy\REPORT_ONLY_MODE
const REPORT_ONLY_MODE
Definition: ContentSecurityPolicy.php:28
ContentSecurityPolicy\falsePositiveBrowser
static falsePositiveBrowser( $ua)
Does this browser give false positive reports?
Definition: ContentSecurityPolicy.php:470
wfMessage
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
Definition: GlobalFunctions.php:1264
ContentSecurityPolicy\getAdditionalSelfUrls
getAdditionalSelfUrls()
Get additional host names for the wiki (e.g.
Definition: ContentSecurityPolicy.php:369
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
ContentSecurityPolicy\sendHeaders
static sendHeaders(IContextSource $context)
Send CSP headers based on wiki config.
Definition: ContentSecurityPolicy.php:73
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:429
wfParseUrl
wfParseUrl( $url)
parse_url() work-alike, but non-broken.
Definition: GlobalFunctions.php:793
wfScript
wfScript( $script='index')
Get the path to a specified script file, respecting file extensions; this is a wrapper around $wgScri...
Definition: GlobalFunctions.php:2642
Config\get
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
ContentSecurityPolicy\__construct
__construct( $nonce, WebResponse $response, Config $mwConfig)
Definition: ContentSecurityPolicy.php:43
ContentSecurityPolicy\escapeUrlForCSP
escapeUrlForCSP( $url)
CSP spec says ',' and ';' are not allowed to appear in urls.
Definition: ContentSecurityPolicy.php:452
ContentSecurityPolicy\$mwConfig
Config $mwConfig
The site configuration object.
Definition: ContentSecurityPolicy.php:34
ContentSecurityPolicy\getAdditionalSelfUrlsScript
getAdditionalSelfUrlsScript()
Get additional script sources.
Definition: ContentSecurityPolicy.php:338
ContentSecurityPolicy\FULL_MODE
const FULL_MODE
Definition: ContentSecurityPolicy.php:29
ContentSecurityPolicy\getReportUri
getReportUri( $mode)
Get the default report uri.
Definition: ContentSecurityPolicy.php:272
ContentSecurityPolicy\$nonce
string $nonce
The nonce to use for inline scripts (from OutputPage)
Definition: ContentSecurityPolicy.php:32
IContextSource
Interface for objects which can provide a MediaWiki context on request.
Definition: IContextSource.php:53
$context
$context
Definition: load.php:45
ContentSecurityPolicy\isNonceRequired
static isNonceRequired(Config $config)
Should we set nonce attribute.
Definition: ContentSecurityPolicy.php:480
ContentSecurityPolicy\prepareUrlForCSP
prepareUrlForCSP( $url)
Given a url, convert to form needed for CSP.
Definition: ContentSecurityPolicy.php:305
$path
$path
Definition: NoLocalSettings.php:25
$source
$source
Definition: mwdoc-filter.php:34
ContentSecurityPolicy\makeCSPDirectives
makeCSPDirectives( $policyConfig, $mode)
Determine what CSP policies to set for this page.
Definition: ContentSecurityPolicy.php:122
WebResponse
Allow programs to request this object from WebRequest::response() and handle all outputting (or lack ...
Definition: WebResponse.php:28
Hooks\run
static run( $event, array $args=[], $deprecatedVersion=null)
Call hook functions defined in Hooks::register and $wgHooks.
Definition: Hooks.php:200
ContentSecurityPolicy
Definition: ContentSecurityPolicy.php:27
ContentSecurityPolicy\getHeaderName
getHeaderName( $reportOnly)
Get the name of the HTTP header to use.
Definition: ContentSecurityPolicy.php:103