MediaWiki master
ContentSecurityPolicy.php
Go to the documentation of this file.
1<?php
22
28use UnexpectedValueException;
29
38 public const REPORT_ONLY_MODE = 1;
39 public const FULL_MODE = 2;
40
42 private $mwConfig;
44 private $response;
45
47 private $extraDefaultSrc = [];
49 private $extraScriptSrc = [];
51 private $extraStyleSrc = [];
52
54 private $hookRunner;
55
65 public function __construct(
66 WebResponse $response,
67 Config $mwConfig,
68 HookContainer $hookContainer
69 ) {
70 $this->response = $response;
71 $this->mwConfig = $mwConfig;
72 $this->hookRunner = new HookRunner( $hookContainer );
73 }
74
83 public function getDirectives() {
84 $cspConfig = $this->mwConfig->get( MainConfigNames::CSPHeader );
85 $cspConfigReportOnly = $this->mwConfig->get( MainConfigNames::CSPReportOnlyHeader );
86
87 $cspPolicy = $this->makeCSPDirectives( $cspConfig, self::FULL_MODE );
88 $cspReportOnlyPolicy = $this->makeCSPDirectives( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
89
90 return array_filter( [
91 $this->getHeaderName( self::FULL_MODE ) => $cspPolicy,
92 $this->getHeaderName( self::REPORT_ONLY_MODE ) => $cspReportOnlyPolicy,
93 ] );
94 }
95
105 public function sendHeaders() {
106 $directives = $this->getDirectives();
107 foreach ( $directives as $headerName => $policy ) {
108 $this->response->header( "$headerName: $policy" );
109 }
110
111 // This used to insert a <meta> tag here, per advice at
112 // https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/
113 // The goal was to prevent nonce from working after the page hit onready,
114 // This would help in old browsers that didn't support nonces, and
115 // also assist for Varnish-cached pages which repeat nonces.
116 // However, this is incompatible with how ResourceLoader runs code
117 // from mw.loader.store, so it was removed.
118 }
119
125 private function getHeaderName( $reportOnly ) {
126 if ( $reportOnly === self::REPORT_ONLY_MODE ) {
127 return 'Content-Security-Policy-Report-Only';
128 }
129
130 if ( $reportOnly === self::FULL_MODE ) {
131 return 'Content-Security-Policy';
132 }
133 throw new UnexpectedValueException( "Mode '$reportOnly' not recognised" );
134 }
135
144 private function makeCSPDirectives( $policyConfig, $mode ) {
145 if ( $policyConfig === false ) {
146 // CSP is disabled
147 return '';
148 }
149 if ( $policyConfig === true ) {
150 $policyConfig = [];
151 }
152
153 $mwConfig = $this->mwConfig;
154
155 if (
156 self::isNonceRequired( $mwConfig ) ||
157 self::isNonceRequiredArray( [ $policyConfig ] )
158 ) {
159 wfDeprecated( 'wgCSPHeader "useNonces" option', '1.41' );
160 }
161
162 $additionalSelfUrls = $this->getAdditionalSelfUrls();
163 $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
164
165 // If no default-src is sent at all, it seems browsers (or at least some),
166 // interpret that as allow anything, but the spec seems to imply that
167 // "data:" and "blob:" should be blocked.
168 $defaultSrc = [ '*', 'data:', 'blob:' ];
169
170 $imgSrc = false;
171 $scriptSrc = [ "'unsafe-eval'", "blob:", "'self'" ];
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 that support 'nonce-foo' sources.
184 // Some older versions of firefox don't follow this rule, but new browsers do.
185 // (Should be for at least Firefox 40+).
186 $scriptSrc[] = "'unsafe-inline'";
187 }
188 // If default source option set to true or an array of urls,
189 // set a restrictive default-src.
190 // If set to false, we send a lenient default-src,
191 // see the code above where $defaultSrc is set initially.
192 if ( isset( $policyConfig['default-src'] )
193 && $policyConfig['default-src'] !== false
194 ) {
195 $defaultSrc = array_merge(
196 [ "'self'", 'data:', 'blob:' ],
197 $additionalSelfUrls
198 );
199 if ( is_array( $policyConfig['default-src'] ) ) {
200 foreach ( $policyConfig['default-src'] as $src ) {
201 $defaultSrc[] = $this->escapeUrlForCSP( $src );
202 }
203 }
204 }
205
206 if ( $policyConfig['includeCORS'] ?? true ) {
207 $CORSUrls = $this->getCORSSources();
208 if ( !in_array( '*', $defaultSrc ) ) {
209 $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
210 }
211 // Unlikely to have * in scriptSrc, but doesn't
212 // hurt to check.
213 if ( !in_array( '*', $scriptSrc ) ) {
214 $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
215 }
216 }
217
218 $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
219 $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
220
221 $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [ "'unsafe-inline'" ] );
222
223 $this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode );
224 $this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode );
225
226 if ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) {
227 if ( $policyConfig['report-uri'] === false ) {
228 $reportUri = false;
229 } else {
230 $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] );
231 }
232 } else {
233 $reportUri = $this->getReportUri( $mode );
234 }
235
236 // Only send an img-src, if we're sending a restrictive default.
237 if ( !is_array( $defaultSrc )
238 || !in_array( '*', $defaultSrc )
239 || !in_array( 'data:', $defaultSrc )
240 || !in_array( 'blob:', $defaultSrc )
241 ) {
242 // A future todo might be to make the allow options only
243 // add all the allowed sites to the header, instead of
244 // allowing all (Assuming there is a small number of sites).
245 // For now, the external image feature disables the limits
246 // CSP puts on external images.
249 ) {
250 $imgSrc = [ '*', 'data:', 'blob:' ];
251 } elseif ( $mwConfig->get( MainConfigNames::EnableImageWhitelist ) ) {
252 $whitelist = wfMessage( 'external_image_whitelist' )
253 ->inContentLanguage()
254 ->plain();
255 if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) {
256 $imgSrc = [ '*', 'data:', 'blob:' ];
257 }
258 }
259 }
260 // Default value 'none'. true is none, false is nothing, string is single directive,
261 // array is list.
262 if ( !isset( $policyConfig['object-src'] ) || $policyConfig['object-src'] === true ) {
263 $objectSrc = [ "'none'" ];
264 } else {
265 $objectSrc = (array)( $policyConfig['object-src'] ?: [] );
266 }
267 $objectSrc = array_map( [ $this, 'escapeUrlForCSP' ], $objectSrc );
268
269 $directives = [];
270 if ( $scriptSrc ) {
271 $directives[] = 'script-src ' . implode( ' ', array_unique( $scriptSrc ) );
272 }
273 if ( $defaultSrc ) {
274 $directives[] = 'default-src ' . implode( ' ', array_unique( $defaultSrc ) );
275 }
276 if ( $cssSrc ) {
277 $directives[] = 'style-src ' . implode( ' ', array_unique( $cssSrc ) );
278 }
279 if ( $imgSrc ) {
280 $directives[] = 'img-src ' . implode( ' ', array_unique( $imgSrc ) );
281 }
282 if ( $objectSrc ) {
283 $directives[] = 'object-src ' . implode( ' ', $objectSrc );
284 }
285 if ( $reportUri ) {
286 $directives[] = 'report-uri ' . $reportUri;
287 }
288
289 $this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode );
290
291 return implode( '; ', $directives );
292 }
293
301 private function getReportUri( $mode ) {
302 $apiArguments = [
303 'action' => 'cspreport',
304 'format' => 'json'
305 ];
306 if ( $mode === self::REPORT_ONLY_MODE ) {
307 $apiArguments['reportonly'] = '1';
308 }
309 $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments );
310
311 // Per spec, ';' and ',' must be hex-escaped in report URI
312 $reportUri = $this->escapeUrlForCSP( $reportUri );
313 return $reportUri;
314 }
315
331 private function prepareUrlForCSP( $url ) {
332 $result = false;
333 if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
334 // A schema source (e.g. blob: or data:)
335 return $url;
336 }
337 $bits = wfParseUrl( $url );
338 if ( !$bits && strpos( $url, '/' ) === false ) {
339 // probably something like example.com.
340 // try again protocol-relative.
341 $url = '//' . $url;
342 $bits = wfParseUrl( $url );
343 }
344 if ( $bits && isset( $bits['host'] )
345 && $bits['host'] !== $this->mwConfig->get( MainConfigNames::ServerName )
346 ) {
347 $result = $bits['host'];
348 if ( $bits['scheme'] !== '' ) {
349 $result = $bits['scheme'] . $bits['delimiter'] . $result;
350 }
351 if ( isset( $bits['port'] ) ) {
352 $result .= ':' . $bits['port'];
353 }
354 $result = $this->escapeUrlForCSP( $result );
355 }
356 return $result;
357 }
358
362 private function getAdditionalSelfUrlsScript() {
363 $additionalUrls = [];
364 // wgExtensionAssetsPath for ?debug=true mode
365 $pathVars = [
369 ];
370
371 foreach ( $pathVars as $path ) {
372 $url = $this->mwConfig->get( $path );
373 $preparedUrl = $this->prepareUrlForCSP( $url );
374 if ( $preparedUrl ) {
375 $additionalUrls[] = $preparedUrl;
376 }
377 }
378 $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
379 foreach ( $RLSources as $sources ) {
380 foreach ( $sources as $value ) {
381 $url = $this->prepareUrlForCSP( $value );
382 if ( $url ) {
383 $additionalUrls[] = $url;
384 }
385 }
386 }
387
388 return array_unique( $additionalUrls );
389 }
390
397 private function getAdditionalSelfUrls() {
398 // XXX on a foreign repo, the included description page can have anything on it,
399 // including inline scripts. But nobody does that.
400
401 // In principle, you can have even more complex configs... (e.g. The urlsByExt option)
402 $pathUrls = [];
403 $additionalSelfUrls = [];
404
405 // Future todo: The zone urls should never go into
406 // style-src. They should either be only in img-src, or if
407 // img-src unspecified they should be in default-src. Similarly,
408 // the DescriptionStylesheetUrl only needs to be in style-src
409 // (or default-src if style-src unspecified).
410 $callback = static function ( $repo, &$urls ) {
411 $urls[] = $repo->getZoneUrl( 'public' );
412 $urls[] = $repo->getZoneUrl( 'transcoded' );
413 $urls[] = $repo->getZoneUrl( 'thumb' );
414 $urls[] = $repo->getDescriptionStylesheetUrl();
415 };
416 $repoGroup = MediaWikiServices::getInstance()->getRepoGroup();
417 $localRepo = $repoGroup->getRepo( 'local' );
418 $callback( $localRepo, $pathUrls );
419 $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] );
420
421 // Globals that might point to a different domain
422 $pathGlobals = [
427 ];
428 foreach ( $pathGlobals as $path ) {
429 $pathUrls[] = $this->mwConfig->get( $path );
430 }
431 foreach ( $pathUrls as $path ) {
432 $preparedUrl = $this->prepareUrlForCSP( $path );
433 if ( $preparedUrl !== false ) {
434 $additionalSelfUrls[] = $preparedUrl;
435 }
436 }
437 $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
438
439 foreach ( $RLSources as $sources ) {
440 foreach ( $sources as $value ) {
441 $url = $this->prepareUrlForCSP( $value );
442 if ( $url ) {
443 $additionalSelfUrls[] = $url;
444 }
445 }
446 }
447
448 return array_unique( $additionalSelfUrls );
449 }
450
463 private function getCORSSources() {
464 $additionalUrls = [];
465 $CORSSources = $this->mwConfig->get( MainConfigNames::CrossSiteAJAXdomains );
466 foreach ( $CORSSources as $source ) {
467 if ( strpos( $source, '?' ) !== false ) {
468 // CSP doesn't support single char wildcard
469 continue;
470 }
471 $url = $this->prepareUrlForCSP( $source );
472 if ( $url ) {
473 $additionalUrls[] = $url;
474 }
475 }
476 return $additionalUrls;
477 }
478
486 private function escapeUrlForCSP( $url ) {
487 return str_replace(
488 [ ';', ',' ],
489 [ '%3B', '%2C' ],
490 $url
491 );
492 }
493
504 public static function falsePositiveBrowser( $ua ) {
505 return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
506 }
507
514 public static function isNonceRequired( Config $config ) {
515 $configs = [
518 ];
519 return self::isNonceRequiredArray( $configs );
520 }
521
528 private static function isNonceRequiredArray( array $configs ) {
529 foreach ( $configs as $headerConfig ) {
530 if (
531 is_array( $headerConfig ) &&
532 isset( $headerConfig['useNonces'] ) &&
533 $headerConfig['useNonces']
534 ) {
535 return true;
536 }
537 }
538 return false;
539 }
540
549 public function getNonce() {
550 return false;
551 }
552
563 public function addDefaultSrc( $source ) {
564 $this->extraDefaultSrc[] = $this->prepareUrlForCSP( $source );
565 }
566
575 public function addStyleSrc( $source ) {
576 $this->extraStyleSrc[] = $this->prepareUrlForCSP( $source );
577 }
578
588 public function addScriptSrc( $source ) {
589 $this->extraScriptSrc[] = $this->prepareUrlForCSP( $source );
590 }
591}
592
594class_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 URL path to a MediaWiki entry point.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
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 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.
Handle sending Content-Security-Policy headers.
static falsePositiveBrowser( $ua)
Does this browser give false positive reports?
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...
getDirectives()
Get the CSP directives for the wiki.
Allow programs to request this object from WebRequest::response() and handle all outputting (or lack ...
Interface for configuration instances.
Definition Config.php:32
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
$source