MediaWiki REL1_37
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
118 private function getHeaderName( $reportOnly ) {
119 if ( $reportOnly === self::REPORT_ONLY_MODE ) {
120 return 'Content-Security-Policy-Report-Only';
121 }
122
123 if ( $reportOnly === self::FULL_MODE ) {
124 return 'Content-Security-Policy';
125 }
126 throw new UnexpectedValueException( "Mode '$reportOnly' not recognised" );
127 }
128
137 private function makeCSPDirectives( $policyConfig, $mode ) {
138 if ( $policyConfig === false ) {
139 // CSP is disabled
140 return '';
141 }
142 if ( $policyConfig === true ) {
143 $policyConfig = [];
144 }
145
147
148 if (
149 !self::isNonceRequired( $mwConfig ) &&
150 self::isNonceRequiredArray( [ $policyConfig ] )
151 ) {
152 // If the current policy requires a nonce, but the global state
153 // does not, that's bad. Throw an exception. This should never happen.
154 throw new LogicException( "Nonce requirement mismatch" );
155 }
156
157 $additionalSelfUrls = $this->getAdditionalSelfUrls();
158 $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
159
160 // If no default-src is sent at all, it
161 // seems browsers (or at least some), interpret
162 // that as allow anything, but the spec seems
163 // to imply that data: and blob: should be
164 // blocked.
165 $defaultSrc = [ '*', 'data:', 'blob:' ];
166
167 $imgSrc = false;
168 $scriptSrc = [ "'unsafe-eval'", "blob:", "'self'" ];
169 if ( $policyConfig['useNonces'] ?? true ) {
170 $scriptSrc[] = "'nonce-" . $this->getNonce() . "'";
171 }
172
173 $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
174 if ( isset( $policyConfig['script-src'] )
175 && is_array( $policyConfig['script-src'] )
176 ) {
177 foreach ( $policyConfig['script-src'] as $src ) {
178 $scriptSrc[] = $this->escapeUrlForCSP( $src );
179 }
180 }
181 // Note: default on if unspecified.
182 if ( $policyConfig['unsafeFallback'] ?? true ) {
183 // unsafe-inline should be ignored on browsers
184 // that support 'nonce-foo' sources.
185 // Some older versions of firefox don't follow this
186 // rule, but new browsers do. (Should be for at least
187 // firefox 40+).
188 $scriptSrc[] = "'unsafe-inline'";
189 }
190 // If default source option set to true or
191 // an array of urls, set a restrictive default-src.
192 // If set to false, we send a lenient default-src,
193 // see the code above where $defaultSrc is set initially.
194 if ( isset( $policyConfig['default-src'] )
195 && $policyConfig['default-src'] !== false
196 ) {
197 $defaultSrc = array_merge(
198 [ "'self'", 'data:', 'blob:' ],
199 $additionalSelfUrls
200 );
201 if ( is_array( $policyConfig['default-src'] ) ) {
202 foreach ( $policyConfig['default-src'] as $src ) {
203 $defaultSrc[] = $this->escapeUrlForCSP( $src );
204 }
205 }
206 }
207
208 if ( $policyConfig['includeCORS'] ?? true ) {
209 $CORSUrls = $this->getCORSSources();
210 if ( !in_array( '*', $defaultSrc ) ) {
211 $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
212 }
213 // Unlikely to have * in scriptSrc, but doesn't
214 // hurt to check.
215 if ( !in_array( '*', $scriptSrc ) ) {
216 $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
217 }
218 }
219
220 $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
221 $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
222
223 $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [ "'unsafe-inline'" ] );
224
225 $this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode );
226 $this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode );
227
228 if ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) {
229 if ( $policyConfig['report-uri'] === false ) {
230 $reportUri = false;
231 } else {
232 $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] );
233 }
234 } else {
235 $reportUri = $this->getReportUri( $mode );
236 }
237
238 // Only send an img-src, if we're sending a restricitve default.
239 if ( !is_array( $defaultSrc )
240 || !in_array( '*', $defaultSrc )
241 || !in_array( 'data:', $defaultSrc )
242 || !in_array( 'blob:', $defaultSrc )
243 ) {
244 // A future todo might be to make the allow options only
245 // add all the allowed sites to the header, instead of
246 // allowing all (Assuming there is a small number of sites).
247 // For now, the external image feature disables the limits
248 // CSP puts on external images.
249 if ( $mwConfig->get( 'AllowExternalImages' )
250 || $mwConfig->get( 'AllowExternalImagesFrom' )
251 || $mwConfig->get( 'AllowImageTag' )
252 ) {
253 $imgSrc = [ '*', 'data:', 'blob:' ];
254 } elseif ( $mwConfig->get( 'EnableImageWhitelist' ) ) {
255 $whitelist = wfMessage( 'external_image_whitelist' )
256 ->inContentLanguage()
257 ->plain();
258 if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) {
259 $imgSrc = [ '*', 'data:', 'blob:' ];
260 }
261 }
262 }
263 // Default value 'none'. true is none, false is nothing, string is single directive,
264 // array is list.
265 if ( !isset( $policyConfig['object-src'] ) || $policyConfig['object-src'] === true ) {
266 $objectSrc = [ "'none'" ];
267 } else {
268 $objectSrc = (array)( $policyConfig['object-src'] ?: [] );
269 }
270 $objectSrc = array_map( [ $this, 'escapeUrlForCSP' ], $objectSrc );
271
272 $directives = [];
273 if ( $scriptSrc ) {
274 $directives[] = 'script-src ' . implode( ' ', array_unique( $scriptSrc ) );
275 }
276 if ( $defaultSrc ) {
277 $directives[] = 'default-src ' . implode( ' ', array_unique( $defaultSrc ) );
278 }
279 if ( $cssSrc ) {
280 $directives[] = 'style-src ' . implode( ' ', array_unique( $cssSrc ) );
281 }
282 if ( $imgSrc ) {
283 $directives[] = 'img-src ' . implode( ' ', array_unique( $imgSrc ) );
284 }
285 if ( $objectSrc ) {
286 $directives[] = 'object-src ' . implode( ' ', $objectSrc );
287 }
288 if ( $reportUri ) {
289 $directives[] = 'report-uri ' . $reportUri;
290 }
291
292 $this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode );
293
294 return implode( '; ', $directives );
295 }
296
304 private function getReportUri( $mode ) {
305 $apiArguments = [
306 'action' => 'cspreport',
307 'format' => 'json'
308 ];
309 if ( $mode === self::REPORT_ONLY_MODE ) {
310 $apiArguments['reportonly'] = '1';
311 }
312 $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments );
313
314 // Per spec, ';' and ',' must be hex-escaped in report URI
315 $reportUri = $this->escapeUrlForCSP( $reportUri );
316 return $reportUri;
317 }
318
334 private function prepareUrlForCSP( $url ) {
335 $result = false;
336 if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
337 // A schema source (e.g. blob: or data:)
338 return $url;
339 }
340 $bits = wfParseUrl( $url );
341 if ( !$bits && strpos( $url, '/' ) === false ) {
342 // probably something like example.com.
343 // try again protocol-relative.
344 $url = '//' . $url;
345 $bits = wfParseUrl( $url );
346 }
347 if ( $bits && isset( $bits['host'] )
348 && $bits['host'] !== $this->mwConfig->get( 'ServerName' )
349 ) {
350 $result = $bits['host'];
351 if ( $bits['scheme'] !== '' ) {
352 $result = $bits['scheme'] . $bits['delimiter'] . $result;
353 }
354 if ( isset( $bits['port'] ) ) {
355 $result .= ':' . $bits['port'];
356 }
357 $result = $this->escapeUrlForCSP( $result );
358 }
359 return $result;
360 }
361
365 private function getAdditionalSelfUrlsScript() {
366 $additionalUrls = [];
367 // wgExtensionAssetsPath for ?debug=true mode
368 $pathVars = [ 'LoadScript', 'ExtensionAssetsPath', 'ResourceBasePath' ];
369
370 foreach ( $pathVars as $path ) {
371 $url = $this->mwConfig->get( $path );
372 $preparedUrl = $this->prepareUrlForCSP( $url );
373 if ( $preparedUrl ) {
374 $additionalUrls[] = $preparedUrl;
375 }
376 }
377 $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
378 foreach ( $RLSources as $wiki => $sources ) {
379 foreach ( $sources as $id => $value ) {
380 $url = $this->prepareUrlForCSP( $value );
381 if ( $url ) {
382 $additionalUrls[] = $url;
383 }
384 }
385 }
386
387 return array_unique( $additionalUrls );
388 }
389
396 private function getAdditionalSelfUrls() {
397 // XXX on a foreign repo, the included description page can have anything on it,
398 // including inline scripts. But nobody sane does that.
399
400 // In principle, you can have even more complex configs... (e.g. The urlsByExt option)
401 $pathUrls = [];
402 $additionalSelfUrls = [];
403
404 // Future todo: The zone urls should never go into
405 // style-src. They should either be only in img-src, or if
406 // img-src unspecified they should be in default-src. Similarly,
407 // the DescriptionStylesheetUrl only needs to be in style-src
408 // (or default-src if style-src unspecified).
409 $callback = static function ( $repo, &$urls ) {
410 $urls[] = $repo->getZoneUrl( 'public' );
411 $urls[] = $repo->getZoneUrl( 'transcoded' );
412 $urls[] = $repo->getZoneUrl( 'thumb' );
413 $urls[] = $repo->getDescriptionStylesheetUrl();
414 };
415 $repoGroup = MediaWikiServices::getInstance()->getRepoGroup();
416 $localRepo = $repoGroup->getRepo( 'local' );
417 $callback( $localRepo, $pathUrls );
418 $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] );
419
420 // Globals that might point to a different domain
421 $pathGlobals = [ 'LoadScript', 'ExtensionAssetsPath', 'StylePath', 'ResourceBasePath' ];
422 foreach ( $pathGlobals as $path ) {
423 $pathUrls[] = $this->mwConfig->get( $path );
424 }
425 foreach ( $pathUrls as $path ) {
426 $preparedUrl = $this->prepareUrlForCSP( $path );
427 if ( $preparedUrl !== false ) {
428 $additionalSelfUrls[] = $preparedUrl;
429 }
430 }
431 $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
432
433 foreach ( $RLSources as $wiki => $sources ) {
434 foreach ( $sources as $id => $value ) {
435 $url = $this->prepareUrlForCSP( $value );
436 if ( $url ) {
437 $additionalSelfUrls[] = $url;
438 }
439 }
440 }
441
442 return array_unique( $additionalSelfUrls );
443 }
444
457 private function getCORSSources() {
458 $additionalUrls = [];
459 $CORSSources = $this->mwConfig->get( 'CrossSiteAJAXdomains' );
460 foreach ( $CORSSources as $source ) {
461 if ( strpos( $source, '?' ) !== false ) {
462 // CSP doesn't support single char wildcard
463 continue;
464 }
465 $url = $this->prepareUrlForCSP( $source );
466 if ( $url ) {
467 $additionalUrls[] = $url;
468 }
469 }
470 return $additionalUrls;
471 }
472
480 private function escapeUrlForCSP( $url ) {
481 return str_replace(
482 [ ';', ',' ],
483 [ '%3B', '%2C' ],
484 $url
485 );
486 }
487
498 public static function falsePositiveBrowser( $ua ) {
499 return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
500 }
501
508 public static function isNonceRequired( Config $config ) {
509 $configs = [
510 $config->get( 'CSPHeader' ),
511 $config->get( 'CSPReportOnlyHeader' )
512 ];
513 return self::isNonceRequiredArray( $configs );
514 }
515
522 private static function isNonceRequiredArray( array $configs ) {
523 foreach ( $configs as $headerConfig ) {
524 if (
525 $headerConfig === true ||
526 ( is_array( $headerConfig ) &&
527 !isset( $headerConfig['useNonces'] ) ) ||
528 ( is_array( $headerConfig ) &&
529 isset( $headerConfig['useNonces'] ) &&
530 $headerConfig['useNonces'] )
531 ) {
532 return true;
533 }
534 }
535 return false;
536 }
537
544 public function getNonce() {
545 if ( !self::isNonceRequired( $this->mwConfig ) ) {
546 return false;
547 }
548 if ( $this->nonce === null ) {
549 $rand = random_bytes( 15 );
550 $this->nonce = base64_encode( $rand );
551 }
552
553 return $this->nonce;
554 }
555
566 public function addDefaultSrc( $source ) {
567 $this->extraDefaultSrc[] = $this->prepareUrlForCSP( $source );
568 }
569
578 public function addStyleSrc( $source ) {
579 $this->extraStyleSrc[] = $this->prepareUrlForCSP( $source );
580 }
581
591 public function addScriptSrc( $source ) {
592 $this->extraScriptSrc[] = $this->prepareUrlForCSP( $source );
593 }
594}
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)
So for example, if an extension added a special page that loaded something it might call $this->getOu...
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?
escapeUrlForCSP( $url)
CSP spec says ',' and ';' are not allowed to appear in urls.
addStyleSrc( $source)
So for example, if an extension added a special page that loaded external CSS it might call $this->ge...
string $nonce
The nonce to use for inline scripts (from OutputPage)
getReportUri( $mode)
Get the default report uri.
addDefaultSrc( $source)
If possible you should use a more specific source type then default.
getCORSSources()
include domains that are allowed to send us CORS requests.
static isNonceRequiredArray(array $configs)
Does a specific config require a nonce.
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