Merge "Feedback form: tweak field spacing"
[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
31 /** @var string The nonce to use for inline scripts (from OutputPage) */
32 private $nonce;
33 /** @var Config The site configuration object */
34 private $mwConfig;
35 /** @var WebResponse */
36 private $response;
37
38 /**
39 * @param string $nonce
40 * @param WebResponse $response
41 * @param Config $mwConfig
42 */
43 public function __construct( $nonce, WebResponse $response, Config $mwConfig ) {
44 $this->nonce = $nonce;
45 $this->response = $response;
46 $this->mwConfig = $mwConfig;
47 }
48
49 /**
50 * Send a single CSP header based on a given policy config.
51 *
52 * @note Most callers will probably want ContentSecurityPolicy::sendHeaders() instead.
53 * @param array $csp ContentSecurityPolicy configuration
54 * @param int $reportOnly self::*_MODE constant
55 */
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
66 /**
67 * Send CSP headers based on wiki config
68 *
69 * Main method that callers are expected to use
70 * @param IContextSource $context A context object, the associated OutputPage
71 * object must be the one that the page in question was generated with.
72 */
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
96 /**
97 * Get the name of the HTTP header to use.
98 *
99 * @param int $reportOnly Either self::REPORT_ONLY_MODE or self::FULL_MODE
100 * @return string Name of http header
101 */
102 private function getHeaderName( $reportOnly ) {
103 if ( $reportOnly === self::REPORT_ONLY_MODE ) {
104 return 'Content-Security-Policy-Report-Only';
105 } elseif ( $reportOnly === self::FULL_MODE ) {
106 return 'Content-Security-Policy';
107 }
108 throw new UnexpectedValueException( $reportOnly );
109 }
110
111 /**
112 * Determine what CSP policies to set for this page
113 *
114 * @param array|bool $config Policy configuration (Either $wgCSPHeader or $wgCSPReportOnlyHeader)
115 * @param int $mode self::REPORT_ONLY_MODE, self::FULL_MODE
116 * @return string Policy directives, or empty string for no policy.
117 */
118 private function makeCSPDirectives( $policyConfig, $mode ) {
119 if ( $policyConfig === false ) {
120 // CSP is disabled
121 return '';
122 }
123 if ( $policyConfig === true ) {
124 $policyConfig = [];
125 }
126
127 $mwConfig = $this->mwConfig;
128
129 $additionalSelfUrls = $this->getAdditionalSelfUrls();
130 $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
131
132 // If no default-src is sent at all, it
133 // seems browsers (or at least some), interpret
134 // that as allow anything, but the spec seems
135 // to imply that data: and blob: should be
136 // blocked.
137 $defaultSrc = [ '*', 'data:', 'blob:' ];
138
139 $cssSrc = false;
140 $imgSrc = false;
141 $scriptSrc = [ "'unsafe-eval'", "'self'" ];
142 if ( !isset( $policyConfig['useNonces'] ) || $policyConfig['useNonces'] ) {
143 $scriptSrc[] = "'nonce-" . $this->nonce . "'";
144 }
145
146 $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
147 if ( isset( $policyConfig['script-src'] )
148 && is_array( $policyConfig['script-src'] )
149 ) {
150 foreach ( $policyConfig['script-src'] as $src ) {
151 $scriptSrc[] = $this->escapeUrlForCSP( $src );
152 }
153 }
154 // Note: default on if unspecified.
155 if ( ( !isset( $policyConfig['unsafeFallback'] )
156 || $policyConfig['unsafeFallback'] )
157 ) {
158 // unsafe-inline should be ignored on browsers
159 // that support 'nonce-foo' sources.
160 // Some older versions of firefox don't follow this
161 // rule, but new browsers do. (Should be for at least
162 // firefox 40+).
163 $scriptSrc[] = "'unsafe-inline'";
164 }
165 // If default source option set to true or
166 // an array of urls, set a restrictive default-src.
167 // If set to false, we send a lenient default-src,
168 // see the code above where $defaultSrc is set initially.
169 if ( isset( $policyConfig['default-src'] )
170 && $policyConfig['default-src'] !== false
171 ) {
172 $defaultSrc = array_merge(
173 [ "'self'", 'data:', 'blob:' ],
174 $additionalSelfUrls
175 );
176 if ( is_array( $policyConfig['default-src'] ) ) {
177 foreach ( $policyConfig['default-src'] as $src ) {
178 $defaultSrc[] = $this->escapeUrlForCSP( $src );
179 }
180 }
181 }
182
183 if ( !isset( $policyConfig['includeCORS'] ) || $policyConfig['includeCORS'] ) {
184 $CORSUrls = $this->getCORSSources();
185 if ( !in_array( '*', $defaultSrc ) ) {
186 $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
187 }
188 // Unlikely to have * in scriptSrc, but doesn't
189 // hurt to check.
190 if ( !in_array( '*', $scriptSrc ) ) {
191 $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
192 }
193 }
194
195 Hooks::run( 'ContentSecurityPolicyDefaultSource', [ &$defaultSrc, $policyConfig, $mode ] );
196 Hooks::run( 'ContentSecurityPolicyScriptSource', [ &$scriptSrc, $policyConfig, $mode ] );
197
198 // Check if array just in case the hook made it false
199 if ( is_array( $defaultSrc ) ) {
200 $cssSrc = array_merge( $defaultSrc, [ "'unsafe-inline'" ] );
201 }
202
203 if ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) {
204 if ( $policyConfig['report-uri'] === false ) {
205 $reportUri = false;
206 } else {
207 $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] );
208 }
209 } else {
210 $reportUri = $this->getReportUri( $mode );
211 }
212
213 // Only send an img-src, if we're sending a restricitve default.
214 if ( !is_array( $defaultSrc )
215 || !in_array( '*', $defaultSrc )
216 || !in_array( 'data:', $defaultSrc )
217 || !in_array( 'blob:', $defaultSrc )
218 ) {
219 // A future todo might be to make the whitelist options only
220 // add all the whitelisted sites to the header, instead of
221 // allowing all (Assuming there is a small number of sites).
222 // For now, the external image feature disables the limits
223 // CSP puts on external images.
224 if ( $mwConfig->get( 'AllowExternalImages' )
225 || $mwConfig->get( 'AllowExternalImagesFrom' )
226 || $mwConfig->get( 'AllowImageTag' )
227 ) {
228 $imgSrc = [ '*', 'data:', 'blob:' ];
229 } elseif ( $mwConfig->get( 'EnableImageWhitelist' ) ) {
230 $whitelist = wfMessage( 'external_image_whitelist' )
231 ->inContentLanguage()
232 ->plain();
233 if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) {
234 $imgSrc = [ '*', 'data:', 'blob:' ];
235 }
236 }
237 }
238
239 $directives = [];
240 if ( $scriptSrc ) {
241 $directives[] = 'script-src ' . implode( ' ', $scriptSrc );
242 }
243 if ( $defaultSrc ) {
244 $directives[] = 'default-src ' . implode( ' ', $defaultSrc );
245 }
246 if ( $cssSrc ) {
247 $directives[] = 'style-src ' . implode( ' ', $cssSrc );
248 }
249 if ( $imgSrc ) {
250 $directives[] = 'img-src ' . implode( ' ', $imgSrc );
251 }
252 if ( $reportUri ) {
253 $directives[] = 'report-uri ' . $reportUri;
254 }
255
256 Hooks::run( 'ContentSecurityPolicyDirectives', [ &$directives, $policyConfig, $mode ] );
257
258 return implode( '; ', $directives );
259 }
260
261 /**
262 * Get the default report uri.
263 *
264 * @param int $mode self::*_MODE constant.
265 * @return string The URI to send reports to.
266 * @throws UnexpectedValueException if given invalid mode.
267 */
268 private function getReportUri( $mode ) {
269 $apiArguments = [
270 'action' => 'cspreport',
271 'format' => 'json'
272 ];
273 if ( $mode === self::REPORT_ONLY_MODE ) {
274 $apiArguments['reportonly'] = '1';
275 }
276 $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments );
277
278 // Per spec, ';' and ',' must be hex-escaped in report uri
279 // Also add an & at the end of url to work around bug in hhvm
280 // with handling of POST parameters when always_decode_post_data
281 // is set to true. See https://github.com/facebook/hhvm/issues/6676
282 $reportUri = $this->escapeUrlForCSP( $reportUri ) . '&';
283 return $reportUri;
284 }
285
286 /**
287 * Given a url, convert to form needed for CSP.
288 *
289 * Currently this does either scheme + host, or
290 * if protocol relative, just the host. Future versions
291 * could potentially preserve some of the path, if its determined
292 * that that would be a good idea.
293 *
294 * @note This does the extra escaping for CSP, but assumes the url
295 * has already had normal url escaping applied.
296 * @note This discards urls same as server name, as 'self' directive
297 * takes care of that.
298 * @param string $url
299 * @return string|bool Converted url or false on failure
300 */
301 private function prepareUrlForCSP( $url ) {
302 $result = false;
303 if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
304 // A schema source (e.g. blob: or data:)
305 return $url;
306 }
307 $bits = wfParseUrl( $url );
308 if ( !$bits && strpos( $url, '/' ) === false ) {
309 // probably something like example.com.
310 // try again protocol-relative.
311 $url = '//' . $url;
312 $bits = wfParseUrl( $url );
313 }
314 if ( $bits && isset( $bits['host'] )
315 && $bits['host'] !== $this->mwConfig->get( 'ServerName' )
316 ) {
317 $result = $bits['host'];
318 if ( $bits['scheme'] !== '' ) {
319 $result = $bits['scheme'] . $bits['delimiter'] . $result;
320 }
321 if ( isset( $bits['port'] ) ) {
322 $result .= ':' . $bits['port'];
323 }
324 $result = $this->escapeUrlForCSP( $result );
325 }
326 return $result;
327 }
328
329 /**
330 * Get additional script sources
331 *
332 * @return array Additional sources for loading scripts from
333 */
334 private function getAdditionalSelfUrlsScript() {
335 $additionalUrls = [];
336 // wgExtensionAssetsPath for ?debug=true mode
337 $pathVars = [ 'LoadScript', 'ExtensionAssetsPath', 'ResourceBasePath' ];
338
339 foreach ( $pathVars as $path ) {
340 $url = $this->mwConfig->get( $path );
341 $preparedUrl = $this->prepareUrlForCSP( $url );
342 if ( $preparedUrl ) {
343 $additionalUrls[] = $preparedUrl;
344 }
345 }
346 $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
347 foreach ( $RLSources as $wiki => $sources ) {
348 foreach ( $sources as $id => $value ) {
349 $url = $this->prepareUrlForCSP( $value );
350 if ( $url ) {
351 $additionalUrls[] = $url;
352 }
353 }
354 }
355
356 return array_unique( $additionalUrls );
357 }
358
359 /**
360 * Get additional host names for the wiki (e.g. if static content loaded elsewhere)
361 *
362 * @note These are general load sources, not script sources
363 * @return array Array of other urls for wiki (for use in default-src)
364 */
365 private function getAdditionalSelfUrls() {
366 // XXX on a foreign repo, the included description page can have anything on it,
367 // including inline scripts. But nobody sane does that.
368
369 // In principle, you can have even more complex configs... (e.g. The urlsByExt option)
370 $pathUrls = [];
371 $additionalSelfUrls = [];
372
373 // Future todo: The zone urls should never go into
374 // style-src. They should either be only in img-src, or if
375 // img-src unspecified they should be in default-src. Similarly,
376 // the DescriptionStylesheetUrl only needs to be in style-src
377 // (or default-src if style-src unspecified).
378 $callback = function ( $repo, &$urls ) {
379 $urls[] = $repo->getZoneUrl( 'public' );
380 $urls[] = $repo->getZoneUrl( 'transcoded' );
381 $urls[] = $repo->getZoneUrl( 'thumb' );
382 $urls[] = $repo->getDescriptionStylesheetUrl();
383 };
384 $localRepo = RepoGroup::singleton()->getRepo( 'local' );
385 $callback( $localRepo, $pathUrls );
386 RepoGroup::singleton()->forEachForeignRepo( $callback, [ &$pathUrls ] );
387
388 // Globals that might point to a different domain
389 $pathGlobals = [ 'LoadScript', 'ExtensionAssetsPath', 'StylePath', 'ResourceBasePath' ];
390 foreach ( $pathGlobals as $path ) {
391 $pathUrls[] = $this->mwConfig->get( $path );
392 }
393 foreach ( $pathUrls as $path ) {
394 $preparedUrl = $this->prepareUrlForCSP( $path );
395 if ( $preparedUrl !== false ) {
396 $additionalSelfUrls[] = $preparedUrl;
397 }
398 }
399 $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
400
401 foreach ( $RLSources as $wiki => $sources ) {
402 foreach ( $sources as $id => $value ) {
403 $url = $this->prepareUrlForCSP( $value );
404 if ( $url ) {
405 $additionalSelfUrls[] = $url;
406 }
407 }
408 }
409
410 return array_unique( $additionalSelfUrls );
411 }
412
413 /**
414 * include domains that are allowed to send us CORS requests.
415 *
416 * Technically, $wgCrossSiteAJAXdomains lists things that are allowed to talk to us
417 * not things that we are allowed to talk to - but if something is allowed to talk to us,
418 * then there is a good chance that we should probably be allowed to talk to it.
419 *
420 * This is configurable with the 'includeCORS' key in the CSP config, and enabled
421 * by default.
422 * @note CORS domains with single character ('?') wildcards, are not included.
423 * @return array Additional hosts
424 */
425 private function getCORSSources() {
426 $additionalUrls = [];
427 $CORSSources = $this->mwConfig->get( 'CrossSiteAJAXdomains' );
428 foreach ( $CORSSources as $source ) {
429 if ( strpos( $source, '?' ) !== false ) {
430 // CSP doesn't support single char wildcard
431 continue;
432 }
433 $url = $this->prepareUrlForCSP( $source );
434 if ( $url ) {
435 $additionalUrls[] = $url;
436 }
437 }
438 return $additionalUrls;
439 }
440
441 /**
442 * CSP spec says ',' and ';' are not allowed to appear in urls.
443 *
444 * @note This assumes that normal escaping has been applied to the url
445 * @param string $url URL (or possibly just part of one)
446 * @return string
447 */
448 private function escapeUrlForCSP( $url ) {
449 return str_replace(
450 [ ';', ',' ],
451 [ '%3B', '%2C' ],
452 $url
453 );
454 }
455
456 /**
457 * Does this browser give false positive reports?
458 *
459 * Some versions of firefox (40-42) incorrectly report a csp
460 * violation for nonce sources, despite allowing them.
461 *
462 * @see https://bugzilla.mozilla.org/show_bug.cgi?id=1026520
463 * @param string $ua User-agent header
464 * @return bool
465 */
466 public static function falsePositiveBrowser( $ua ) {
467 return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
468 }
469
470 /**
471 * Should we set nonce attribute
472 *
473 * @param Config $config Configuration object
474 * @return bool
475 */
476 public static function isNonceRequired( Config $config ) {
477 $configs = [
478 $config->get( 'CSPHeader' ),
479 $config->get( 'CSPReportOnlyHeader' )
480 ];
481 foreach ( $configs as $headerConfig ) {
482 if (
483 $headerConfig === true ||
484 ( is_array( $headerConfig ) &&
485 !isset( $headerConfig['useNonces'] ) ) ||
486 ( is_array( $headerConfig ) &&
487 isset( $headerConfig['useNonces'] ) &&
488 $headerConfig['useNonces'] )
489 ) {
490 return true;
491 }
492 }
493 return false;
494 }
495 }