MediaWiki REL1_35
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}
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.
getAdditionalSelfUrls()
Get additional host names for the wiki (e.g.
static isNonceRequired(Config $config)
Should we set nonce attribute.
prepareUrlForCSP( $url)
Given a url, convert to form needed for CSP.
__construct(WebResponse $response, Config $mwConfig, HookContainer $hookContainer)
addScriptSrc( $source)
Add an additional script src.
makeCSPDirectives( $policyConfig, $mode)
Determine what CSP policies to set for this page.
sendHeaders()
Send CSP headers based on wiki config.
static falsePositiveBrowser( $ua)
Does this browser give false positive reports?
getHeaderName( $reportOnly)
Get the name of the HTTP header to use.
escapeUrlForCSP( $url)
CSP spec says ',' and ';' are not allowed to appear in urls.
addStyleSrc( $source)
Add an additional CSS src.
string $nonce
The nonce to use for inline scripts (from OutputPage)
getReportUri( $mode)
Get the default report uri.
addDefaultSrc( $source)
Add an additional default src.
getCORSSources()
include domains that are allowed to send us CORS requests.
static isNonceRequiredArray(array $configs)
Does a specific config require a nonce.
getAdditionalSelfUrlsScript()
Get additional script sources.
sendCSPHeader( $csp, $reportOnly)
Send a single CSP header based on a given policy config.
getNonce()
Get the nonce if nonce is in use.
Config $mwConfig
The site configuration object.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
MediaWikiServices is the service locator for the application scope of MediaWiki.
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