MediaWiki REL1_39
ContentSecurityPolicy.php
Go to the documentation of this file.
1<?php
32
34 public const REPORT_ONLY_MODE = 1;
35 public const FULL_MODE = 2;
36
38 private $nonce;
40 private $mwConfig;
42 private $response;
43
45 private $extraDefaultSrc = [];
47 private $extraScriptSrc = [];
49 private $extraStyleSrc = [];
50
52 private $hookRunner;
53
63 public function __construct( WebResponse $response, Config $mwConfig,
64 HookContainer $hookContainer
65 ) {
66 $this->response = $response;
67 $this->mwConfig = $mwConfig;
68 $this->hookRunner = new HookRunner( $hookContainer );
69 }
70
79 public function sendCSPHeader( $csp, $reportOnly ) {
80 $policy = $this->makeCSPDirectives( $csp, $reportOnly );
81 $headerName = $this->getHeaderName( $reportOnly );
82 if ( $policy ) {
83 $this->response->header(
84 "$headerName: $policy"
85 );
86 }
87 }
88
98 public function sendHeaders() {
99 $cspConfig = $this->mwConfig->get( MainConfigNames::CSPHeader );
100 $cspConfigReportOnly = $this->mwConfig->get( MainConfigNames::CSPReportOnlyHeader );
101
102 $this->sendCSPHeader( $cspConfig, self::FULL_MODE );
103 $this->sendCSPHeader( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
104
105 // This used to insert a <meta> tag here, per advice at
106 // https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/
107 // The goal was to prevent nonce from working after the page hit onready,
108 // This would help in old browsers that didn't support nonces, and
109 // also assist for varnish-cached pages which repeat nonces.
110 // However, this is incompatible with how resource loader storage works
111 // via mw.domEval() so it was removed.
112 }
113
119 private function getHeaderName( $reportOnly ) {
120 if ( $reportOnly === self::REPORT_ONLY_MODE ) {
121 return 'Content-Security-Policy-Report-Only';
122 }
123
124 if ( $reportOnly === self::FULL_MODE ) {
125 return 'Content-Security-Policy';
126 }
127 throw new UnexpectedValueException( "Mode '$reportOnly' not recognised" );
128 }
129
138 private function makeCSPDirectives( $policyConfig, $mode ) {
139 if ( $policyConfig === false ) {
140 // CSP is disabled
141 return '';
142 }
143 if ( $policyConfig === true ) {
144 $policyConfig = [];
145 }
146
147 $mwConfig = $this->mwConfig;
148
149 if (
150 !self::isNonceRequired( $mwConfig ) &&
151 self::isNonceRequiredArray( [ $policyConfig ] )
152 ) {
153 // If the current policy requires a nonce, but the global state
154 // does not, that's bad. Throw an exception. This should never happen.
155 throw new LogicException( "Nonce requirement mismatch" );
156 }
157
158 $additionalSelfUrls = $this->getAdditionalSelfUrls();
159 $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
160
161 // If no default-src is sent at all, it
162 // seems browsers (or at least some), interpret
163 // that as allow anything, but the spec seems
164 // to imply that data: and blob: should be
165 // blocked.
166 $defaultSrc = [ '*', 'data:', 'blob:' ];
167
168 $imgSrc = false;
169 $scriptSrc = [ "'unsafe-eval'", "blob:", "'self'" ];
170 if ( $policyConfig['useNonces'] ?? true ) {
171 $scriptSrc[] = "'nonce-" . $this->getNonce() . "'";
172 }
173
174 $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
175 if ( isset( $policyConfig['script-src'] )
176 && is_array( $policyConfig['script-src'] )
177 ) {
178 foreach ( $policyConfig['script-src'] as $src ) {
179 $scriptSrc[] = $this->escapeUrlForCSP( $src );
180 }
181 }
182 // Note: default on if unspecified.
183 if ( $policyConfig['unsafeFallback'] ?? true ) {
184 // unsafe-inline should be ignored on browsers
185 // that support 'nonce-foo' sources.
186 // Some older versions of firefox don't follow this
187 // rule, but new browsers do. (Should be for at least
188 // firefox 40+).
189 $scriptSrc[] = "'unsafe-inline'";
190 }
191 // If default source option set to true or
192 // an array of urls, set a restrictive default-src.
193 // If set to false, we send a lenient default-src,
194 // see the code above where $defaultSrc is set initially.
195 if ( isset( $policyConfig['default-src'] )
196 && $policyConfig['default-src'] !== false
197 ) {
198 $defaultSrc = array_merge(
199 [ "'self'", 'data:', 'blob:' ],
200 $additionalSelfUrls
201 );
202 if ( is_array( $policyConfig['default-src'] ) ) {
203 foreach ( $policyConfig['default-src'] as $src ) {
204 $defaultSrc[] = $this->escapeUrlForCSP( $src );
205 }
206 }
207 }
208
209 if ( $policyConfig['includeCORS'] ?? true ) {
210 $CORSUrls = $this->getCORSSources();
211 if ( !in_array( '*', $defaultSrc ) ) {
212 $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
213 }
214 // Unlikely to have * in scriptSrc, but doesn't
215 // hurt to check.
216 if ( !in_array( '*', $scriptSrc ) ) {
217 $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
218 }
219 }
220
221 $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
222 $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
223
224 $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [ "'unsafe-inline'" ] );
225
226 $this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode );
227 $this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode );
228
229 if ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) {
230 if ( $policyConfig['report-uri'] === false ) {
231 $reportUri = false;
232 } else {
233 $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] );
234 }
235 } else {
236 $reportUri = $this->getReportUri( $mode );
237 }
238
239 // Only send an img-src, if we're sending a restrictive default.
240 if ( !is_array( $defaultSrc )
241 || !in_array( '*', $defaultSrc )
242 || !in_array( 'data:', $defaultSrc )
243 || !in_array( 'blob:', $defaultSrc )
244 ) {
245 // A future todo might be to make the allow options only
246 // add all the allowed sites to the header, instead of
247 // allowing all (Assuming there is a small number of sites).
248 // For now, the external image feature disables the limits
249 // CSP puts on external images.
250 if ( $mwConfig->get( MainConfigNames::AllowExternalImages )
251 || $mwConfig->get( MainConfigNames::AllowExternalImagesFrom )
252 || $mwConfig->get( MainConfigNames::AllowImageTag )
253 ) {
254 $imgSrc = [ '*', 'data:', 'blob:' ];
255 } elseif ( $mwConfig->get( MainConfigNames::EnableImageWhitelist ) ) {
256 $whitelist = wfMessage( 'external_image_whitelist' )
257 ->inContentLanguage()
258 ->plain();
259 if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) {
260 $imgSrc = [ '*', 'data:', 'blob:' ];
261 }
262 }
263 }
264 // Default value 'none'. true is none, false is nothing, string is single directive,
265 // array is list.
266 if ( !isset( $policyConfig['object-src'] ) || $policyConfig['object-src'] === true ) {
267 $objectSrc = [ "'none'" ];
268 } else {
269 $objectSrc = (array)( $policyConfig['object-src'] ?: [] );
270 }
271 $objectSrc = array_map( [ $this, 'escapeUrlForCSP' ], $objectSrc );
272
273 $directives = [];
274 if ( $scriptSrc ) {
275 $directives[] = 'script-src ' . implode( ' ', array_unique( $scriptSrc ) );
276 }
277 if ( $defaultSrc ) {
278 $directives[] = 'default-src ' . implode( ' ', array_unique( $defaultSrc ) );
279 }
280 if ( $cssSrc ) {
281 $directives[] = 'style-src ' . implode( ' ', array_unique( $cssSrc ) );
282 }
283 if ( $imgSrc ) {
284 $directives[] = 'img-src ' . implode( ' ', array_unique( $imgSrc ) );
285 }
286 if ( $objectSrc ) {
287 $directives[] = 'object-src ' . implode( ' ', $objectSrc );
288 }
289 if ( $reportUri ) {
290 $directives[] = 'report-uri ' . $reportUri;
291 }
292
293 $this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode );
294
295 return implode( '; ', $directives );
296 }
297
305 private function getReportUri( $mode ) {
306 $apiArguments = [
307 'action' => 'cspreport',
308 'format' => 'json'
309 ];
310 if ( $mode === self::REPORT_ONLY_MODE ) {
311 $apiArguments['reportonly'] = '1';
312 }
313 $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments );
314
315 // Per spec, ';' and ',' must be hex-escaped in report URI
316 $reportUri = $this->escapeUrlForCSP( $reportUri );
317 return $reportUri;
318 }
319
335 private function prepareUrlForCSP( $url ) {
336 $result = false;
337 if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
338 // A schema source (e.g. blob: or data:)
339 return $url;
340 }
341 $bits = wfParseUrl( $url );
342 if ( !$bits && strpos( $url, '/' ) === false ) {
343 // probably something like example.com.
344 // try again protocol-relative.
345 $url = '//' . $url;
346 $bits = wfParseUrl( $url );
347 }
348 if ( $bits && isset( $bits['host'] )
349 && $bits['host'] !== $this->mwConfig->get( MainConfigNames::ServerName )
350 ) {
351 $result = $bits['host'];
352 if ( $bits['scheme'] !== '' ) {
353 $result = $bits['scheme'] . $bits['delimiter'] . $result;
354 }
355 if ( isset( $bits['port'] ) ) {
356 $result .= ':' . $bits['port'];
357 }
358 $result = $this->escapeUrlForCSP( $result );
359 }
360 return $result;
361 }
362
366 private function getAdditionalSelfUrlsScript() {
367 $additionalUrls = [];
368 // wgExtensionAssetsPath for ?debug=true mode
369 $pathVars = [ MainConfigNames::LoadScript, MainConfigNames::ExtensionAssetsPath,
370 MainConfigNames::ResourceBasePath ];
371
372 foreach ( $pathVars as $path ) {
373 $url = $this->mwConfig->get( $path );
374 $preparedUrl = $this->prepareUrlForCSP( $url );
375 if ( $preparedUrl ) {
376 $additionalUrls[] = $preparedUrl;
377 }
378 }
379 $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
380 foreach ( $RLSources as $wiki => $sources ) {
381 foreach ( $sources as $id => $value ) {
382 $url = $this->prepareUrlForCSP( $value );
383 if ( $url ) {
384 $additionalUrls[] = $url;
385 }
386 }
387 }
388
389 return array_unique( $additionalUrls );
390 }
391
398 private function getAdditionalSelfUrls() {
399 // XXX on a foreign repo, the included description page can have anything on it,
400 // including inline scripts. But nobody does that.
401
402 // In principle, you can have even more complex configs... (e.g. The urlsByExt option)
403 $pathUrls = [];
404 $additionalSelfUrls = [];
405
406 // Future todo: The zone urls should never go into
407 // style-src. They should either be only in img-src, or if
408 // img-src unspecified they should be in default-src. Similarly,
409 // the DescriptionStylesheetUrl only needs to be in style-src
410 // (or default-src if style-src unspecified).
411 $callback = static function ( $repo, &$urls ) {
412 $urls[] = $repo->getZoneUrl( 'public' );
413 $urls[] = $repo->getZoneUrl( 'transcoded' );
414 $urls[] = $repo->getZoneUrl( 'thumb' );
415 $urls[] = $repo->getDescriptionStylesheetUrl();
416 };
417 $repoGroup = MediaWikiServices::getInstance()->getRepoGroup();
418 $localRepo = $repoGroup->getRepo( 'local' );
419 $callback( $localRepo, $pathUrls );
420 $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] );
421
422 // Globals that might point to a different domain
423 $pathGlobals = [ MainConfigNames::LoadScript, MainConfigNames::ExtensionAssetsPath,
424 MainConfigNames::StylePath, MainConfigNames::ResourceBasePath ];
425 foreach ( $pathGlobals as $path ) {
426 $pathUrls[] = $this->mwConfig->get( $path );
427 }
428 foreach ( $pathUrls as $path ) {
429 $preparedUrl = $this->prepareUrlForCSP( $path );
430 if ( $preparedUrl !== false ) {
431 $additionalSelfUrls[] = $preparedUrl;
432 }
433 }
434 $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
435
436 foreach ( $RLSources as $wiki => $sources ) {
437 foreach ( $sources as $id => $value ) {
438 $url = $this->prepareUrlForCSP( $value );
439 if ( $url ) {
440 $additionalSelfUrls[] = $url;
441 }
442 }
443 }
444
445 return array_unique( $additionalSelfUrls );
446 }
447
460 private function getCORSSources() {
461 $additionalUrls = [];
462 $CORSSources = $this->mwConfig->get( MainConfigNames::CrossSiteAJAXdomains );
463 foreach ( $CORSSources as $source ) {
464 if ( strpos( $source, '?' ) !== false ) {
465 // CSP doesn't support single char wildcard
466 continue;
467 }
468 $url = $this->prepareUrlForCSP( $source );
469 if ( $url ) {
470 $additionalUrls[] = $url;
471 }
472 }
473 return $additionalUrls;
474 }
475
483 private function escapeUrlForCSP( $url ) {
484 return str_replace(
485 [ ';', ',' ],
486 [ '%3B', '%2C' ],
487 $url
488 );
489 }
490
501 public static function falsePositiveBrowser( $ua ) {
502 return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
503 }
504
511 public static function isNonceRequired( Config $config ) {
512 $configs = [
513 $config->get( MainConfigNames::CSPHeader ),
514 $config->get( MainConfigNames::CSPReportOnlyHeader )
515 ];
516 return self::isNonceRequiredArray( $configs );
517 }
518
525 private static function isNonceRequiredArray( array $configs ) {
526 foreach ( $configs as $headerConfig ) {
527 if (
528 $headerConfig === true ||
529 ( is_array( $headerConfig ) &&
530 !isset( $headerConfig['useNonces'] ) ) ||
531 ( is_array( $headerConfig ) &&
532 isset( $headerConfig['useNonces'] ) &&
533 $headerConfig['useNonces'] )
534 ) {
535 return true;
536 }
537 }
538 return false;
539 }
540
547 public function getNonce() {
548 if ( !self::isNonceRequired( $this->mwConfig ) ) {
549 return false;
550 }
551 if ( $this->nonce === null ) {
552 $rand = random_bytes( 15 );
553 $this->nonce = base64_encode( $rand );
554 }
555
556 return $this->nonce;
557 }
558
569 public function addDefaultSrc( $source ) {
570 $this->extraDefaultSrc[] = $this->prepareUrlForCSP( $source );
571 }
572
581 public function addStyleSrc( $source ) {
582 $this->extraStyleSrc[] = $this->prepareUrlForCSP( $source );
583 }
584
594 public function addScriptSrc( $source ) {
595 $this->extraScriptSrc[] = $this->prepareUrlForCSP( $source );
596 }
597}
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.
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...
sendHeaders()
Send CSP headers based on wiki config.
static falsePositiveBrowser( $ua)
Does this browser give false positive reports?
addStyleSrc( $source)
So for example, if an extension added a special page that loaded external CSS it might call $this->ge...
addDefaultSrc( $source)
If possible you should use a more specific source type then default.
sendCSPHeader( $csp, $reportOnly)
Send a single CSP header based on a given policy config.
getNonce()
Get the nonce if nonce is in use.
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.
Service locator for MediaWiki core services.
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