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 
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  $reportUri = $this->escapeUrlForCSP( $reportUri );
284  return $reportUri;
285  }
286 
302  private function prepareUrlForCSP( $url ) {
303  $result = false;
304  if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
305  // A schema source (e.g. blob: or data:)
306  return $url;
307  }
308  $bits = wfParseUrl( $url );
309  if ( !$bits && strpos( $url, '/' ) === false ) {
310  // probably something like example.com.
311  // try again protocol-relative.
312  $url = '//' . $url;
313  $bits = wfParseUrl( $url );
314  }
315  if ( $bits && isset( $bits['host'] )
316  && $bits['host'] !== $this->mwConfig->get( 'ServerName' )
317  ) {
318  $result = $bits['host'];
319  if ( $bits['scheme'] !== '' ) {
320  $result = $bits['scheme'] . $bits['delimiter'] . $result;
321  }
322  if ( isset( $bits['port'] ) ) {
323  $result .= ':' . $bits['port'];
324  }
325  $result = $this->escapeUrlForCSP( $result );
326  }
327  return $result;
328  }
329 
335  private function getAdditionalSelfUrlsScript() {
336  $additionalUrls = [];
337  // wgExtensionAssetsPath for ?debug=true mode
338  $pathVars = [ 'LoadScript', 'ExtensionAssetsPath', 'ResourceBasePath' ];
339 
340  foreach ( $pathVars as $path ) {
341  $url = $this->mwConfig->get( $path );
342  $preparedUrl = $this->prepareUrlForCSP( $url );
343  if ( $preparedUrl ) {
344  $additionalUrls[] = $preparedUrl;
345  }
346  }
347  $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
348  foreach ( $RLSources as $wiki => $sources ) {
349  foreach ( $sources as $id => $value ) {
350  $url = $this->prepareUrlForCSP( $value );
351  if ( $url ) {
352  $additionalUrls[] = $url;
353  }
354  }
355  }
356 
357  return array_unique( $additionalUrls );
358  }
359 
366  private function getAdditionalSelfUrls() {
367  // XXX on a foreign repo, the included description page can have anything on it,
368  // including inline scripts. But nobody sane does that.
369 
370  // In principle, you can have even more complex configs... (e.g. The urlsByExt option)
371  $pathUrls = [];
372  $additionalSelfUrls = [];
373 
374  // Future todo: The zone urls should never go into
375  // style-src. They should either be only in img-src, or if
376  // img-src unspecified they should be in default-src. Similarly,
377  // the DescriptionStylesheetUrl only needs to be in style-src
378  // (or default-src if style-src unspecified).
379  $callback = function ( $repo, &$urls ) {
380  $urls[] = $repo->getZoneUrl( 'public' );
381  $urls[] = $repo->getZoneUrl( 'transcoded' );
382  $urls[] = $repo->getZoneUrl( 'thumb' );
383  $urls[] = $repo->getDescriptionStylesheetUrl();
384  };
385  $localRepo = RepoGroup::singleton()->getRepo( 'local' );
386  $callback( $localRepo, $pathUrls );
387  RepoGroup::singleton()->forEachForeignRepo( $callback, [ &$pathUrls ] );
388 
389  // Globals that might point to a different domain
390  $pathGlobals = [ 'LoadScript', 'ExtensionAssetsPath', 'StylePath', 'ResourceBasePath' ];
391  foreach ( $pathGlobals as $path ) {
392  $pathUrls[] = $this->mwConfig->get( $path );
393  }
394  foreach ( $pathUrls as $path ) {
395  $preparedUrl = $this->prepareUrlForCSP( $path );
396  if ( $preparedUrl !== false ) {
397  $additionalSelfUrls[] = $preparedUrl;
398  }
399  }
400  $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
401 
402  foreach ( $RLSources as $wiki => $sources ) {
403  foreach ( $sources as $id => $value ) {
404  $url = $this->prepareUrlForCSP( $value );
405  if ( $url ) {
406  $additionalSelfUrls[] = $url;
407  }
408  }
409  }
410 
411  return array_unique( $additionalSelfUrls );
412  }
413 
426  private function getCORSSources() {
427  $additionalUrls = [];
428  $CORSSources = $this->mwConfig->get( 'CrossSiteAJAXdomains' );
429  foreach ( $CORSSources as $source ) {
430  if ( strpos( $source, '?' ) !== false ) {
431  // CSP doesn't support single char wildcard
432  continue;
433  }
434  $url = $this->prepareUrlForCSP( $source );
435  if ( $url ) {
436  $additionalUrls[] = $url;
437  }
438  }
439  return $additionalUrls;
440  }
441 
449  private function escapeUrlForCSP( $url ) {
450  return str_replace(
451  [ ';', ',' ],
452  [ '%3B', '%2C' ],
453  $url
454  );
455  }
456 
467  public static function falsePositiveBrowser( $ua ) {
468  return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
469  }
470 
477  public static function isNonceRequired( Config $config ) {
478  $configs = [
479  $config->get( 'CSPHeader' ),
480  $config->get( 'CSPReportOnlyHeader' )
481  ];
482  foreach ( $configs as $headerConfig ) {
483  if (
484  $headerConfig === true ||
485  ( is_array( $headerConfig ) &&
486  !isset( $headerConfig['useNonces'] ) ) ||
487  ( is_array( $headerConfig ) &&
488  isset( $headerConfig['useNonces'] ) &&
489  $headerConfig['useNonces'] )
490  ) {
491  return true;
492  }
493  }
494  return false;
495  }
496 }
getAdditionalSelfUrlsScript()
Get additional script sources.
$context
Definition: load.php:45
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
getCORSSources()
include domains that are allowed to send us CORS requests.
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...
getConfig()
Get the site configuration.
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 sendHeaders(IContextSource $context)
Send CSP headers based on wiki config.
static singleton()
Definition: RepoGroup.php:60
__construct( $nonce, WebResponse $response, Config $mwConfig)
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.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
static run( $event, array $args=[], $deprecatedVersion=null)
Call hook functions defined in Hooks::register and $wgHooks.
Definition: Hooks.php:200