MediaWiki REL1_34
ContentSecurityPolicy.php
Go to the documentation of this file.
1<?php
29 const FULL_MODE = 2;
30
32 private $nonce;
34 private $mwConfig;
36 private $response;
37
44 $this->nonce = $nonce;
45 $this->response = $response;
46 $this->mwConfig = $mwConfig;
47 }
48
56 public function sendCSPHeader( $csp, $reportOnly ) {
57 $policy = $this->makeCSPDirectives( $csp, $reportOnly );
58 $headerName = $this->getHeaderName( $reportOnly );
59 if ( $policy ) {
60 $this->response->header(
61 "$headerName: $policy"
62 );
63 }
64 }
65
73 public static function sendHeaders( IContextSource $context ) {
74 $out = $context->getOutput();
75 $csp = new ContentSecurityPolicy(
76 $out->getCSPNonce(),
77 $context->getRequest()->response(),
78 $context->getConfig()
79 );
80
81 $cspConfig = $context->getConfig()->get( 'CSPHeader' );
82 $cspConfigReportOnly = $context->getConfig()->get( 'CSPReportOnlyHeader' );
83
84 $csp->sendCSPHeader( $cspConfig, self::FULL_MODE );
85 $csp->sendCSPHeader( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
86
87 // This used to insert a <meta> tag here, per advice at
88 // https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/
89 // The goal was to prevent nonce from working after the page hit onready,
90 // This would help in old browsers that didn't support nonces, and
91 // also assist for varnish-cached pages which repeat nonces.
92 // However, this is incompatible with how resource loader storage works
93 // via mw.domEval() so it was removed.
94 }
95
103 private function getHeaderName( $reportOnly ) {
104 if ( $reportOnly === self::REPORT_ONLY_MODE ) {
105 return 'Content-Security-Policy-Report-Only';
106 }
107
108 if ( $reportOnly === self::FULL_MODE ) {
109 return 'Content-Security-Policy';
110 }
111 throw new UnexpectedValueException( $reportOnly );
112 }
113
122 private function makeCSPDirectives( $policyConfig, $mode ) {
123 if ( $policyConfig === false ) {
124 // CSP is disabled
125 return '';
126 }
127 if ( $policyConfig === true ) {
128 $policyConfig = [];
129 }
130
132
133 $additionalSelfUrls = $this->getAdditionalSelfUrls();
134 $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
135
136 // If no default-src is sent at all, it
137 // seems browsers (or at least some), interpret
138 // that as allow anything, but the spec seems
139 // to imply that data: and blob: should be
140 // blocked.
141 $defaultSrc = [ '*', 'data:', 'blob:' ];
142
143 $cssSrc = false;
144 $imgSrc = false;
145 $scriptSrc = [ "'unsafe-eval'", "'self'" ];
146 if ( !isset( $policyConfig['useNonces'] ) || $policyConfig['useNonces'] ) {
147 $scriptSrc[] = "'nonce-" . $this->nonce . "'";
148 }
149
150 $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
151 if ( isset( $policyConfig['script-src'] )
152 && is_array( $policyConfig['script-src'] )
153 ) {
154 foreach ( $policyConfig['script-src'] as $src ) {
155 $scriptSrc[] = $this->escapeUrlForCSP( $src );
156 }
157 }
158 // Note: default on if unspecified.
159 if ( !isset( $policyConfig['unsafeFallback'] )
160 || $policyConfig['unsafeFallback']
161 ) {
162 // unsafe-inline should be ignored on browsers
163 // that support 'nonce-foo' sources.
164 // Some older versions of firefox don't follow this
165 // rule, but new browsers do. (Should be for at least
166 // firefox 40+).
167 $scriptSrc[] = "'unsafe-inline'";
168 }
169 // If default source option set to true or
170 // an array of urls, set a restrictive default-src.
171 // If set to false, we send a lenient default-src,
172 // see the code above where $defaultSrc is set initially.
173 if ( isset( $policyConfig['default-src'] )
174 && $policyConfig['default-src'] !== false
175 ) {
176 $defaultSrc = array_merge(
177 [ "'self'", 'data:', 'blob:' ],
178 $additionalSelfUrls
179 );
180 if ( is_array( $policyConfig['default-src'] ) ) {
181 foreach ( $policyConfig['default-src'] as $src ) {
182 $defaultSrc[] = $this->escapeUrlForCSP( $src );
183 }
184 }
185 }
186
187 if ( !isset( $policyConfig['includeCORS'] ) || $policyConfig['includeCORS'] ) {
188 $CORSUrls = $this->getCORSSources();
189 if ( !in_array( '*', $defaultSrc ) ) {
190 $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
191 }
192 // Unlikely to have * in scriptSrc, but doesn't
193 // hurt to check.
194 if ( !in_array( '*', $scriptSrc ) ) {
195 $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
196 }
197 }
198
199 Hooks::run( 'ContentSecurityPolicyDefaultSource', [ &$defaultSrc, $policyConfig, $mode ] );
200 Hooks::run( 'ContentSecurityPolicyScriptSource', [ &$scriptSrc, $policyConfig, $mode ] );
201
202 // Check if array just in case the hook made it false
203 if ( is_array( $defaultSrc ) ) {
204 $cssSrc = array_merge( $defaultSrc, [ "'unsafe-inline'" ] );
205 }
206
207 if ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) {
208 if ( $policyConfig['report-uri'] === false ) {
209 $reportUri = false;
210 } else {
211 $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] );
212 }
213 } else {
214 $reportUri = $this->getReportUri( $mode );
215 }
216
217 // Only send an img-src, if we're sending a restricitve default.
218 if ( !is_array( $defaultSrc )
219 || !in_array( '*', $defaultSrc )
220 || !in_array( 'data:', $defaultSrc )
221 || !in_array( 'blob:', $defaultSrc )
222 ) {
223 // A future todo might be to make the whitelist options only
224 // add all the whitelisted sites to the header, instead of
225 // allowing all (Assuming there is a small number of sites).
226 // For now, the external image feature disables the limits
227 // CSP puts on external images.
228 if ( $mwConfig->get( 'AllowExternalImages' )
229 || $mwConfig->get( 'AllowExternalImagesFrom' )
230 || $mwConfig->get( 'AllowImageTag' )
231 ) {
232 $imgSrc = [ '*', 'data:', 'blob:' ];
233 } elseif ( $mwConfig->get( 'EnableImageWhitelist' ) ) {
234 $whitelist = wfMessage( 'external_image_whitelist' )
235 ->inContentLanguage()
236 ->plain();
237 if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) {
238 $imgSrc = [ '*', 'data:', 'blob:' ];
239 }
240 }
241 }
242
243 $directives = [];
244 if ( $scriptSrc ) {
245 $directives[] = 'script-src ' . implode( ' ', $scriptSrc );
246 }
247 if ( $defaultSrc ) {
248 $directives[] = 'default-src ' . implode( ' ', $defaultSrc );
249 }
250 if ( $cssSrc ) {
251 $directives[] = 'style-src ' . implode( ' ', $cssSrc );
252 }
253 if ( $imgSrc ) {
254 $directives[] = 'img-src ' . implode( ' ', $imgSrc );
255 }
256 if ( $reportUri ) {
257 $directives[] = 'report-uri ' . $reportUri;
258 }
259
260 Hooks::run( 'ContentSecurityPolicyDirectives', [ &$directives, $policyConfig, $mode ] );
261
262 return implode( '; ', $directives );
263 }
264
272 private function getReportUri( $mode ) {
273 $apiArguments = [
274 'action' => 'cspreport',
275 'format' => 'json'
276 ];
277 if ( $mode === self::REPORT_ONLY_MODE ) {
278 $apiArguments['reportonly'] = '1';
279 }
280 $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments );
281
282 // Per spec, ';' and ',' must be hex-escaped in report uri
283 // Also add an & at the end of url to work around bug in hhvm
284 // with handling of POST parameters when always_decode_post_data
285 // is set to true. See https://github.com/facebook/hhvm/issues/6676
286 $reportUri = $this->escapeUrlForCSP( $reportUri ) . '&';
287 return $reportUri;
288 }
289
305 private function prepareUrlForCSP( $url ) {
306 $result = false;
307 if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
308 // A schema source (e.g. blob: or data:)
309 return $url;
310 }
311 $bits = wfParseUrl( $url );
312 if ( !$bits && strpos( $url, '/' ) === false ) {
313 // probably something like example.com.
314 // try again protocol-relative.
315 $url = '//' . $url;
316 $bits = wfParseUrl( $url );
317 }
318 if ( $bits && isset( $bits['host'] )
319 && $bits['host'] !== $this->mwConfig->get( 'ServerName' )
320 ) {
321 $result = $bits['host'];
322 if ( $bits['scheme'] !== '' ) {
323 $result = $bits['scheme'] . $bits['delimiter'] . $result;
324 }
325 if ( isset( $bits['port'] ) ) {
326 $result .= ':' . $bits['port'];
327 }
328 $result = $this->escapeUrlForCSP( $result );
329 }
330 return $result;
331 }
332
338 private function getAdditionalSelfUrlsScript() {
339 $additionalUrls = [];
340 // wgExtensionAssetsPath for ?debug=true mode
341 $pathVars = [ 'LoadScript', 'ExtensionAssetsPath', 'ResourceBasePath' ];
342
343 foreach ( $pathVars as $path ) {
344 $url = $this->mwConfig->get( $path );
345 $preparedUrl = $this->prepareUrlForCSP( $url );
346 if ( $preparedUrl ) {
347 $additionalUrls[] = $preparedUrl;
348 }
349 }
350 $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
351 foreach ( $RLSources as $wiki => $sources ) {
352 foreach ( $sources as $id => $value ) {
353 $url = $this->prepareUrlForCSP( $value );
354 if ( $url ) {
355 $additionalUrls[] = $url;
356 }
357 }
358 }
359
360 return array_unique( $additionalUrls );
361 }
362
369 private function getAdditionalSelfUrls() {
370 // XXX on a foreign repo, the included description page can have anything on it,
371 // including inline scripts. But nobody sane does that.
372
373 // In principle, you can have even more complex configs... (e.g. The urlsByExt option)
374 $pathUrls = [];
375 $additionalSelfUrls = [];
376
377 // Future todo: The zone urls should never go into
378 // style-src. They should either be only in img-src, or if
379 // img-src unspecified they should be in default-src. Similarly,
380 // the DescriptionStylesheetUrl only needs to be in style-src
381 // (or default-src if style-src unspecified).
382 $callback = function ( $repo, &$urls ) {
383 $urls[] = $repo->getZoneUrl( 'public' );
384 $urls[] = $repo->getZoneUrl( 'transcoded' );
385 $urls[] = $repo->getZoneUrl( 'thumb' );
386 $urls[] = $repo->getDescriptionStylesheetUrl();
387 };
388 $localRepo = RepoGroup::singleton()->getRepo( 'local' );
389 $callback( $localRepo, $pathUrls );
390 RepoGroup::singleton()->forEachForeignRepo( $callback, [ &$pathUrls ] );
391
392 // Globals that might point to a different domain
393 $pathGlobals = [ 'LoadScript', 'ExtensionAssetsPath', 'StylePath', 'ResourceBasePath' ];
394 foreach ( $pathGlobals as $path ) {
395 $pathUrls[] = $this->mwConfig->get( $path );
396 }
397 foreach ( $pathUrls as $path ) {
398 $preparedUrl = $this->prepareUrlForCSP( $path );
399 if ( $preparedUrl !== false ) {
400 $additionalSelfUrls[] = $preparedUrl;
401 }
402 }
403 $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
404
405 foreach ( $RLSources as $wiki => $sources ) {
406 foreach ( $sources as $id => $value ) {
407 $url = $this->prepareUrlForCSP( $value );
408 if ( $url ) {
409 $additionalSelfUrls[] = $url;
410 }
411 }
412 }
413
414 return array_unique( $additionalSelfUrls );
415 }
416
429 private function getCORSSources() {
430 $additionalUrls = [];
431 $CORSSources = $this->mwConfig->get( 'CrossSiteAJAXdomains' );
432 foreach ( $CORSSources as $source ) {
433 if ( strpos( $source, '?' ) !== false ) {
434 // CSP doesn't support single char wildcard
435 continue;
436 }
437 $url = $this->prepareUrlForCSP( $source );
438 if ( $url ) {
439 $additionalUrls[] = $url;
440 }
441 }
442 return $additionalUrls;
443 }
444
452 private function escapeUrlForCSP( $url ) {
453 return str_replace(
454 [ ';', ',' ],
455 [ '%3B', '%2C' ],
456 $url
457 );
458 }
459
470 public static function falsePositiveBrowser( $ua ) {
471 return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
472 }
473
480 public static function isNonceRequired( Config $config ) {
481 $configs = [
482 $config->get( 'CSPHeader' ),
483 $config->get( 'CSPReportOnlyHeader' )
484 ];
485 foreach ( $configs as $headerConfig ) {
486 if (
487 $headerConfig === true ||
488 ( is_array( $headerConfig ) &&
489 !isset( $headerConfig['useNonces'] ) ) ||
490 ( is_array( $headerConfig ) &&
491 isset( $headerConfig['useNonces'] ) &&
492 $headerConfig['useNonces'] )
493 ) {
494 return true;
495 }
496 }
497 return false;
498 }
499}
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.
__construct( $nonce, WebResponse $response, Config $mwConfig)
static isNonceRequired(Config $config)
Should we set nonce attribute.
prepareUrlForCSP( $url)
Given a url, convert to form needed for CSP.
makeCSPDirectives( $policyConfig, $mode)
Determine what CSP policies to set for this page.
static falsePositiveBrowser( $ua)
Does this browser give false positive reports?
getHeaderName( $reportOnly)
Get the name of the HTTP header to use.
static sendHeaders(IContextSource $context)
Send CSP headers based on wiki config.
escapeUrlForCSP( $url)
CSP spec says ',' and ';' are not allowed to appear in urls.
string $nonce
The nonce to use for inline scripts (from OutputPage)
getReportUri( $mode)
Get the default report uri.
getCORSSources()
include domains that are allowed to send us CORS requests.
getAdditionalSelfUrlsScript()
Get additional script sources.
sendCSPHeader( $csp, $reportOnly)
Send a single CSP header based on a given policy config.
Config $mwConfig
The site configuration object.
Allow programs to request this object from WebRequest::response() and handle all outputting (or lack ...
Interface for configuration instances.
Definition Config.php:28
get( $name)
Get a configuration variable such as "Sitename" or "UploadMaintenance.".
Interface for objects which can provide a MediaWiki context on request.
$context
Definition load.php:45
$source