MediaWiki master
ContentSecurityPolicy.php
Go to the documentation of this file.
1<?php
8
14use UnexpectedValueException;
15
24 public const REPORT_ONLY_MODE = 1;
25 public const FULL_MODE = 2;
26
27 // Used for uploaded files. Keep in sync with images/.htaccess
28 private const UPLOAD_CSP = "default-src 'none'; style-src 'unsafe-inline' data:;" .
29 "font-src data:; img-src data: 'self'; media-src data: 'self'; sandbox";
30 private const UPLOAD_CSP_PDF = "default-src 'none'; style-src 'unsafe-inline' data:; object-src 'self';" .
31 "font-src data:; img-src data: 'self'; media-src data: 'self';";
32
34 private $mwConfig;
36 private $response;
37
39 private $extraDefaultSrc = [];
41 private $extraScriptSrc = [];
43 private $extraStyleSrc = [];
44
46 private $hookRunner;
47
57 public function __construct(
58 WebResponse $response,
59 Config $mwConfig,
60 HookContainer $hookContainer
61 ) {
62 $this->response = $response;
63 $this->mwConfig = $mwConfig;
64 $this->hookRunner = new HookRunner( $hookContainer );
65 }
66
75 public function getDirectives() {
76 $cspConfig = $this->mwConfig->get( MainConfigNames::CSPHeader );
77 $cspConfigReportOnly = $this->mwConfig->get( MainConfigNames::CSPReportOnlyHeader );
78
79 $cspPolicy = $this->makeCSPDirectives( $cspConfig, self::FULL_MODE );
80 $cspReportOnlyPolicy = $this->makeCSPDirectives( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
81
82 return array_filter( [
83 $this->getHeaderName( self::FULL_MODE ) => $cspPolicy,
84 $this->getHeaderName( self::REPORT_ONLY_MODE ) => $cspReportOnlyPolicy,
85 ] );
86 }
87
97 public function sendHeaders() {
98 $directives = $this->getDirectives();
99 foreach ( $directives as $headerName => $policy ) {
100 $this->response->header( "$headerName: $policy" );
101 }
102
103 // This used to insert a <meta> tag here, per advice at
104 // https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/
105 // The goal was to prevent nonce from working after the page hit onready,
106 // This would help in old browsers that didn't support nonces, and
107 // also assist for Varnish-cached pages which repeat nonces.
108 // However, this is incompatible with how ResourceLoader runs code
109 // from mw.loader.store, so it was removed.
110 }
111
117 private function getHeaderName( $reportOnly ) {
118 if ( $reportOnly === self::REPORT_ONLY_MODE ) {
119 return 'Content-Security-Policy-Report-Only';
120 }
121
122 if ( $reportOnly === self::FULL_MODE ) {
123 return 'Content-Security-Policy';
124 }
125 throw new UnexpectedValueException( "Mode '$reportOnly' not recognised" );
126 }
127
136 private function makeCSPDirectives( $policyConfig, $mode ) {
137 if ( $policyConfig === false ) {
138 // CSP is disabled
139 return '';
140 }
141 if ( $policyConfig === true ) {
142 $policyConfig = [];
143 }
144
145 $mwConfig = $this->mwConfig;
146
147 if (
148 self::isNonceRequired( $mwConfig ) ||
149 self::isNonceRequiredArray( [ $policyConfig ] )
150 ) {
151 wfDeprecated( 'wgCSPHeader "useNonces" option', '1.41' );
152 }
153
154 $additionalSelfUrls = $this->getAdditionalSelfUrls();
155 $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
156
157 // If no default-src is sent at all, it seems browsers (or at least some),
158 // interpret that as allow anything, but the spec seems to imply that
159 // "data:" and "blob:" should be blocked.
160 $defaultSrc = [ '*', 'data:', 'blob:' ];
161
162 $imgSrc = false;
163 $scriptSrc = [ "'unsafe-eval'", "blob:", "'self'" ];
164
165 $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
166 if ( isset( $policyConfig['script-src'] )
167 && is_array( $policyConfig['script-src'] )
168 ) {
169 foreach ( $policyConfig['script-src'] as $src ) {
170 $scriptSrc[] = $this->escapeUrlForCSP( $src );
171 }
172 }
173 // Note: default on if unspecified.
174 if ( $policyConfig['unsafeFallback'] ?? true ) {
175 // unsafe-inline should be ignored on browsers that support 'nonce-foo' sources.
176 // Some older versions of firefox don't follow this rule, but new browsers do.
177 // (Should be for at least Firefox 40+).
178 $scriptSrc[] = "'unsafe-inline'";
179 }
180 // If default source option set to true or an array of urls,
181 // set a restrictive default-src.
182 // If set to false, we send a lenient default-src,
183 // see the code above where $defaultSrc is set initially.
184 if ( isset( $policyConfig['default-src'] )
185 && $policyConfig['default-src'] !== false
186 ) {
187 $defaultSrc = [ "'self'", 'data:', 'blob:', ...$additionalSelfUrls ];
188 if ( is_array( $policyConfig['default-src'] ) ) {
189 foreach ( $policyConfig['default-src'] as $src ) {
190 $defaultSrc[] = $this->escapeUrlForCSP( $src );
191 }
192 }
193 }
194
195 if ( $policyConfig['includeCORS'] ?? true ) {
196 $CORSUrls = $this->getCORSSources();
197 if ( !in_array( '*', $defaultSrc ) ) {
198 $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
199 }
200 // Unlikely to have * in scriptSrc, but doesn't
201 // hurt to check.
202 if ( !in_array( '*', $scriptSrc ) ) {
203 $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
204 }
205 }
206
207 $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
208 $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
209
210 $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [ "'unsafe-inline'" ] );
211
212 $this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode );
213 $this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode );
214
215 if ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) {
216 if ( $policyConfig['report-uri'] === false ) {
217 $reportUri = false;
218 } else {
219 $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] );
220 }
221 } else {
222 $reportUri = $this->getReportUri( $mode );
223 }
224
225 // Only send an img-src, if we're sending a restrictive default.
226 if ( !is_array( $defaultSrc )
227 || !in_array( '*', $defaultSrc )
228 || !in_array( 'data:', $defaultSrc )
229 || !in_array( 'blob:', $defaultSrc )
230 ) {
231 // A future todo might be to make the allow options only
232 // add all the allowed sites to the header, instead of
233 // allowing all (Assuming there is a small number of sites).
234 // For now, the external image feature disables the limits
235 // CSP puts on external images.
238 ) {
239 $imgSrc = [ '*', 'data:', 'blob:' ];
240 } elseif ( $mwConfig->get( MainConfigNames::EnableImageWhitelist ) ) {
241 $whitelist = wfMessage( 'external_image_whitelist' )
242 ->inContentLanguage()
243 ->plain();
244 if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) {
245 $imgSrc = [ '*', 'data:', 'blob:' ];
246 }
247 }
248 }
249 // Default value 'none'. true is none, false is nothing, string is single directive,
250 // array is list.
251 if ( !isset( $policyConfig['object-src'] ) || $policyConfig['object-src'] === true ) {
252 $objectSrc = [ "'none'" ];
253 } else {
254 $objectSrc = (array)( $policyConfig['object-src'] ?: [] );
255 }
256 $objectSrc = array_map( $this->escapeUrlForCSP( ... ), $objectSrc );
257
258 $directives = [];
259 if ( $scriptSrc ) {
260 $directives[] = 'script-src ' . implode( ' ', array_unique( $scriptSrc ) );
261 }
262 if ( $defaultSrc ) {
263 $directives[] = 'default-src ' . implode( ' ', array_unique( $defaultSrc ) );
264 }
265 if ( $cssSrc ) {
266 $directives[] = 'style-src ' . implode( ' ', array_unique( $cssSrc ) );
267 }
268 if ( $imgSrc ) {
269 $directives[] = 'img-src ' . implode( ' ', array_unique( $imgSrc ) );
270 }
271 if ( $objectSrc ) {
272 $directives[] = 'object-src ' . implode( ' ', $objectSrc );
273 }
274 if ( $reportUri ) {
275 $directives[] = 'report-uri ' . $reportUri;
276 }
277
278 $this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode );
279
280 return implode( '; ', $directives );
281 }
282
290 private function getReportUri( $mode ) {
291 $apiArguments = [
292 'action' => 'cspreport',
293 'format' => 'json'
294 ];
295 if ( $mode === self::REPORT_ONLY_MODE ) {
296 $apiArguments['reportonly'] = '1';
297 }
298 $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments );
299
300 // Per spec, ';' and ',' must be hex-escaped in report URI
301 $reportUri = $this->escapeUrlForCSP( $reportUri );
302 return $reportUri;
303 }
304
320 private function prepareUrlForCSP( $url ) {
321 $result = false;
322 if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
323 // A schema source (e.g. blob: or data:)
324 return $url;
325 }
326 $bits = wfGetUrlUtils()->parse( $url );
327 if ( !$bits && !str_contains( $url, '/' ) ) {
328 // probably something like example.com.
329 // try again protocol-relative.
330 $url = '//' . $url;
331 $bits = wfGetUrlUtils()->parse( $url );
332 }
333 if ( $bits && isset( $bits['host'] )
334 && $bits['host'] !== $this->mwConfig->get( MainConfigNames::ServerName )
335 ) {
336 $result = $bits['host'];
337 if ( $bits['scheme'] !== '' ) {
338 $result = $bits['scheme'] . $bits['delimiter'] . $result;
339 }
340 if ( isset( $bits['port'] ) ) {
341 $result .= ':' . $bits['port'];
342 }
343 $result = $this->escapeUrlForCSP( $result );
344 }
345 return $result;
346 }
347
351 private function getAdditionalSelfUrlsScript() {
352 $additionalUrls = [];
353 // wgExtensionAssetsPath for ?debug=true mode
354 $pathVars = [
358 ];
359
360 foreach ( $pathVars as $path ) {
361 $url = $this->mwConfig->get( $path );
362 $preparedUrl = $this->prepareUrlForCSP( $url );
363 if ( $preparedUrl ) {
364 $additionalUrls[] = $preparedUrl;
365 }
366 }
367 $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
368 foreach ( $RLSources as $sources ) {
369 foreach ( $sources as $value ) {
370 $url = $this->prepareUrlForCSP( $value );
371 if ( $url ) {
372 $additionalUrls[] = $url;
373 }
374 }
375 }
376
377 return array_unique( $additionalUrls );
378 }
379
386 private function getAdditionalSelfUrls() {
387 // XXX on a foreign repo, the included description page can have anything on it,
388 // including inline scripts. But nobody does that.
389
390 // In principle, you can have even more complex configs... (e.g. The urlsByExt option)
391 $pathUrls = [];
392 $additionalSelfUrls = [];
393
394 // Future todo: The zone urls should never go into
395 // style-src. They should either be only in img-src, or if
396 // img-src unspecified they should be in default-src. Similarly,
397 // the DescriptionStylesheetUrl only needs to be in style-src
398 // (or default-src if style-src unspecified).
399 $callback = static function ( $repo, &$urls ) {
400 $urls[] = $repo->getZoneUrl( 'public' );
401 $urls[] = $repo->getZoneUrl( 'transcoded' );
402 $urls[] = $repo->getZoneUrl( 'thumb' );
403 $urls[] = $repo->getDescriptionStylesheetUrl();
404 };
405 $repoGroup = MediaWikiServices::getInstance()->getRepoGroup();
406 $localRepo = $repoGroup->getRepo( 'local' );
407 $callback( $localRepo, $pathUrls );
408 $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] );
409
410 // Globals that might point to a different domain
411 $pathGlobals = [
416 ];
417 foreach ( $pathGlobals as $path ) {
418 $pathUrls[] = $this->mwConfig->get( $path );
419 }
420 foreach ( $pathUrls as $path ) {
421 $preparedUrl = $this->prepareUrlForCSP( $path );
422 if ( $preparedUrl !== false ) {
423 $additionalSelfUrls[] = $preparedUrl;
424 }
425 }
426 $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
427
428 foreach ( $RLSources as $sources ) {
429 foreach ( $sources as $value ) {
430 $url = $this->prepareUrlForCSP( $value );
431 if ( $url ) {
432 $additionalSelfUrls[] = $url;
433 }
434 }
435 }
436
437 return array_unique( $additionalSelfUrls );
438 }
439
452 private function getCORSSources() {
453 $additionalUrls = [];
454 $CORSSources = $this->mwConfig->get( MainConfigNames::CrossSiteAJAXdomains );
455 foreach ( $CORSSources as $source ) {
456 if ( str_contains( $source, '?' ) ) {
457 // CSP doesn't support single char wildcard
458 continue;
459 }
460 $url = $this->prepareUrlForCSP( $source );
461 if ( $url ) {
462 $additionalUrls[] = $url;
463 }
464 }
465 return $additionalUrls;
466 }
467
475 private function escapeUrlForCSP( $url ) {
476 return str_replace(
477 [ ';', ',' ],
478 [ '%3B', '%2C' ],
479 $url
480 );
481 }
482
493 public static function falsePositiveBrowser( $ua ) {
494 return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
495 }
496
503 public static function isNonceRequired( Config $config ) {
504 $configs = [
507 ];
508 return self::isNonceRequiredArray( $configs );
509 }
510
517 private static function isNonceRequiredArray( array $configs ) {
518 foreach ( $configs as $headerConfig ) {
519 if (
520 is_array( $headerConfig ) &&
521 isset( $headerConfig['useNonces'] ) &&
522 $headerConfig['useNonces']
523 ) {
524 return true;
525 }
526 }
527 return false;
528 }
529
538 public function getNonce() {
539 return false;
540 }
541
552 public function addDefaultSrc( $source ) {
553 $this->extraDefaultSrc[] = $this->prepareUrlForCSP( $source );
554 }
555
564 public function addStyleSrc( $source ) {
565 $this->extraStyleSrc[] = $this->prepareUrlForCSP( $source );
566 }
567
577 public function addScriptSrc( $source ) {
578 $this->extraScriptSrc[] = $this->prepareUrlForCSP( $source );
579 }
580
591 public static function getMediaHeader( string $filename ) {
592 $config = MediaWikiServices::getInstance()->getMainConfig();
593 if ( !$config->get( MainConfigNames::CSPUploadEntryPoint ) ) {
594 return null;
595 }
596 // Some browsers (Chrome) require allowing objects to
597 // render PDFs. Generally plugins are slightly higher-risk
598 // so only allow it on pdf files.
599 if ( strtolower( substr( $filename, -4 ) ) === '.pdf' ) {
600 return self::UPLOAD_CSP_PDF;
601 }
602 return self::UPLOAD_CSP;
603 }
604
615 public static function sendRestrictiveHeader() {
616 // Intentionally don't use WebResponse, since we want to use this in
617 // exception handler, so avoid unnecessary dependencies. Still allow
618 // default-src of 'self' for favicon and whatnot. This doesn't include
619 // default-src data or style-src 'unsafe-inline', which would be fairly
620 // safe, but we are trying to be minimal here.
621 header( "Content-Security-Policy: default-src 'self'; script-src 'none'; object-src 'none'" );
622 }
623}
wfGetUrlUtils()
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 CSPUploadEntryPoint
Name constant for the CSPUploadEntryPoint 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.
static getMediaHeader(string $filename)
Get the CSP header for a specific file.
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.
static sendRestrictiveHeader()
Output a very restrictive CSP header to disallow all active content.
Allow programs to request this object from WebRequest::response() and handle all outputting (or lack ...
Interface for configuration instances.
Definition Config.php:18
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
$source