Merge "Correct recent schema changes for MSSQL, Oracle"
[lhc/web/wiklou.git] / includes / ContentSecurityPolicy.php
1 <?php
2 /**
3 * Handle sending Content-Security-Policy headers
4 *
5 * @see https://www.w3.org/TR/CSP2/
6 *
7 * Copyright © 2015–2018 Brian Wolff
8 *
9 * This program is free software; you can redistribute it and/or modify
10 * it under the terms of the GNU General Public License as published by
11 * the Free Software Foundation; either version 2 of the License, or
12 * (at your option) any later version.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License along
20 * with this program; if not, write to the Free Software Foundation, Inc.,
21 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
22 * http://www.gnu.org/copyleft/gpl.html
23 *
24 * @since 1.32
25 * @file
26 */
27 class ContentSecurityPolicy {
28 const REPORT_ONLY_MODE = 1;
29 const FULL_MODE = 2;
30 /** Used for meta tag. Does not include report urls or nonce sources */
31 const FULL_MODE_RESTRICTED = 3;
32
33 /** @var string The nonce to use for inline scripts (from OutputPage) */
34 private $nonce;
35 /** @var Config The site configuration object */
36 private $mwConfig;
37 /** @var WebResponse */
38 private $response;
39
40 /**
41 * @param string $nonce
42 * @param WebResponse $response
43 * @param Config $mwConfig
44 */
45 public function __construct( $nonce, WebResponse $response, Config $mwConfig ) {
46 $this->nonce = $nonce;
47 $this->response = $response;
48 $this->mwConfig = $mwConfig;
49 }
50
51 /**
52 * Send a single CSP header based on a given policy config.
53 *
54 * @note Most callers will probably want ContentSecurityPolicy::sendHeaders() instead.
55 * @param array $csp ContentSecurityPolicy configuration
56 * @param int $reportOnly self::*_MODE constant
57 */
58 public function sendCSPHeader( $csp, $reportOnly ) {
59 $policy = $this->makeCSPDirectives( $csp, $reportOnly );
60 $headerName = $this->getHeaderName( $reportOnly );
61 if ( $policy ) {
62 $this->response->header(
63 "$headerName: $policy"
64 );
65 }
66 }
67
68 /**
69 * Return the meta header to use for after load restricted mode
70 *
71 * This should restrict browsers that don't support nonce-sources.
72 * Idea stolen from
73 * https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/
74 *
75 * @param array $csp CSP configuration
76 * @return string Content for meta tag
77 */
78 public function getMetaHeader( $csp ) {
79 return $this->makeCSPDirectives( $csp, self::FULL_MODE_RESTRICTED );
80 }
81
82 /**
83 * Send CSP headers based on wiki config
84 *
85 * Main method that callers are expected to use
86 * @param IContextSource $context A context object, the associated OutputPage
87 * object must be the one that the page in question was generated with.
88 */
89 public static function sendHeaders( IContextSource $context ) {
90 $out = $context->getOutput();
91 $csp = new ContentSecurityPolicy(
92 $out->getCSPNonce(),
93 $context->getRequest()->response(),
94 $context->getConfig()
95 );
96
97 $cspConfig = $context->getConfig()->get( 'CSPHeader' );
98 $cspConfigReportOnly = $context->getConfig()->get( 'CSPReportOnlyHeader' );
99
100 $csp->sendCSPHeader( $cspConfig, self::FULL_MODE );
101 $csp->sendCSPHeader( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
102
103 // Include <meta> header which increases security level after initial load.
104 // This helps mitigate attacks on browsers not supporting CSP2. It also
105 // helps mitigate attacks due to the shared nonce that non-logged in users
106 // get due to varnish cache.
107 // Unclear if this is the best place to insert the meta tag, or if
108 // it should be in a RL module. I figure its best to do this as early
109 // as possible.
110 // FIXME: Needs testing to see if this actually works properly
111 $metaHeader = $csp->getMetaHeader( $cspConfig );
112 if ( $metaHeader ) {
113 $context->getOutput()->addScript(
114 ResourceLoader::makeInlineScript(
115 $csp->makeMetaInsertScript(
116 $metaHeader
117 ),
118 $out->getCSPNonce()
119 )
120 );
121 }
122 }
123
124 /**
125 * Makes javascript to insert a meta CSP header after page load
126 *
127 * @see https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/
128 * @param string $metaContents content of meta tag
129 * @return string JS for including in page
130 */
131 private function makeMetaInsertScript( $metaContents ) {
132 return "$('\\x3Cmeta http-equiv=\"Content-Security-Policy\"\\x3E')" .
133 '.attr("content",' .
134 Xml::encodeJsVar( $metaContents ) .
135 ').prependTo($("head"))';
136 }
137
138 /**
139 * Get the name of the HTTP header to use.
140 *
141 * @param int $reportOnly Either self::REPORT_ONLY_MODE or self::FULL_MODE
142 * @return string Name of http header
143 * @throws UnexpectedValueException if you feed it self::FULL_MODE_RESTRICTED.
144 */
145 private function getHeaderName( $reportOnly ) {
146 if ( $reportOnly === self::REPORT_ONLY_MODE ) {
147 return 'Content-Security-Policy-Report-Only';
148 } elseif ( $reportOnly === self::FULL_MODE ) {
149 return 'Content-Security-Policy';
150 }
151 throw new UnexpectedValueException( $reportOnly );
152 }
153
154 /**
155 * Determine what CSP policies to set for this page
156 *
157 * @param array|bool $config Policy configuration (Either $wgCSPHeader or $wgCSPReportOnlyHeader)
158 * @param int $mode self::REPORT_ONLY_MODE, self::FULL_MODE or Self::FULL_MODE_RESTRICTED
159 * @return string Policy directives, or empty string for no policy.
160 */
161 private function makeCSPDirectives( $policyConfig, $mode ) {
162 if ( $policyConfig === false ) {
163 // CSP is disabled
164 return '';
165 }
166 if ( $policyConfig === true ) {
167 $policyConfig = [];
168 }
169
170 $mwConfig = $this->mwConfig;
171
172 $additionalSelfUrls = $this->getAdditionalSelfUrls();
173 $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
174
175 // If no default-src is sent at all, it
176 // seems browsers (or at least some), interpret
177 // that as allow anything, but the spec seems
178 // to imply that data: and blob: should be
179 // blocked.
180 $defaultSrc = [ '*', 'data:', 'blob:' ];
181
182 $cssSrc = false;
183 $imgSrc = false;
184 $scriptSrc = [ "'unsafe-eval'", "'self'" ];
185 if (
186 $mode !== self::FULL_MODE_RESTRICTED &&
187 ( !isset( $policyConfig['useNonces'] ) || $policyConfig['useNonces'] )
188 ) {
189 $nonceSrc = "'nonce-" . $this->nonce . "'";
190 $scriptSrc[] = $nonceSrc;
191 }
192 $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
193 if ( isset( $policyConfig['script-src'] )
194 && is_array( $policyConfig['script-src'] )
195 ) {
196 foreach ( $policyConfig['script-src'] as $src ) {
197 $scriptSrc[] = $this->escapeUrlForCSP( $src );
198 }
199 }
200 // Note: default on if unspecified.
201 if ( ( !isset( $policyConfig['unsafeFallback'] )
202 || $policyConfig['unsafeFallback'] )
203 && $mode !== self::FULL_MODE_RESTRICTED
204 ) {
205 // unsafe-inline should be ignored on browsers
206 // that support 'nonce-foo' sources.
207 // Some older versions of firefox don't follow this
208 // rule, but new browsers do. (Should be for at least
209 // firefox 40+).
210 $scriptSrc[] = "'unsafe-inline'";
211 }
212 // If default source option set to true or
213 // an array of urls, set a restrictive default-src.
214 // If set to false, we send a lenient default-src,
215 // see the code above where $defaultSrc is set initially.
216 if ( isset( $policyConfig['default-src'] )
217 && $policyConfig['default-src'] !== false
218 ) {
219 $defaultSrc = array_merge(
220 [ "'self'", 'data:', 'blob:' ],
221 $additionalSelfUrls
222 );
223 if ( is_array( $policyConfig['default-src'] ) ) {
224 foreach ( $policyConfig['default-src'] as $src ) {
225 $defaultSrc[] = $this->escapeUrlForCSP( $src );
226 }
227 }
228 }
229
230 if ( !isset( $policyConfig['includeCORS'] ) || $policyConfig['includeCORS'] ) {
231 $CORSUrls = $this->getCORSSources();
232 if ( !in_array( '*', $defaultSrc ) ) {
233 $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
234 }
235 // Unlikely to have * in scriptSrc, but doesn't
236 // hurt to check.
237 if ( !in_array( '*', $scriptSrc ) ) {
238 $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
239 }
240 }
241
242 Hooks::run( 'ContentSecurityPolicyDefaultSource', [ &$defaultSrc, $policyConfig, $mode ] );
243 Hooks::run( 'ContentSecurityPolicyScriptSource', [ &$scriptSrc, $policyConfig, $mode ] );
244
245 // Check if array just in case the hook made it false
246 if ( is_array( $defaultSrc ) ) {
247 $cssSrc = array_merge( $defaultSrc, [ "'unsafe-inline'" ] );
248 }
249
250 if ( $mode === self::FULL_MODE_RESTRICTED ) {
251 // report-uri disallowed in <meta> tags.
252 $reportUri = false;
253 } elseif ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) {
254 if ( $policyConfig['report-uri'] === false ) {
255 $reportUri = false;
256 } else {
257 $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] );
258 }
259 } else {
260 $reportUri = $this->getReportUri( $mode );
261 }
262
263 // Only send an img-src, if we're sending a restricitve default.
264 if ( !is_array( $defaultSrc )
265 || !in_array( '*', $defaultSrc )
266 || !in_array( 'data:', $defaultSrc )
267 || !in_array( 'blob:', $defaultSrc )
268 ) {
269 // A future todo might be to make the whitelist options only
270 // add all the whitelisted sites to the header, instead of
271 // allowing all (Assuming there is a small number of sites).
272 // For now, the external image feature disables the limits
273 // CSP puts on external images.
274 if ( $mwConfig->get( 'AllowExternalImages' )
275 || $mwConfig->get( 'AllowExternalImagesFrom' )
276 || $mwConfig->get( 'AllowImageTag' )
277 ) {
278 $imgSrc = [ '*', 'data:', 'blob:' ];
279 } elseif ( $mwConfig->get( 'EnableImageWhitelist' ) ) {
280 $whitelist = wfMessage( 'external_image_whitelist' )
281 ->inContentLanguage()
282 ->plain();
283 if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) {
284 $imgSrc = [ '*', 'data:', 'blob:' ];
285 }
286 }
287 }
288
289 $directives = [];
290 if ( $scriptSrc ) {
291 $directives[] = 'script-src ' . implode( ' ', $scriptSrc );
292 }
293 if ( $defaultSrc ) {
294 $directives[] = 'default-src ' . implode( ' ', $defaultSrc );
295 }
296 if ( $cssSrc ) {
297 $directives[] = 'style-src ' . implode( ' ', $cssSrc );
298 }
299 if ( $imgSrc ) {
300 $directives[] = 'img-src ' . implode( ' ', $imgSrc );
301 }
302 if ( $reportUri ) {
303 $directives[] = 'report-uri ' . $reportUri;
304 }
305
306 Hooks::run( 'ContentSecurityPolicyDirectives', [ &$directives, $policyConfig, $mode ] );
307
308 return implode( '; ', $directives );
309 }
310
311 /**
312 * Get the default report uri.
313 *
314 * @param int $mode self::*_MODE constant. Do not use with self::FULL_MODE_RESTRICTED
315 * @return string The URI to send reports to.
316 * @throws UnexpectedValueException if given invalid mode.
317 */
318 private function getReportUri( $mode ) {
319 if ( $mode === self::FULL_MODE_RESTRICTED ) {
320 throw new UnexpectedValueException( $mode );
321 }
322 $apiArguments = [
323 'action' => 'cspreport',
324 'format' => 'json'
325 ];
326 if ( $mode === self::REPORT_ONLY_MODE ) {
327 $apiArguments['reportonly'] = '1';
328 }
329 $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments );
330
331 // Per spec, ';' and ',' must be hex-escaped in report uri
332 // Also add an & at the end of url to work around bug in hhvm
333 // with handling of POST parameters when always_decode_post_data
334 // is set to true. See https://github.com/facebook/hhvm/issues/6676
335 $reportUri = $this->escapeUrlForCSP( $reportUri ) . '&';
336 return $reportUri;
337 }
338
339 /**
340 * Given a url, convert to form needed for CSP.
341 *
342 * Currently this does either scheme + host, or
343 * if protocol relative, just the host. Future versions
344 * could potentially preserve some of the path, if its determined
345 * that that would be a good idea.
346 *
347 * @note This does the extra escaping for CSP, but assumes the url
348 * has already had normal url escaping applied.
349 * @note This discards urls same as server name, as 'self' directive
350 * takes care of that.
351 * @param string $url
352 * @return string|bool Converted url or false on failure
353 */
354 private function prepareUrlForCSP( $url ) {
355 $result = false;
356 if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
357 // A schema source (e.g. blob: or data:)
358 return $url;
359 }
360 $bits = wfParseUrl( $url );
361 if ( !$bits && strpos( $url, '/' ) === false ) {
362 // probably something like example.com.
363 // try again protocol-relative.
364 $url = '//' . $url;
365 $bits = wfParseUrl( $url );
366 }
367 if ( $bits && isset( $bits['host'] )
368 && $bits['host'] !== $this->mwConfig->get( 'ServerName' )
369 ) {
370 $result = $bits['host'];
371 if ( $bits['scheme'] !== '' ) {
372 $result = $bits['scheme'] . $bits['delimiter'] . $result;
373 }
374 if ( isset( $bits['port'] ) ) {
375 $result .= ':' . $bits['port'];
376 }
377 $result = $this->escapeUrlForCSP( $result );
378 }
379 return $result;
380 }
381
382 /**
383 * Get additional script sources
384 *
385 * @return array Additional sources for loading scripts from
386 */
387 private function getAdditionalSelfUrlsScript() {
388 $additionalUrls = [];
389 // wgExtensionAssetsPath for ?debug=true mode
390 $pathVars = [ 'LoadScript', 'ExtensionAssetsPath', 'ResourceBasePath' ];
391
392 foreach ( $pathVars as $path ) {
393 $url = $this->mwConfig->get( $path );
394 $preparedUrl = $this->prepareUrlForCSP( $url );
395 if ( $preparedUrl ) {
396 $additionalUrls[] = $preparedUrl;
397 }
398 }
399 $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
400 foreach ( $RLSources as $wiki => $sources ) {
401 foreach ( $sources as $id => $value ) {
402 $url = $this->prepareUrlForCSP( $value );
403 if ( $url ) {
404 $additionalUrls[] = $url;
405 }
406 }
407 }
408
409 return array_unique( $additionalUrls );
410 }
411
412 /**
413 * Get additional host names for the wiki (e.g. if static content loaded elsewhere)
414 *
415 * @note These are general load sources, not script sources
416 * @return array Array of other urls for wiki (for use in default-src)
417 */
418 private function getAdditionalSelfUrls() {
419 // XXX on a foreign repo, the included description page can have anything on it,
420 // including inline scripts. But nobody sane does that.
421
422 // In principle, you can have even more complex configs... (e.g. The urlsByExt option)
423 $pathUrls = [];
424 $additionalSelfUrls = [];
425
426 // Future todo: The zone urls should never go into
427 // style-src. They should either be only in img-src, or if
428 // img-src unspecified they should be in default-src. Similarly,
429 // the DescriptionStylesheetUrl only needs to be in style-src
430 // (or default-src if style-src unspecified).
431 $callback = function ( $repo, &$urls ) {
432 $urls[] = $repo->getZoneUrl( 'public' );
433 $urls[] = $repo->getZoneUrl( 'transcoded' );
434 $urls[] = $repo->getZoneUrl( 'thumb' );
435 $urls[] = $repo->getDescriptionStylesheetUrl();
436 };
437 $localRepo = RepoGroup::singleton()->getRepo( 'local' );
438 $callback( $localRepo, $pathUrls );
439 RepoGroup::singleton()->forEachForeignRepo( $callback, [ &$pathUrls ] );
440
441 // Globals that might point to a different domain
442 $pathGlobals = [ 'LoadScript', 'ExtensionAssetsPath', 'StylePath', 'ResourceBasePath' ];
443 foreach ( $pathGlobals as $path ) {
444 $pathUrls[] = $this->mwConfig->get( $path );
445 }
446 foreach ( $pathUrls as $path ) {
447 $preparedUrl = $this->prepareUrlForCSP( $path );
448 if ( $preparedUrl !== false ) {
449 $additionalSelfUrls[] = $preparedUrl;
450 }
451 }
452 $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
453
454 foreach ( $RLSources as $wiki => $sources ) {
455 foreach ( $sources as $id => $value ) {
456 $url = $this->prepareUrlForCSP( $value );
457 if ( $url ) {
458 $additionalSelfUrls[] = $url;
459 }
460 }
461 }
462
463 return array_unique( $additionalSelfUrls );
464 }
465
466 /**
467 * include domains that are allowed to send us CORS requests.
468 *
469 * Technically, $wgCrossSiteAJAXdomains lists things that are allowed to talk to us
470 * not things that we are allowed to talk to - but if something is allowed to talk to us,
471 * then there is a good chance that we should probably be allowed to talk to it.
472 *
473 * This is configurable with the 'includeCORS' key in the CSP config, and enabled
474 * by default.
475 * @note CORS domains with single character ('?') wildcards, are not included.
476 * @return array Additional hosts
477 */
478 private function getCORSSources() {
479 $additionalUrls = [];
480 $CORSSources = $this->mwConfig->get( 'CrossSiteAJAXdomains' );
481 foreach ( $CORSSources as $source ) {
482 if ( strpos( $source, '?' ) !== false ) {
483 // CSP doesn't support single char wildcard
484 continue;
485 }
486 $url = $this->prepareUrlForCSP( $source );
487 if ( $url ) {
488 $additionalUrls[] = $url;
489 }
490 }
491 return $additionalUrls;
492 }
493
494 /**
495 * CSP spec says ',' and ';' are not allowed to appear in urls.
496 *
497 * @note This assumes that normal escaping has been applied to the url
498 * @param string $url URL (or possibly just part of one)
499 * @return string
500 */
501 private function escapeUrlForCSP( $url ) {
502 return str_replace(
503 [ ';', ',' ],
504 [ '%3B', '%2C' ],
505 $url
506 );
507 }
508
509 /**
510 * Does this browser give false positive reports?
511 *
512 * Some versions of firefox (40-42) incorrectly report a csp
513 * violation for nonce sources, despite allowing them.
514 *
515 * @see https://bugzilla.mozilla.org/show_bug.cgi?id=1026520
516 * @param string $ua User-agent header
517 * @return bool
518 */
519 public static function falsePositiveBrowser( $ua ) {
520 return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
521 }
522
523 /**
524 * Should we set nonce attribute
525 *
526 * @param Config $config Configuration object
527 * @return bool
528 */
529 public static function isNonceRequired( Config $config ) {
530 $configs = [
531 $config->get( 'CSPHeader' ),
532 $config->get( 'CSPReportOnlyHeader' )
533 ];
534 foreach ( $configs as $headerConfig ) {
535 if (
536 $headerConfig === true ||
537 ( is_array( $headerConfig ) &&
538 !isset( $headerConfig['useNonces'] ) ) ||
539 ( is_array( $headerConfig ) &&
540 isset( $headerConfig['useNonces'] ) &&
541 $headerConfig['useNonces'] )
542 ) {
543 return true;
544 }
545 }
546 return false;
547 }
548 }