MediaWiki REL1_40
ContentSecurityPolicy.php
Go to the documentation of this file.
1<?php
29
30use Config;
31use LogicException;
36use UnexpectedValueException;
37
39 public const REPORT_ONLY_MODE = 1;
40 public const FULL_MODE = 2;
41
43 private $nonce;
45 private $mwConfig;
47 private $response;
48
50 private $extraDefaultSrc = [];
52 private $extraScriptSrc = [];
54 private $extraStyleSrc = [];
55
57 private $hookRunner;
58
68 public function __construct(
69 WebResponse $response,
70 Config $mwConfig,
71 HookContainer $hookContainer
72 ) {
73 $this->response = $response;
74 $this->mwConfig = $mwConfig;
75 $this->hookRunner = new HookRunner( $hookContainer );
76 }
77
86 public function sendCSPHeader( $csp, $reportOnly ) {
87 $policy = $this->makeCSPDirectives( $csp, $reportOnly );
88 $headerName = $this->getHeaderName( $reportOnly );
89 if ( $policy ) {
90 $this->response->header(
91 "$headerName: $policy"
92 );
93 }
94 }
95
105 public function sendHeaders() {
106 $cspConfig = $this->mwConfig->get( MainConfigNames::CSPHeader );
107 $cspConfigReportOnly = $this->mwConfig->get( MainConfigNames::CSPReportOnlyHeader );
108
109 $this->sendCSPHeader( $cspConfig, self::FULL_MODE );
110 $this->sendCSPHeader( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
111
112 // This used to insert a <meta> tag here, per advice at
113 // https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/
114 // The goal was to prevent nonce from working after the page hit onready,
115 // This would help in old browsers that didn't support nonces, and
116 // also assist for varnish-cached pages which repeat nonces.
117 // However, this is incompatible with how resource loader storage works
118 // via mw.domEval() so it was removed.
119 }
120
126 private function getHeaderName( $reportOnly ) {
127 if ( $reportOnly === self::REPORT_ONLY_MODE ) {
128 return 'Content-Security-Policy-Report-Only';
129 }
130
131 if ( $reportOnly === self::FULL_MODE ) {
132 return 'Content-Security-Policy';
133 }
134 throw new UnexpectedValueException( "Mode '$reportOnly' not recognised" );
135 }
136
145 private function makeCSPDirectives( $policyConfig, $mode ) {
146 if ( $policyConfig === false ) {
147 // CSP is disabled
148 return '';
149 }
150 if ( $policyConfig === true ) {
151 $policyConfig = [];
152 }
153
154 $mwConfig = $this->mwConfig;
155
156 if (
157 !self::isNonceRequired( $mwConfig ) &&
158 self::isNonceRequiredArray( [ $policyConfig ] )
159 ) {
160 // If the current policy requires a nonce, but the global state
161 // does not, that's bad. Throw an exception. This should never happen.
162 throw new LogicException( "Nonce requirement mismatch" );
163 }
164
165 $additionalSelfUrls = $this->getAdditionalSelfUrls();
166 $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
167
168 // If no default-src is sent at all, it
169 // seems browsers (or at least some), interpret
170 // that as allow anything, but the spec seems
171 // to imply that data: and blob: should be
172 // blocked.
173 $defaultSrc = [ '*', 'data:', 'blob:' ];
174
175 $imgSrc = false;
176 $scriptSrc = [ "'unsafe-eval'", "blob:", "'self'" ];
177 if ( $policyConfig['useNonces'] ?? true ) {
178 $scriptSrc[] = "'nonce-" . $this->getNonce() . "'";
179 }
180
181 $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
182 if ( isset( $policyConfig['script-src'] )
183 && is_array( $policyConfig['script-src'] )
184 ) {
185 foreach ( $policyConfig['script-src'] as $src ) {
186 $scriptSrc[] = $this->escapeUrlForCSP( $src );
187 }
188 }
189 // Note: default on if unspecified.
190 if ( $policyConfig['unsafeFallback'] ?? true ) {
191 // unsafe-inline should be ignored on browsers
192 // that support 'nonce-foo' sources.
193 // Some older versions of firefox don't follow this
194 // rule, but new browsers do. (Should be for at least
195 // firefox 40+).
196 $scriptSrc[] = "'unsafe-inline'";
197 }
198 // If default source option set to true or
199 // an array of urls, set a restrictive default-src.
200 // If set to false, we send a lenient default-src,
201 // see the code above where $defaultSrc is set initially.
202 if ( isset( $policyConfig['default-src'] )
203 && $policyConfig['default-src'] !== false
204 ) {
205 $defaultSrc = array_merge(
206 [ "'self'", 'data:', 'blob:' ],
207 $additionalSelfUrls
208 );
209 if ( is_array( $policyConfig['default-src'] ) ) {
210 foreach ( $policyConfig['default-src'] as $src ) {
211 $defaultSrc[] = $this->escapeUrlForCSP( $src );
212 }
213 }
214 }
215
216 if ( $policyConfig['includeCORS'] ?? true ) {
217 $CORSUrls = $this->getCORSSources();
218 if ( !in_array( '*', $defaultSrc ) ) {
219 $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
220 }
221 // Unlikely to have * in scriptSrc, but doesn't
222 // hurt to check.
223 if ( !in_array( '*', $scriptSrc ) ) {
224 $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
225 }
226 }
227
228 $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
229 $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
230
231 $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [ "'unsafe-inline'" ] );
232
233 $this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode );
234 $this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode );
235
236 if ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) {
237 if ( $policyConfig['report-uri'] === false ) {
238 $reportUri = false;
239 } else {
240 $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] );
241 }
242 } else {
243 $reportUri = $this->getReportUri( $mode );
244 }
245
246 // Only send an img-src, if we're sending a restrictive default.
247 if ( !is_array( $defaultSrc )
248 || !in_array( '*', $defaultSrc )
249 || !in_array( 'data:', $defaultSrc )
250 || !in_array( 'blob:', $defaultSrc )
251 ) {
252 // A future todo might be to make the allow options only
253 // add all the allowed sites to the header, instead of
254 // allowing all (Assuming there is a small number of sites).
255 // For now, the external image feature disables the limits
256 // CSP puts on external images.
259 || $mwConfig->get( MainConfigNames::AllowImageTag )
260 ) {
261 $imgSrc = [ '*', 'data:', 'blob:' ];
262 } elseif ( $mwConfig->get( MainConfigNames::EnableImageWhitelist ) ) {
263 $whitelist = wfMessage( 'external_image_whitelist' )
264 ->inContentLanguage()
265 ->plain();
266 if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) {
267 $imgSrc = [ '*', 'data:', 'blob:' ];
268 }
269 }
270 }
271 // Default value 'none'. true is none, false is nothing, string is single directive,
272 // array is list.
273 if ( !isset( $policyConfig['object-src'] ) || $policyConfig['object-src'] === true ) {
274 $objectSrc = [ "'none'" ];
275 } else {
276 $objectSrc = (array)( $policyConfig['object-src'] ?: [] );
277 }
278 $objectSrc = array_map( [ $this, 'escapeUrlForCSP' ], $objectSrc );
279
280 $directives = [];
281 if ( $scriptSrc ) {
282 $directives[] = 'script-src ' . implode( ' ', array_unique( $scriptSrc ) );
283 }
284 if ( $defaultSrc ) {
285 $directives[] = 'default-src ' . implode( ' ', array_unique( $defaultSrc ) );
286 }
287 if ( $cssSrc ) {
288 $directives[] = 'style-src ' . implode( ' ', array_unique( $cssSrc ) );
289 }
290 if ( $imgSrc ) {
291 $directives[] = 'img-src ' . implode( ' ', array_unique( $imgSrc ) );
292 }
293 if ( $objectSrc ) {
294 $directives[] = 'object-src ' . implode( ' ', $objectSrc );
295 }
296 if ( $reportUri ) {
297 $directives[] = 'report-uri ' . $reportUri;
298 }
299
300 $this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode );
301
302 return implode( '; ', $directives );
303 }
304
312 private function getReportUri( $mode ) {
313 $apiArguments = [
314 'action' => 'cspreport',
315 'format' => 'json'
316 ];
317 if ( $mode === self::REPORT_ONLY_MODE ) {
318 $apiArguments['reportonly'] = '1';
319 }
320 $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments );
321
322 // Per spec, ';' and ',' must be hex-escaped in report URI
323 $reportUri = $this->escapeUrlForCSP( $reportUri );
324 return $reportUri;
325 }
326
342 private function prepareUrlForCSP( $url ) {
343 $result = false;
344 if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
345 // A schema source (e.g. blob: or data:)
346 return $url;
347 }
348 $bits = wfParseUrl( $url );
349 if ( !$bits && strpos( $url, '/' ) === false ) {
350 // probably something like example.com.
351 // try again protocol-relative.
352 $url = '//' . $url;
353 $bits = wfParseUrl( $url );
354 }
355 if ( $bits && isset( $bits['host'] )
356 && $bits['host'] !== $this->mwConfig->get( MainConfigNames::ServerName )
357 ) {
358 $result = $bits['host'];
359 if ( $bits['scheme'] !== '' ) {
360 $result = $bits['scheme'] . $bits['delimiter'] . $result;
361 }
362 if ( isset( $bits['port'] ) ) {
363 $result .= ':' . $bits['port'];
364 }
365 $result = $this->escapeUrlForCSP( $result );
366 }
367 return $result;
368 }
369
373 private function getAdditionalSelfUrlsScript() {
374 $additionalUrls = [];
375 // wgExtensionAssetsPath for ?debug=true mode
376 $pathVars = [
380 ];
381
382 foreach ( $pathVars as $path ) {
383 $url = $this->mwConfig->get( $path );
384 $preparedUrl = $this->prepareUrlForCSP( $url );
385 if ( $preparedUrl ) {
386 $additionalUrls[] = $preparedUrl;
387 }
388 }
389 $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
390 foreach ( $RLSources as $sources ) {
391 foreach ( $sources as $value ) {
392 $url = $this->prepareUrlForCSP( $value );
393 if ( $url ) {
394 $additionalUrls[] = $url;
395 }
396 }
397 }
398
399 return array_unique( $additionalUrls );
400 }
401
408 private function getAdditionalSelfUrls() {
409 // XXX on a foreign repo, the included description page can have anything on it,
410 // including inline scripts. But nobody does that.
411
412 // In principle, you can have even more complex configs... (e.g. The urlsByExt option)
413 $pathUrls = [];
414 $additionalSelfUrls = [];
415
416 // Future todo: The zone urls should never go into
417 // style-src. They should either be only in img-src, or if
418 // img-src unspecified they should be in default-src. Similarly,
419 // the DescriptionStylesheetUrl only needs to be in style-src
420 // (or default-src if style-src unspecified).
421 $callback = static function ( $repo, &$urls ) {
422 $urls[] = $repo->getZoneUrl( 'public' );
423 $urls[] = $repo->getZoneUrl( 'transcoded' );
424 $urls[] = $repo->getZoneUrl( 'thumb' );
425 $urls[] = $repo->getDescriptionStylesheetUrl();
426 };
427 $repoGroup = MediaWikiServices::getInstance()->getRepoGroup();
428 $localRepo = $repoGroup->getRepo( 'local' );
429 $callback( $localRepo, $pathUrls );
430 $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] );
431
432 // Globals that might point to a different domain
433 $pathGlobals = [
438 ];
439 foreach ( $pathGlobals as $path ) {
440 $pathUrls[] = $this->mwConfig->get( $path );
441 }
442 foreach ( $pathUrls as $path ) {
443 $preparedUrl = $this->prepareUrlForCSP( $path );
444 if ( $preparedUrl !== false ) {
445 $additionalSelfUrls[] = $preparedUrl;
446 }
447 }
448 $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
449
450 foreach ( $RLSources as $sources ) {
451 foreach ( $sources as $value ) {
452 $url = $this->prepareUrlForCSP( $value );
453 if ( $url ) {
454 $additionalSelfUrls[] = $url;
455 }
456 }
457 }
458
459 return array_unique( $additionalSelfUrls );
460 }
461
474 private function getCORSSources() {
475 $additionalUrls = [];
476 $CORSSources = $this->mwConfig->get( MainConfigNames::CrossSiteAJAXdomains );
477 foreach ( $CORSSources as $source ) {
478 if ( strpos( $source, '?' ) !== false ) {
479 // CSP doesn't support single char wildcard
480 continue;
481 }
482 $url = $this->prepareUrlForCSP( $source );
483 if ( $url ) {
484 $additionalUrls[] = $url;
485 }
486 }
487 return $additionalUrls;
488 }
489
497 private function escapeUrlForCSP( $url ) {
498 return str_replace(
499 [ ';', ',' ],
500 [ '%3B', '%2C' ],
501 $url
502 );
503 }
504
515 public static function falsePositiveBrowser( $ua ) {
516 return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
517 }
518
525 public static function isNonceRequired( Config $config ) {
526 $configs = [
529 ];
530 return self::isNonceRequiredArray( $configs );
531 }
532
539 private static function isNonceRequiredArray( array $configs ) {
540 foreach ( $configs as $headerConfig ) {
541 if (
542 $headerConfig === true ||
543 ( is_array( $headerConfig ) &&
544 !isset( $headerConfig['useNonces'] ) ) ||
545 ( is_array( $headerConfig ) &&
546 isset( $headerConfig['useNonces'] ) &&
547 $headerConfig['useNonces'] )
548 ) {
549 return true;
550 }
551 }
552 return false;
553 }
554
561 public function getNonce() {
562 if ( !self::isNonceRequired( $this->mwConfig ) ) {
563 return false;
564 }
565 $this->nonce ??= base64_encode( random_bytes( 15 ) );
566
567 return $this->nonce;
568 }
569
580 public function addDefaultSrc( $source ) {
581 $this->extraDefaultSrc[] = $this->prepareUrlForCSP( $source );
582 }
583
592 public function addStyleSrc( $source ) {
593 $this->extraStyleSrc[] = $this->prepareUrlForCSP( $source );
594 }
595
605 public function addScriptSrc( $source ) {
606 $this->extraScriptSrc[] = $this->prepareUrlForCSP( $source );
607 }
608}
609
610class_alias( ContentSecurityPolicy::class, 'ContentSecurityPolicy' );
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.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
A class containing constants representing the names of configuration variables.
const ServerName
Name constant for the ServerName setting, for use with Config::get()
const StylePath
Name constant for the StylePath setting, for use with Config::get()
const ExtensionAssetsPath
Name constant for the ExtensionAssetsPath setting, for use with Config::get()
const ResourceBasePath
Name constant for the ResourceBasePath setting, for use with Config::get()
const ResourceLoaderSources
Name constant for the ResourceLoaderSources setting, for use with Config::get()
const CSPHeader
Name constant for the CSPHeader setting, for use with Config::get()
const AllowExternalImages
Name constant for the AllowExternalImages setting, for use with Config::get()
const EnableImageWhitelist
Name constant for the EnableImageWhitelist setting, for use with Config::get()
const AllowImageTag
Name constant for the AllowImageTag setting, for use with Config::get()
const LoadScript
Name constant for the LoadScript setting, for use with Config::get()
const CSPReportOnlyHeader
Name constant for the CSPReportOnlyHeader setting, for use with Config::get()
const AllowExternalImagesFrom
Name constant for the AllowExternalImagesFrom setting, for use with Config::get()
const CrossSiteAJAXdomains
Name constant for the CrossSiteAJAXdomains setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
static falsePositiveBrowser( $ua)
Does this browser give false positive reports?
sendCSPHeader( $csp, $reportOnly)
Send a single CSP header based on a given policy config.
addDefaultSrc( $source)
If possible you should use a more specific source type then default.
sendHeaders()
Send CSP headers based on wiki config.
getNonce()
Get the nonce if nonce is in use.
addStyleSrc( $source)
So for example, if an extension added a special page that loaded external CSS it might call $this->ge...
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...
Allow programs to request this object from WebRequest::response() and handle all outputting (or lack ...
Interface for configuration instances.
Definition Config.php:30
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
$source