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
28use MediaWiki\HookContainer\HookContainer;
29use MediaWiki\HookContainer\HookRunner;
30use MediaWiki\MediaWikiServices;
31
32class ContentSecurityPolicy {
33	public const REPORT_ONLY_MODE = 1;
34	public const FULL_MODE = 2;
35
36	/** @var string The nonce to use for inline scripts (from OutputPage) */
37	private $nonce;
38	/** @var Config The site configuration object */
39	private $mwConfig;
40	/** @var WebResponse */
41	private $response;
42
43	/** @var array */
44	private $extraDefaultSrc = [];
45	/** @var array */
46	private $extraScriptSrc = [];
47	/** @var array */
48	private $extraStyleSrc = [];
49
50	/** @var HookRunner */
51	private $hookRunner;
52
53	/**
54	 * @note As a general rule, you would not construct this class directly
55	 *  but use the instance from OutputPage::getCSP()
56	 * @internal
57	 * @param WebResponse $response
58	 * @param Config $mwConfig
59	 * @param HookContainer $hookContainer
60	 * @since 1.35 Method signature changed
61	 */
62	public function __construct( WebResponse $response, Config $mwConfig,
63		HookContainer $hookContainer
64	) {
65		$this->response = $response;
66		$this->mwConfig = $mwConfig;
67		$this->hookRunner = new HookRunner( $hookContainer );
68	}
69
70	/**
71	 * Send a single CSP header based on a given policy config.
72	 *
73	 * @note Most callers will probably want ContentSecurityPolicy::sendHeaders() instead.
74	 * @internal
75	 * @param array $csp ContentSecurityPolicy configuration
76	 * @param int $reportOnly self::*_MODE constant
77	 */
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
88	/**
89	 * Send CSP headers based on wiki config
90	 *
91	 * Main method that callers (OutputPage) are expected to use.
92	 * As a general rule, you would never call this in an extension unless
93	 * you have disabled OutputPage and are fully controlling the output.
94	 *
95	 * @since 1.35
96	 */
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
113	/**
114	 * Get the name of the HTTP header to use.
115	 *
116	 * @param int $reportOnly Either self::REPORT_ONLY_MODE or self::FULL_MODE
117	 * @return string Name of http header
118	 * @throws UnexpectedValueException
119	 */
120	private function getHeaderName( $reportOnly ) {
121		if ( $reportOnly === self::REPORT_ONLY_MODE ) {
122			return 'Content-Security-Policy-Report-Only';
123		}
124
125		if ( $reportOnly === self::FULL_MODE ) {
126			return 'Content-Security-Policy';
127		}
128		throw new UnexpectedValueException( "Mode '$reportOnly' not recognised" );
129	}
130
131	/**
132	 * Determine what CSP policies to set for this page
133	 *
134	 * @param array|bool $policyConfig Policy configuration
135	 *   (Either $wgCSPHeader or $wgCSPReportOnlyHeader)
136	 * @param int $mode self::REPORT_ONLY_MODE, self::FULL_MODE
137	 * @return string Policy directives, or empty string for no policy.
138	 */
139	private function makeCSPDirectives( $policyConfig, $mode ) {
140		if ( $policyConfig === false ) {
141			// CSP is disabled
142			return '';
143		}
144		if ( $policyConfig === true ) {
145			$policyConfig = [];
146		}
147
148		$mwConfig = $this->mwConfig;
149
150		if (
151			!self::isNonceRequired( $mwConfig ) &&
152			self::isNonceRequiredArray( [ $policyConfig ] )
153		) {
154			// If the current policy requires a nonce, but the global state
155			// does not, that's bad. Throw an exception. This should never happen.
156			throw new LogicException( "Nonce requirement mismatch" );
157		}
158
159		$additionalSelfUrls = $this->getAdditionalSelfUrls();
160		$additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
161
162		// If no default-src is sent at all, it
163		// seems browsers (or at least some), interpret
164		// that as allow anything, but the spec seems
165		// to imply that data: and blob: should be
166		// blocked.
167		$defaultSrc = [ '*', 'data:', 'blob:' ];
168
169		$imgSrc = false;
170		$scriptSrc = [ "'unsafe-eval'", "blob:", "'self'" ];
171		if ( $policyConfig['useNonces'] ?? true ) {
172			$scriptSrc[] = "'nonce-" . $this->getNonce() . "'";
173		}
174
175		$scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
176		if ( isset( $policyConfig['script-src'] )
177			&& is_array( $policyConfig['script-src'] )
178		) {
179			foreach ( $policyConfig['script-src'] as $src ) {
180				$scriptSrc[] = $this->escapeUrlForCSP( $src );
181			}
182		}
183		// Note: default on if unspecified.
184		if ( $policyConfig['unsafeFallback'] ?? true ) {
185			// unsafe-inline should be ignored on browsers
186			// that support 'nonce-foo' sources.
187			// Some older versions of firefox don't follow this
188			// rule, but new browsers do. (Should be for at least
189			// firefox 40+).
190			$scriptSrc[] = "'unsafe-inline'";
191		}
192		// If default source option set to true or
193		// an array of urls, set a restrictive default-src.
194		// If set to false, we send a lenient default-src,
195		// see the code above where $defaultSrc is set initially.
196		if ( isset( $policyConfig['default-src'] )
197			&& $policyConfig['default-src'] !== false
198		) {
199			$defaultSrc = array_merge(
200				[ "'self'", 'data:', 'blob:' ],
201				$additionalSelfUrls
202			);
203			if ( is_array( $policyConfig['default-src'] ) ) {
204				foreach ( $policyConfig['default-src'] as $src ) {
205					$defaultSrc[] = $this->escapeUrlForCSP( $src );
206				}
207			}
208		}
209
210		if ( $policyConfig['includeCORS'] ?? true ) {
211			$CORSUrls = $this->getCORSSources();
212			if ( !in_array( '*', $defaultSrc ) ) {
213				$defaultSrc = array_merge( $defaultSrc, $CORSUrls );
214			}
215			// Unlikely to have * in scriptSrc, but doesn't
216			// hurt to check.
217			if ( !in_array( '*', $scriptSrc ) ) {
218				$scriptSrc = array_merge( $scriptSrc, $CORSUrls );
219			}
220		}
221
222		$defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
223		$scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
224
225		$cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [ "'unsafe-inline'" ] );
226
227		$this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode );
228		$this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode );
229
230		if ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) {
231			if ( $policyConfig['report-uri'] === false ) {
232				$reportUri = false;
233			} else {
234				$reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] );
235			}
236		} else {
237			$reportUri = $this->getReportUri( $mode );
238		}
239
240		// Only send an img-src, if we're sending a restricitve default.
241		if ( !is_array( $defaultSrc )
242			|| !in_array( '*', $defaultSrc )
243			|| !in_array( 'data:', $defaultSrc )
244			|| !in_array( 'blob:', $defaultSrc )
245		) {
246			// A future todo might be to make the whitelist options only
247			// add all the whitelisted sites to the header, instead of
248			// allowing all (Assuming there is a small number of sites).
249			// For now, the external image feature disables the limits
250			// CSP puts on external images.
251			if ( $mwConfig->get( 'AllowExternalImages' )
252				|| $mwConfig->get( 'AllowExternalImagesFrom' )
253				|| $mwConfig->get( 'AllowImageTag' )
254			) {
255				$imgSrc = [ '*', 'data:', 'blob:' ];
256			} elseif ( $mwConfig->get( 'EnableImageWhitelist' ) ) {
257				$whitelist = wfMessage( 'external_image_whitelist' )
258					->inContentLanguage()
259					->plain();
260				if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) {
261					$imgSrc = [ '*', 'data:', 'blob:' ];
262				}
263			}
264		}
265		// Default value 'none'. true is none, false is nothing, string is single directive,
266		// array is list.
267		if ( !isset( $policyConfig['object-src'] ) || $policyConfig['object-src'] === true ) {
268			$objectSrc = [ "'none'" ];
269		} else {
270			$objectSrc = (array)( $policyConfig['object-src'] ?: [] );
271		}
272		$objectSrc = array_map( [ $this, 'escapeUrlForCSP' ], $objectSrc );
273
274		$directives = [];
275		if ( $scriptSrc ) {
276			$directives[] = 'script-src ' . implode( ' ', array_unique( $scriptSrc ) );
277		}
278		if ( $defaultSrc ) {
279			$directives[] = 'default-src ' . implode( ' ', array_unique( $defaultSrc ) );
280		}
281		if ( $cssSrc ) {
282			$directives[] = 'style-src ' . implode( ' ', array_unique( $cssSrc ) );
283		}
284		if ( $imgSrc ) {
285			$directives[] = 'img-src ' . implode( ' ', array_unique( $imgSrc ) );
286		}
287		if ( $objectSrc ) {
288			$directives[] = 'object-src ' . implode( ' ', $objectSrc );
289		}
290		if ( $reportUri ) {
291			$directives[] = 'report-uri ' . $reportUri;
292		}
293
294		$this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode );
295
296		return implode( '; ', $directives );
297	}
298
299	/**
300	 * Get the default report uri.
301	 *
302	 * @param int $mode self::*_MODE constant.
303	 * @return string The URI to send reports to.
304	 * @throws UnexpectedValueException if given invalid mode.
305	 */
306	private function getReportUri( $mode ) {
307		$apiArguments = [
308			'action' => 'cspreport',
309			'format' => 'json'
310		];
311		if ( $mode === self::REPORT_ONLY_MODE ) {
312			$apiArguments['reportonly'] = '1';
313		}
314		$reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments );
315
316		// Per spec, ';' and ',' must be hex-escaped in report URI
317		$reportUri = $this->escapeUrlForCSP( $reportUri );
318		return $reportUri;
319	}
320
321	/**
322	 * Given a url, convert to form needed for CSP.
323	 *
324	 * Currently this does either scheme + host, or
325	 * if protocol relative, just the host. Future versions
326	 * could potentially preserve some of the path, if its determined
327	 * that that would be a good idea.
328	 *
329	 * @note This does the extra escaping for CSP, but assumes the url
330	 *   has already had normal url escaping applied.
331	 * @note This discards urls same as server name, as 'self' directive
332	 *   takes care of that.
333	 * @param string $url
334	 * @return string|bool Converted url or false on failure
335	 */
336	private function prepareUrlForCSP( $url ) {
337		$result = false;
338		if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
339			// A schema source (e.g. blob: or data:)
340			return $url;
341		}
342		$bits = wfParseUrl( $url );
343		if ( !$bits && strpos( $url, '/' ) === false ) {
344			// probably something like example.com.
345			// try again protocol-relative.
346			$url = '//' . $url;
347			$bits = wfParseUrl( $url );
348		}
349		if ( $bits && isset( $bits['host'] )
350			&& $bits['host'] !== $this->mwConfig->get( 'ServerName' )
351		) {
352			$result = $bits['host'];
353			if ( $bits['scheme'] !== '' ) {
354				$result = $bits['scheme'] . $bits['delimiter'] . $result;
355			}
356			if ( isset( $bits['port'] ) ) {
357				$result .= ':' . $bits['port'];
358			}
359			$result = $this->escapeUrlForCSP( $result );
360		}
361		return $result;
362	}
363
364	/**
365	 * Get additional script sources
366	 *
367	 * @return array Additional sources for loading scripts from
368	 */
369	private function getAdditionalSelfUrlsScript() {
370		$additionalUrls = [];
371		// wgExtensionAssetsPath for ?debug=true mode
372		$pathVars = [ 'LoadScript', 'ExtensionAssetsPath', 'ResourceBasePath' ];
373
374		foreach ( $pathVars as $path ) {
375			$url = $this->mwConfig->get( $path );
376			$preparedUrl = $this->prepareUrlForCSP( $url );
377			if ( $preparedUrl ) {
378				$additionalUrls[] = $preparedUrl;
379			}
380		}
381		$RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
382		foreach ( $RLSources as $wiki => $sources ) {
383			foreach ( $sources as $id => $value ) {
384				$url = $this->prepareUrlForCSP( $value );
385				if ( $url ) {
386					$additionalUrls[] = $url;
387				}
388			}
389		}
390
391		return array_unique( $additionalUrls );
392	}
393
394	/**
395	 * Get additional host names for the wiki (e.g. if static content loaded elsewhere)
396	 *
397	 * @note These are general load sources, not script sources
398	 * @return string[] Array of other urls for wiki (for use in default-src)
399	 */
400	private function getAdditionalSelfUrls() {
401		// XXX on a foreign repo, the included description page can have anything on it,
402		// including inline scripts. But nobody sane does that.
403
404		// In principle, you can have even more complex configs... (e.g. The urlsByExt option)
405		$pathUrls = [];
406		$additionalSelfUrls = [];
407
408		// Future todo: The zone urls should never go into
409		// style-src. They should either be only in img-src, or if
410		// img-src unspecified they should be in default-src. Similarly,
411		// the DescriptionStylesheetUrl only needs to be in style-src
412		// (or default-src if style-src unspecified).
413		$callback = static function ( $repo, &$urls ) {
414			$urls[] = $repo->getZoneUrl( 'public' );
415			$urls[] = $repo->getZoneUrl( 'transcoded' );
416			$urls[] = $repo->getZoneUrl( 'thumb' );
417			$urls[] = $repo->getDescriptionStylesheetUrl();
418		};
419		$repoGroup = MediaWikiServices::getInstance()->getRepoGroup();
420		$localRepo = $repoGroup->getRepo( 'local' );
421		$callback( $localRepo, $pathUrls );
422		$repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] );
423
424		// Globals that might point to a different domain
425		$pathGlobals = [ 'LoadScript', 'ExtensionAssetsPath', 'StylePath', 'ResourceBasePath' ];
426		foreach ( $pathGlobals as $path ) {
427			$pathUrls[] = $this->mwConfig->get( $path );
428		}
429		foreach ( $pathUrls as $path ) {
430			$preparedUrl = $this->prepareUrlForCSP( $path );
431			if ( $preparedUrl !== false ) {
432				$additionalSelfUrls[] = $preparedUrl;
433			}
434		}
435		$RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
436
437		foreach ( $RLSources as $wiki => $sources ) {
438			foreach ( $sources as $id => $value ) {
439				$url = $this->prepareUrlForCSP( $value );
440				if ( $url ) {
441					$additionalSelfUrls[] = $url;
442				}
443			}
444		}
445
446		return array_unique( $additionalSelfUrls );
447	}
448
449	/**
450	 * include domains that are allowed to send us CORS requests.
451	 *
452	 * Technically, $wgCrossSiteAJAXdomains lists things that are allowed to talk to us
453	 * not things that we are allowed to talk to - but if something is allowed to talk to us,
454	 * then there is a good chance that we should probably be allowed to talk to it.
455	 *
456	 * This is configurable with the 'includeCORS' key in the CSP config, and enabled
457	 * by default.
458	 * @note CORS domains with single character ('?') wildcards, are not included.
459	 * @return array Additional hosts
460	 */
461	private function getCORSSources() {
462		$additionalUrls = [];
463		$CORSSources = $this->mwConfig->get( 'CrossSiteAJAXdomains' );
464		foreach ( $CORSSources as $source ) {
465			if ( strpos( $source, '?' ) !== false ) {
466				// CSP doesn't support single char wildcard
467				continue;
468			}
469			$url = $this->prepareUrlForCSP( $source );
470			if ( $url ) {
471				$additionalUrls[] = $url;
472			}
473		}
474		return $additionalUrls;
475	}
476
477	/**
478	 * CSP spec says ',' and ';' are not allowed to appear in urls.
479	 *
480	 * @note This assumes that normal escaping has been applied to the url
481	 * @param string $url URL (or possibly just part of one)
482	 * @return string
483	 */
484	private function escapeUrlForCSP( $url ) {
485		return str_replace(
486			[ ';', ',' ],
487			[ '%3B', '%2C' ],
488			$url
489		);
490	}
491
492	/**
493	 * Does this browser give false positive reports?
494	 *
495	 * Some versions of firefox (40-42) incorrectly report a csp
496	 * violation for nonce sources, despite allowing them.
497	 *
498	 * @see https://bugzilla.mozilla.org/show_bug.cgi?id=1026520
499	 * @param string $ua User-agent header
500	 * @return bool
501	 */
502	public static function falsePositiveBrowser( $ua ) {
503		return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
504	}
505
506	/**
507	 * Should we set nonce attribute
508	 *
509	 * @param Config $config Configuration object
510	 * @return bool
511	 */
512	public static function isNonceRequired( Config $config ) {
513		$configs = [
514			$config->get( 'CSPHeader' ),
515			$config->get( 'CSPReportOnlyHeader' )
516		];
517		return self::isNonceRequiredArray( $configs );
518	}
519
520	/**
521	 * Does a specific config require a nonce
522	 *
523	 * @param array $configs An array of CSP config arrays
524	 * @return bool
525	 */
526	private static function isNonceRequiredArray( array $configs ) {
527		foreach ( $configs as $headerConfig ) {
528			if (
529				$headerConfig === true ||
530				( is_array( $headerConfig ) &&
531				!isset( $headerConfig['useNonces'] ) ) ||
532				( is_array( $headerConfig ) &&
533				isset( $headerConfig['useNonces'] ) &&
534				$headerConfig['useNonces'] )
535			) {
536				return true;
537			}
538		}
539		return false;
540	}
541
542	/**
543	 * Get the nonce if nonce is in use
544	 *
545	 * @since 1.35
546	 * @return bool|string A random (base64) string or false if not used.
547	 */
548	public function getNonce() {
549		if ( !self::isNonceRequired( $this->mwConfig ) ) {
550			return false;
551		}
552		if ( $this->nonce === null ) {
553			$rand = random_bytes( 15 );
554			$this->nonce = base64_encode( $rand );
555		}
556
557		return $this->nonce;
558	}
559
560	/**
561	 * Add an additional default src
562	 *
563	 * If possible you should use a more specific source type then default.
564	 *
565	 * So for example, if an extension added a special page that loaded something
566	 * it might call $this->getOutput()->getCSP()->addDefaultSrc( '*.example.com' );
567	 *
568	 * @since 1.35
569	 * @param string $source Source to add.
570	 *   e.g. blob:, *.example.com, %https://example.com, example.com/foo
571	 */
572	public function addDefaultSrc( $source ) {
573		$this->extraDefaultSrc[] = $this->prepareUrlForCSP( $source );
574	}
575
576	/**
577	 * Add an additional CSS src
578	 *
579	 * So for example, if an extension added a special page that loaded external CSS
580	 * it might call $this->getOutput()->getCSP()->addStyleSrc( '*.example.com' );
581	 *
582	 * @since 1.35
583	 * @param string $source Source to add.
584	 *   e.g. blob:, *.example.com, %https://example.com, example.com/foo
585	 */
586	public function addStyleSrc( $source ) {
587		$this->extraStyleSrc[] = $this->prepareUrlForCSP( $source );
588	}
589
590	/**
591	 * Add an additional script src
592	 *
593	 * So for example, if an extension added a special page that loaded something
594	 * it might call $this->getOutput()->getCSP()->addScriptSrc( '*.example.com' );
595	 *
596	 * @since 1.35
597	 * @warning Be careful including external scripts, as they can take over accounts.
598	 * @param string $source Source to add.
599	 *   e.g. blob:, *.example.com, %https://example.com, example.com/foo
600	 */
601	public function addScriptSrc( $source ) {
602		$this->extraScriptSrc[] = $this->prepareUrlForCSP( $source );
603	}
604}
605