From 70941efd35562dcb7003229b56c91a98a67de7a9 Mon Sep 17 00:00:00 2001 From: Brian Wolff Date: Sun, 28 Feb 2016 23:13:10 -0500 Subject: [PATCH] Initial support for Content Security Policy, disabled by default The primary goal here is a defense in depth measure to stop an attacker who found a bug in the parser allowing them to insert malicious attributes. This wouldn't stop someone who could insert a full script tag (since at current it can't distinguish between malicious and legit user js). It also would not prevent DOM-based or reflected XSS for anons, as the nonce value is guessable for anons when receiving a response cached by varnish. However, the limited protection of just stopping stored XSS where the attacker only has control of attributes, is still a big win in my opinion. (But it wouldn't prevent someone who has that type of xss from abusing things like data-ooui attribute). This will likely break many gadgets. Its expected that any sort of rollout on Wikimedia will be done very slowly, with lots of testing and the report-only option to begin with. This is behind feature flags that are off by default, so merging this patch should not cause any change in default behaviour. This may break some extensions (The most obvious one is charinsert (See fe648d41005), but will probably need some testing in report-only mode to see if anything else breaks) This uses the unsafe-eval option of CSP, in order to support RL's local storage thingy. For better security, we may want to remove some of the sillier uses of eval (e.g. jquery.ui.datepicker.js). For more info, see spec: https://www.w3.org/TR/CSP2/ Additionally see: https://www.mediawiki.org/wiki/Requests_for_comment/Content-Security-Policy Bug: T135963 Change-Id: I80f6f469ba4c0b608385483457df96ccb7429ae5 --- RELEASE-NOTES-1.32 | 4 + autoload.php | 1 + docs/hooks.txt | 25 + includes/ContentSecurityPolicy.php | 527 ++++++++++++++++++ includes/DefaultSettings.php | 28 + includes/EditPage.php | 5 +- includes/GlobalFunctions.php | 5 +- includes/Html.php | 20 +- includes/OutputPage.php | 44 +- includes/api/ApiCSPReport.php | 20 +- includes/debug/MWDebug.php | 3 +- includes/resourceloader/ResourceLoader.php | 17 +- .../ResourceLoaderClientHtml.php | 45 +- includes/skins/Skin.php | 6 +- includes/skins/SkinTemplate.php | 2 +- .../specialpage/ChangesListSpecialPage.php | 3 +- .../includes/ContentSecurityPolicyTest.php | 310 +++++++++++ tests/phpunit/includes/OutputPageTest.php | 46 +- .../ResourceLoaderClientHtmlTest.php | 8 +- 19 files changed, 1073 insertions(+), 46 deletions(-) create mode 100644 includes/ContentSecurityPolicy.php create mode 100644 tests/phpunit/includes/ContentSecurityPolicyTest.php diff --git a/RELEASE-NOTES-1.32 b/RELEASE-NOTES-1.32 index 9fd3161f1e..8e4491008a 100644 --- a/RELEASE-NOTES-1.32 +++ b/RELEASE-NOTES-1.32 @@ -17,6 +17,10 @@ production. 'html5-legacy' value for $wgFragmentMode is no longer accepted. * The experimental Html5Internal and Html5Depurate tidy drivers were removed. RemexHtml, which is the default, should be used instead. +* (T135963) You can now define a Content Security Policy for your wiki. This + adds a defense-in-depth feature to stop an attacker who has found a bug in + the parser allowing them to insert malicious attributes. Disabled by default, + you can configure this via $wgCSPHeader and $wgCSPReportOnlyHeader. === New features in 1.32 === * (T112474) Generalized the ResourceLoader mechanism for overriding modules diff --git a/autoload.php b/autoload.php index ec0d59f479..6aa832c0b2 100644 --- a/autoload.php +++ b/autoload.php @@ -303,6 +303,7 @@ $wgAutoloadLocalClasses = [ 'Content' => __DIR__ . '/includes/content/Content.php', 'ContentHandler' => __DIR__ . '/includes/content/ContentHandler.php', 'ContentModelLogFormatter' => __DIR__ . '/includes/logging/ContentModelLogFormatter.php', + 'ContentSecurityPolicy' => __DIR__ . '/includes/ContentSecurityPolicy.php', 'ContextSource' => __DIR__ . '/includes/context/ContextSource.php', 'ContribsPager' => __DIR__ . '/includes/specials/pagers/ContribsPager.php', 'ConvertExtensionToRegistration' => __DIR__ . '/maintenance/convertExtensionToRegistration.php', diff --git a/docs/hooks.txt b/docs/hooks.txt index b38bd666e4..9404e1448d 100644 --- a/docs/hooks.txt +++ b/docs/hooks.txt @@ -1186,6 +1186,31 @@ $lossy: boolean indicating whether lossy conversion is allowed. converted Content object. Note that $result->getContentModel() must return $toModel. +'ContentSecurityPolicyDefaultSource': Modify the allowed CSP load sources. This affects all +directives except for the script directive. If you want to add a script +source, see ContentSecurityPolicyScriptSource hook. +&$defaultSrc: Array of Content-Security-Policy allowed sources +$policyConfig: Current configuration for the Content-Security-Policy header +$mode: ContentSecurityPolicy::REPORT_ONLY_MODE or ContentSecurityPolicy::FULL_MODE + depending on type of header + +'ContentSecurityPolicyDirectives': Modify the content security policy directives. +Use this only if ContentSecurityPolicyDefaultSource and +ContentSecurityPolicyScriptSource do not meet your needs. +&$directives: Array of CSP directives +$policyConfig: Current configuration for the CSP header +$mode: ContentSecurityPolicy::REPORT_ONLY_MODE or + ContentSecurityPolicy::FULL_MODE depending on type of header + +'ContentSecurityPolicyScriptSource': Modify the allowed CSP script sources. +Note that you also have to use ContentSecurityPolicyDefaultSource if you +want non-script sources to be loaded from +whatever you add. +&$scriptSrc: Array of CSP directives +$policyConfig: Current configuration for the CSP header +$mode: ContentSecurityPolicy::REPORT_ONLY_MODE or ContentSecurityPolicy::FULL_MODE + depending on type of header + 'CustomEditor': When invoking the page editor Return true to allow the normal editor to be used, or false if implementing a custom editor, e.g. for a special namespace, etc. diff --git a/includes/ContentSecurityPolicy.php b/includes/ContentSecurityPolicy.php new file mode 100644 index 0000000000..21d7d57dcd --- /dev/null +++ b/includes/ContentSecurityPolicy.php @@ -0,0 +1,527 @@ +nonce = $nonce; + $this->response = $response; + $this->mwConfig = $mwConfig; + } + + /** + * Send a single CSP header based on a given policy config. + * + * @note Most callers will probably want ContentSecurityPolicy::sendHeaders() instead. + * @param array $csp ContentSecurityPolicy configuration + * @param int $reportOnly self::*_MODE constant + */ + public function sendCSPHeader( $csp, $reportOnly ) { + $policy = $this->makeCSPDirectives( $csp, $reportOnly ); + $headerName = $this->getHeaderName( $reportOnly ); + if ( $policy ) { + $this->response->header( + "$headerName: $policy" + ); + } + } + + /** + * Return the meta header to use for after load restricted mode + * + * This should restrict browsers that don't support nonce-sources. + * Idea stolen from + * https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/ + * + * @param array $csp CSP configuration + * @return string Content for meta tag + */ + public function getMetaHeader( $csp ) { + return $this->makeCSPDirectives( $csp, self::FULL_MODE_RESTRICTED ); + } + + /** + * Send CSP headers based on wiki config + * + * Main method that callers are expected to use + * @param IContextSource $context A context object, the associated OutputPage + * object must be the one that the page in question was generated with. + */ + public static function sendHeaders( IContextSource $context ) { + $out = $context->getOutput(); + $csp = new ContentSecurityPolicy( + $out->getCSPNonce(), + $context->getRequest()->response(), + $context->getConfig() + ); + + $cspConfig = $context->getConfig()->get( 'CSPHeader' ); + $cspConfigReportOnly = $context->getConfig()->get( 'CSPReportOnlyHeader' ); + + $csp->sendCSPHeader( $cspConfig, self::FULL_MODE ); + $csp->sendCSPHeader( $cspConfigReportOnly, self::REPORT_ONLY_MODE ); + + // Include header which increases security level after initial load. + // This helps mitigate attacks on browsers not supporting CSP2. It also + // helps mitigate attacks due to the shared nonce that non-logged in users + // get due to varnish cache. + // Unclear if this is the best place to insert the meta tag, or if + // it should be in a RL module. I figure its best to do this as early + // as possible. + // FIXME: Needs testing to see if this actually works properly + $metaHeader = $csp->getMetaHeader( $cspConfig ); + if ( $metaHeader ) { + $context->getOutput()->addScript( + ResourceLoader::makeInlineScript( + $csp->makeMetaInsertScript( + $metaHeader + ), + $out->getCSPNonce() + ) + ); + } + } + + /** + * Makes javascript to insert a meta CSP header after page load + * + * @see https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/ + * @param string $metaContents content of meta tag + * @return string JS for including in page + */ + private function makeMetaInsertScript( $metaContents ) { + return "$('\\x3Cmeta http-equiv=\"Content-Security-Policy\"\\x3E')" . + '.attr("content",' . + Xml::encodeJsVar( $metaContents ) . + ').prependTo($("head"))'; + } + + /** + * Get the name of the HTTP header to use. + * + * @param int $reportOnly Either self::REPORT_ONLY_MODE or self::FULL_MODE + * @return string Name of http header + * @throws UnexpectedValueException if you feed it self::FULL_MODE_RESTRICTED. + */ + private function getHeaderName( $reportOnly ) { + if ( $reportOnly === self::REPORT_ONLY_MODE ) { + return 'Content-Security-Policy-Report-Only'; + } elseif ( $reportOnly === self::FULL_MODE ) { + return 'Content-Security-Policy'; + } + throw new UnexpectedValueException( $reportOnly ); + } + + /** + * Determine what CSP policies to set for this page + * + * @param array|bool $config Policy configuration (Either $wgCSPHeader or $wgCSPReportOnlyHeader) + * @param int $mode self::REPORT_ONLY_MODE, self::FULL_MODE or Self::FULL_MODE_RESTRICTED + * @return string Policy directives, or empty string for no policy. + */ + private function makeCSPDirectives( $policyConfig, $mode ) { + if ( $policyConfig === false ) { + // CSP is disabled + return ''; + } + if ( $policyConfig === true ) { + $policyConfig = []; + } + + $mwConfig = $this->mwConfig; + + $additionalSelfUrls = $this->getAdditionalSelfUrls(); + $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript(); + $nonceSrc = "'nonce-" . $this->nonce . "'"; + + // If no default-src is sent at all, it + // seems browsers (or at least some), interpret + // that as allow anything, but the spec seems + // to imply that data: and blob: should be + // blocked. + $defaultSrc = [ '*', 'data:', 'blob:' ]; + + $cssSrc = false; + $imgSrc = false; + $scriptSrc = [ "'unsafe-eval'", "'self'" ]; + if ( $mode !== self::FULL_MODE_RESTRICTED ) { + $scriptSrc[] = $nonceSrc; + } + $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript ); + if ( isset( $policyConfig['script-src'] ) + && is_array( $policyConfig['script-src'] ) + ) { + foreach ( $policyConfig['script-src'] as $src ) { + $scriptSrc[] = $this->escapeUrlForCSP( $src ); + } + } + // Note: default on if unspecified. + if ( ( !isset( $policyConfig['unsafeFallback'] ) + || $policyConfig['unsafeFallback'] ) + && $mode !== self::FULL_MODE_RESTRICTED + ) { + // unsafe-inline should be ignored on browsers + // that support 'nonce-foo' sources. + // Some older versions of firefox don't follow this + // rule, but new browsers do. (Should be for at least + // firefox 40+). + $scriptSrc[] = "'unsafe-inline'"; + } + // If default source option set to true or + // an array of urls, set a restrictive default-src. + // If set to false, we send a lenient default-src, + // see the code above where $defaultSrc is set initially. + if ( isset( $policyConfig['default-src'] ) + && $policyConfig['default-src'] !== false + ) { + $defaultSrc = array_merge( + [ "'self'", 'data:', 'blob:' ], + $additionalSelfUrls + ); + if ( is_array( $policyConfig['default-src'] ) ) { + foreach ( $policyConfig['default-src'] as $src ) { + $defaultSrc[] = $this->escapeUrlForCSP( $src ); + } + } + } + + if ( !isset( $policyConfig['includeCORS'] ) || $policyConfig['includeCORS'] ) { + $CORSUrls = $this->getCORSSources(); + if ( !in_array( '*', $defaultSrc ) ) { + $defaultSrc = array_merge( $defaultSrc, $CORSUrls ); + } + // Unlikely to have * in scriptSrc, but doesn't + // hurt to check. + if ( !in_array( '*', $scriptSrc ) ) { + $scriptSrc = array_merge( $scriptSrc, $CORSUrls ); + } + } + + Hooks::run( 'ContentSecurityPolicyDefaultSource', [ &$defaultSrc, $policyConfig, $mode ] ); + Hooks::run( 'ContentSecurityPolicyScriptSource', [ &$scriptSrc, $policyConfig, $mode ] ); + + // Check if array just in case the hook made it false + if ( is_array( $defaultSrc ) ) { + $cssSrc = array_merge( $defaultSrc, [ "'unsafe-inline'" ] ); + } + + if ( $mode === self::FULL_MODE_RESTRICTED ) { + // report-uri disallowed in tags. + $reportUri = false; + } elseif ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) { + if ( $policyConfig['report-uri'] === false ) { + $reportUri = false; + } else { + $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] ); + } + } else { + $reportUri = $this->getReportUri( $mode ); + } + + // Only send an img-src, if we're sending a restricitve default. + if ( !is_array( $defaultSrc ) + || !in_array( '*', $defaultSrc ) + || !in_array( 'data:', $defaultSrc ) + || !in_array( 'blob:', $defaultSrc ) + ) { + // A future todo might be to make the whitelist options only + // add all the whitelisted sites to the header, instead of + // allowing all (Assuming there is a small number of sites). + // For now, the external image feature disables the limits + // CSP puts on external images. + if ( $mwConfig->get( 'AllowExternalImages' ) + || $mwConfig->get( 'AllowExternalImagesFrom' ) + || $mwConfig->get( 'AllowImageTag' ) + ) { + $imgSrc = [ '*', 'data:', 'blob:' ]; + } elseif ( $mwConfig->get( 'EnableImageWhitelist' ) ) { + $whitelist = wfMessage( 'external_image_whitelist' ) + ->inContentLanguage() + ->plain(); + if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) { + $imgSrc = [ '*', 'data:', 'blob:' ]; + } + } + } + + $directives = []; + if ( $scriptSrc ) { + $directives[] = 'script-src ' . implode( ' ', $scriptSrc ); + } + if ( $defaultSrc ) { + $directives[] = 'default-src ' . implode( ' ', $defaultSrc ); + } + if ( $cssSrc ) { + $directives[] = 'style-src ' . implode( ' ', $cssSrc ); + } + if ( $imgSrc ) { + $directives[] = 'img-src ' . implode( ' ', $imgSrc ); + } + if ( $reportUri ) { + $directives[] = 'report-uri ' . $reportUri; + } + + Hooks::run( 'ContentSecurityPolicyDirectives', [ &$directives, $policyConfig, $mode ] ); + + return implode( '; ', $directives ); + } + + /** + * Get the default report uri. + * + * @param int $mode self::*_MODE constant. Do not use with self::FULL_MODE_RESTRICTED + * @return string The URI to send reports to. + * @throws UnexpectedValueException if given invalid mode. + */ + private function getReportUri( $mode ) { + if ( $mode === self::FULL_MODE_RESTRICTED ) { + throw new UnexpectedValueException( $mode ); + } + $apiArguments = [ + 'action' => 'cspreport', + 'format' => 'json' + ]; + if ( $mode === self::REPORT_ONLY_MODE ) { + $apiArguments['reportonly'] = '1'; + } + $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments ); + + // Per spec, ';' and ',' must be hex-escaped in report uri + $reportUri = $this->escapeUrlForCSP( $reportUri ); + return $reportUri; + } + + /** + * Given a url, convert to form needed for CSP. + * + * Currently this does either scheme + host, or + * if protocol relative, just the host. Future versions + * could potentially preserve some of the path, if its determined + * that that would be a good idea. + * + * @note This does the extra escaping for CSP, but assumes the url + * has already had normal url escaping applied. + * @note This discards urls same as server name, as 'self' directive + * takes care of that. + * @param string $url + * @return string|bool Converted url or false on failure + */ + private function prepareUrlForCSP( $url ) { + $result = false; + if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) { + // A schema source (e.g. blob: or data:) + return $url; + } + $bits = wfParseUrl( $url ); + if ( !$bits && strpos( $url, '/' ) === false ) { + // probably something like example.com. + // try again protocol-relative. + $url = '//' . $url; + $bits = wfParseUrl( $url ); + } + if ( $bits && isset( $bits['host'] ) + && $bits['host'] !== $this->mwConfig->get( 'ServerName' ) + ) { + $result = $bits['host']; + if ( $bits['scheme'] !== '' ) { + $result = $bits['scheme'] . $bits['delimiter'] . $result; + } + if ( isset( $bits['port'] ) ) { + $result .= ':' . $bits['port']; + } + $result = $this->escapeUrlForCSP( $result ); + } + return $result; + } + + /** + * Get additional script sources + * + * @return array Additional sources for loading scripts from + */ + private function getAdditionalSelfUrlsScript() { + $additionalUrls = []; + // wgExtensionAssetsPath for ?debug=true mode + $pathVars = [ 'LoadScript', 'ExtensionAssetsPath', 'ResourceBasePath' ]; + + foreach ( $pathVars as $path ) { + $url = $this->mwConfig->get( $path ); + $preparedUrl = $this->prepareUrlForCSP( $url ); + if ( $preparedUrl ) { + $additionalUrls[] = $preparedUrl; + } + } + $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' ); + foreach ( $RLSources as $wiki => $sources ) { + foreach ( $sources as $id => $value ) { + $url = $this->prepareUrlForCSP( $value ); + if ( $url ) { + $additionalUrls[] = $url; + } + } + } + + return array_unique( $additionalUrls ); + } + + /** + * Get additional host names for the wiki (e.g. if static content loaded elsewhere) + * + * @note These are general load sources, not script sources + * @return array Array of other urls for wiki (for use in default-src) + */ + private function getAdditionalSelfUrls() { + // XXX on a foreign repo, the included description page can have anything on it, + // including inline scripts. But nobody sane does that. + + // In principle, you can have even more complex configs... (e.g. The urlsByExt option) + $pathUrls = []; + $additionalSelfUrls = []; + + // Future todo: The zone urls should never go into + // style-src. They should either be only in img-src, or if + // img-src unspecified they should be in default-src. Similarly, + // the DescriptionStylesheetUrl only needs to be in style-src + // (or default-src if style-src unspecified). + $callback = function ( $repo, &$urls ) { + $urls[] = $repo->getZoneUrl( 'public' ); + $urls[] = $repo->getZoneUrl( 'transcoded' ); + $urls[] = $repo->getZoneUrl( 'thumb' ); + $urls[] = $repo->getDescriptionStylesheetUrl(); + }; + $localRepo = RepoGroup::singleton()->getRepo( 'local' ); + $callback( $localRepo, $pathUrls ); + RepoGroup::singleton()->forEachForeignRepo( $callback, [ &$pathUrls ] ); + + // Globals that might point to a different domain + $pathGlobals = [ 'LoadScript', 'ExtensionAssetsPath', 'StylePath', 'ResourceBasePath' ]; + foreach ( $pathGlobals as $path ) { + $pathUrls[] = $this->mwConfig->get( $path ); + } + foreach ( $pathUrls as $path ) { + $preparedUrl = $this->prepareUrlForCSP( $path ); + if ( $preparedUrl !== false ) { + $additionalSelfUrls[] = $preparedUrl; + } + } + $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' ); + + foreach ( $RLSources as $wiki => $sources ) { + foreach ( $sources as $id => $value ) { + $url = $this->prepareUrlForCSP( $value ); + if ( $url ) { + $additionalSelfUrls[] = $url; + } + } + } + + return array_unique( $additionalSelfUrls ); + } + + /** + * include domains that are allowed to send us CORS requests. + * + * Technically, $wgCrossSiteAJAXdomains lists things that are allowed to talk to us + * not things that we are allowed to talk to - but if something is allowed to talk to us, + * then there is a good chance that we should probably be allowed to talk to it. + * + * This is configurable with the 'includeCORS' key in the CSP config, and enabled + * by default. + * @note CORS domains with single character ('?') wildcards, are not included. + * @return array Additional hosts + */ + private function getCORSSources() { + $additionalUrls = []; + $CORSSources = $this->mwConfig->get( 'CrossSiteAJAXdomains' ); + foreach ( $CORSSources as $source ) { + if ( strpos( $source, '?' ) !== false ) { + // CSP doesn't support single char wildcard + continue; + } + $url = $this->prepareUrlForCSP( $source ); + if ( $url ) { + $additionalUrls[] = $url; + } + } + return $additionalUrls; + } + + /** + * CSP spec says ',' and ';' are not allowed to appear in urls. + * + * @note This assumes that normal escaping has been applied to the url + * @param string $url URL (or possibly just part of one) + * @return string + */ + private function escapeUrlForCSP( $url ) { + return str_replace( + [ ';', ',' ], + [ '%3B', '%2C' ], + $url + ); + } + + /** + * Does this browser give false positive reports? + * + * Some versions of firefox (40-42) incorrectly report a csp + * violation for nonce sources, despite allowing them. + * + * @see https://bugzilla.mozilla.org/show_bug.cgi?id=1026520 + * @param string $ua User-agent header + * @return bool + */ + public static function falsePositiveBrowser( $ua ) { + return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua ); + } + + /** + * Is CSP currently enabled (i.e. Should we set nonce attribute) + * + * @param Config $config Configuration object + * @return bool + */ + public static function isEnabled( Config $config ) { + return $config->get( 'CSPHeader' ) !== false + || $config->get( 'CSPReportOnlyHeader' ) !== false; + } +} diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index dcf648e79c..72375fbfaf 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -8752,6 +8752,34 @@ $wgMaxUserDBWriteDuration = false; */ $wgMaxJobDBWriteDuration = false; +/** + * Controls Content-Security-Policy header [Experimental] + * + * @see https://www.w3.org/TR/CSP2/ + * @since 1.32 + * @var bool|array true to send default version, false to not send. + * If an array, can have parameters: + * 'default-src' If true or array (of additional urls) will set a default-src + * directive, which limits what places things can load from. If false or not + * set, will send a default-src directive allowing all sources. + * 'includeCORS' If true or not set, will include urls from + * $wgCrossSiteAJAXdomains as an allowed load sources. + * 'unsafeFallback' Add unsafe-inline as a script source, as a fallback for + * browsers that do not understand nonce-sources [default on]. + * 'script-src' Array of additional places that are allowed to have JS be loaded from. + * 'report-uri' true to use MW api [default], false to disable, string for alternate uri + * @warning May cause slowness on windows due to slow random number generator. + */ +$wgCSPHeader = false; + +/** + * Controls Content-Security-Policy-Report-Only header + * + * @since 1.32 + * @var bool|array Same as $wgCSPHeader + */ +$wgCSPReportOnlyHeader = false; + /** * Mapping of event channels (or channel categories) to EventRelayer configuration. * diff --git a/includes/EditPage.php b/includes/EditPage.php index 4f6b7b4bbb..6d39e3a03d 100644 --- a/includes/EditPage.php +++ b/includes/EditPage.php @@ -4111,12 +4111,15 @@ ERROR; $script .= '});'; + $nonce = $wgOut->getCSPNonce(); + $wgOut->addScript( ResourceLoader::makeInlineScript( $script, $nonce ) ); + $toolbar = '
'; if ( Hooks::run( 'EditPageBeforeEditToolbar', [ &$toolbar ] ) ) { // Only add the old toolbar cruft to the page payload if the toolbar has not // been over-written by a hook caller - $wgOut->addScript( ResourceLoader::makeInlineScript( $script ) ); + $wgOut->addScript( ResourceLoader::makeInlineScript( $script, $nonce ) ); }; return $toolbar; diff --git a/includes/GlobalFunctions.php b/includes/GlobalFunctions.php index 9569bc1fb4..7e8df7ee48 100644 --- a/includes/GlobalFunctions.php +++ b/includes/GlobalFunctions.php @@ -1513,9 +1513,10 @@ function wfHostname() { * If $wgShowHostnames is true, the script will also set 'wgHostname' to the * hostname of the server handling the request. * + * @param string $nonce Value from OutputPage::getCSPNonce * @return string */ -function wfReportTime() { +function wfReportTime( $nonce = null ) { global $wgShowHostnames; $elapsed = ( microtime( true ) - $_SERVER['REQUEST_TIME_FLOAT'] ); @@ -1525,7 +1526,7 @@ function wfReportTime() { if ( $wgShowHostnames ) { $reportVars['wgHostname'] = wfHostname(); } - return Skin::makeVariablesScript( $reportVars ); + return Skin::makeVariablesScript( $reportVars, $nonce ); } /** diff --git a/includes/Html.php b/includes/Html.php index 3bcf13132f..019e0785f9 100644 --- a/includes/Html.php +++ b/includes/Html.php @@ -557,10 +557,18 @@ class Html { * literal "" or (for XML) literal "]]>". * * @param string $contents JavaScript + * @param string $nonce Nonce for CSP header, from OutputPage::getCSPNonce() * @return string Raw HTML */ - public static function inlineScript( $contents ) { + public static function inlineScript( $contents, $nonce = null ) { $attrs = []; + if ( $nonce !== null ) { + $attrs['nonce'] = $nonce; + } else { + if ( ContentSecurityPolicy::isEnabled( RequestContext::getMain()->getConfig() ) ) { + wfWarn( "no nonce set on script. CSP will break it" ); + } + } if ( preg_match( '/[<&]/', $contents ) ) { $contents = "/**/"; @@ -574,10 +582,18 @@ class Html { * "". * * @param string $url + * @param string $nonce Nonce for CSP header, from OutputPage::getCSPNonce() * @return string Raw HTML */ - public static function linkedScript( $url ) { + public static function linkedScript( $url, $nonce = null ) { $attrs = [ 'src' => $url ]; + if ( $nonce !== null ) { + $attrs['nonce'] = $nonce; + } else { + if ( ContentSecurityPolicy::isEnabled( RequestContext::getMain()->getConfig() ) ) { + wfWarn( "no nonce set on script. CSP will break it" ); + } + } return self::element( 'script', $attrs ); } diff --git a/includes/OutputPage.php b/includes/OutputPage.php index fbc7b60439..52dfc1164e 100644 --- a/includes/OutputPage.php +++ b/includes/OutputPage.php @@ -304,6 +304,11 @@ class OutputPage extends ContextSource { */ private $mLinkHeader = []; + /** + * @var string The nonce for Content-Security-Policy + */ + private $CSPNonce; + /** * Constructor for OutputPage. This should not be called directly. * Instead a new RequestContext should be created and it will implicitly create @@ -475,7 +480,7 @@ class OutputPage extends ContextSource { if ( is_null( $version ) ) { $version = $this->getConfig()->get( 'StyleVersion' ); } - $this->addScript( Html::linkedScript( wfAppendQuery( $path, $version ) ) ); + $this->addScript( Html::linkedScript( wfAppendQuery( $path, $version ), $this->getCSPNonce() ) ); } /** @@ -485,7 +490,7 @@ class OutputPage extends ContextSource { * @param string $script JavaScript text, no script tags */ public function addInlineScript( $script ) { - $this->mScripts .= Html::inlineScript( $script ); + $this->mScripts .= Html::inlineScript( "\n$script\n", $this->getCSPNonce() ) . "\n"; } /** @@ -2433,6 +2438,8 @@ class OutputPage extends ContextSource { $response->header( "X-Frame-Options: $frameOptions" ); } + ContentSecurityPolicy::sendHeaders( $this ); + if ( $this->mArticleBodyOnly ) { echo $this->mBodytext; } else { @@ -2900,7 +2907,7 @@ class OutputPage extends ContextSource { } $pieces[] = Html::element( 'title', null, $this->getHTMLTitle() ); - $pieces[] = $this->getRlClient()->getHeadHtml(); + $pieces[] = $this->getRlClient()->getHeadHtml( $this->getCSPNonce() ); $pieces[] = $this->buildExemptModules(); $pieces = array_merge( $pieces, array_values( $this->getHeadLinksArray() ) ); $pieces = array_merge( $pieces, array_values( $this->mHeadItems ) ); @@ -2911,7 +2918,8 @@ class OutputPage extends ContextSource { ResourceLoaderContext::newDummyContext(), [ 'html5shiv' ], ResourceLoaderModule::TYPE_SCRIPTS, - [ 'sync' => true ] + [ 'sync' => true ], + $this->getCSPNonce() ) . ''; @@ -2992,7 +3000,8 @@ class OutputPage extends ContextSource { $this->getRlClientContext(), $modules, $only, - $extraQuery + $extraQuery, + $this->getCSPNonce() ); } @@ -3025,7 +3034,8 @@ class OutputPage extends ContextSource { $chunks[] = ResourceLoader::makeInlineScript( ResourceLoader::makeConfigSetScript( [ 'wgPageParseReport' => $this->limitReportJSData ] - ) + ), + $this->getCSPNonce() ); } @@ -3992,4 +4002,26 @@ class OutputPage extends ContextSource { ); } } + + /** + * Get (and set if not yet set) the CSP nonce. + * + * This value needs to be included in any ' ); } diff --git a/includes/resourceloader/ResourceLoaderClientHtml.php b/includes/resourceloader/ResourceLoaderClientHtml.php index bb8ab32998..d0a9c4248a 100644 --- a/includes/resourceloader/ResourceLoaderClientHtml.php +++ b/includes/resourceloader/ResourceLoaderClientHtml.php @@ -248,9 +248,10 @@ class ResourceLoaderClientHtml { * - Inline scripts can't be asynchronous. * - For styles, earlier is better. * + * @param string $nonce From OutputPage::getCSPNonce() * @return string|WrappedStringList HTML */ - public function getHeadHtml() { + public function getHeadHtml( $nonce ) { $data = $this->getData(); $chunks = []; @@ -259,13 +260,15 @@ class ResourceLoaderClientHtml { // See also #getDocumentAttributes() and /resources/src/startup.js. $chunks[] = Html::inlineScript( 'document.documentElement.className = document.documentElement.className' - . '.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );' + . '.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );', + $nonce ); // Inline RLQ: Set page variables if ( $this->config ) { $chunks[] = ResourceLoader::makeInlineScript( - ResourceLoader::makeConfigSetScript( $this->config ) + ResourceLoader::makeConfigSetScript( $this->config ), + $nonce ); } @@ -273,7 +276,8 @@ class ResourceLoaderClientHtml { $states = array_merge( $this->exemptStates, $data['states'] ); if ( $states ) { $chunks[] = ResourceLoader::makeInlineScript( - ResourceLoader::makeLoaderStateScript( $states ) + ResourceLoader::makeLoaderStateScript( $states ), + $nonce ); } @@ -281,14 +285,16 @@ class ResourceLoaderClientHtml { if ( $data['embed']['general'] ) { $chunks[] = $this->getLoad( $data['embed']['general'], - ResourceLoaderModule::TYPE_COMBINED + ResourceLoaderModule::TYPE_COMBINED, + $nonce ); } // Inline RLQ: Load general modules if ( $data['general'] ) { $chunks[] = ResourceLoader::makeInlineScript( - Xml::encodeJsCall( 'mw.loader.load', [ $data['general'] ] ) + Xml::encodeJsCall( 'mw.loader.load', [ $data['general'] ] ), + $nonce ); } @@ -296,7 +302,8 @@ class ResourceLoaderClientHtml { if ( $data['scripts'] ) { $chunks[] = $this->getLoad( $data['scripts'], - ResourceLoaderModule::TYPE_SCRIPTS + ResourceLoaderModule::TYPE_SCRIPTS, + $nonce ); } @@ -304,7 +311,8 @@ class ResourceLoaderClientHtml { if ( $data['styles'] ) { $chunks[] = $this->getLoad( $data['styles'], - ResourceLoaderModule::TYPE_STYLES + ResourceLoaderModule::TYPE_STYLES, + $nonce ); } @@ -312,7 +320,8 @@ class ResourceLoaderClientHtml { if ( $data['embed']['styles'] ) { $chunks[] = $this->getLoad( $data['embed']['styles'], - ResourceLoaderModule::TYPE_STYLES + ResourceLoaderModule::TYPE_STYLES, + $nonce ); } @@ -324,6 +333,7 @@ class ResourceLoaderClientHtml { $chunks[] = $this->getLoad( 'startup', ResourceLoaderModule::TYPE_SCRIPTS, + $nonce, $startupQuery ); @@ -341,8 +351,8 @@ class ResourceLoaderClientHtml { return self::makeContext( $this->context, $group, $type ); } - private function getLoad( $modules, $only, array $extraQuery = [] ) { - return self::makeLoad( $this->context, (array)$modules, $only, $extraQuery ); + private function getLoad( $modules, $only, $nonce, array $extraQuery = [] ) { + return self::makeLoad( $this->context, (array)$modules, $only, $extraQuery, $nonce ); } private static function makeContext( ResourceLoaderContext $mainContext, $group, $type, @@ -369,11 +379,12 @@ class ResourceLoaderClientHtml { * @param ResourceLoaderContext $mainContext * @param array $modules One or more module names * @param string $only ResourceLoaderModule TYPE_ class constant - * @param array $extraQuery [optional] Array with extra query parameters for the request + * @param array $extraQuery Array with extra query parameters for the request + * @param string $nonce See OutputPage::getCSPNonce() [Since 1.32] * @return string|WrappedStringList HTML */ public static function makeLoad( ResourceLoaderContext $mainContext, array $modules, $only, - array $extraQuery = [] + array $extraQuery, $nonce ) { $rl = $mainContext->getResourceLoader(); $chunks = []; @@ -385,7 +396,7 @@ class ResourceLoaderClientHtml { $chunks = []; // Recursively call us for every item foreach ( $modules as $name ) { - $chunks[] = self::makeLoad( $mainContext, [ $name ], $only, $extraQuery ); + $chunks[] = self::makeLoad( $mainContext, [ $name ], $only, $extraQuery, $nonce ); } return new WrappedStringList( "\n", $chunks ); } @@ -427,7 +438,8 @@ class ResourceLoaderClientHtml { ); } else { $chunks[] = ResourceLoader::makeInlineScript( - $rl->makeModuleResponse( $context, $moduleSet ) + $rl->makeModuleResponse( $context, $moduleSet ), + $nonce ); } } else { @@ -461,7 +473,8 @@ class ResourceLoaderClientHtml { ] ); } else { $chunk = ResourceLoader::makeInlineScript( - Xml::encodeJsCall( 'mw.loader.load', [ $url ] ) + Xml::encodeJsCall( 'mw.loader.load', [ $url ] ), + $nonce ); } } diff --git a/includes/skins/Skin.php b/includes/skins/Skin.php index 340bc2f5bd..5dfa7e36c3 100644 --- a/includes/skins/Skin.php +++ b/includes/skins/Skin.php @@ -401,12 +401,14 @@ abstract class Skin extends ContextSource { /** * @param array $data + * @param string $nonce OutputPage::getCSPNonce() * @return string */ - static function makeVariablesScript( $data ) { + static function makeVariablesScript( $data, $nonce = null ) { if ( $data ) { return ResourceLoader::makeInlineScript( - ResourceLoader::makeConfigSetScript( $data ) + ResourceLoader::makeConfigSetScript( $data ), + $nonce ); } else { return ''; diff --git a/includes/skins/SkinTemplate.php b/includes/skins/SkinTemplate.php index 1d5d534ace..507688dfcc 100644 --- a/includes/skins/SkinTemplate.php +++ b/includes/skins/SkinTemplate.php @@ -465,7 +465,7 @@ class SkinTemplate extends Skin { $tpl->set( 'debug', '' ); $tpl->set( 'debughtml', $this->generateDebugHTML() ); - $tpl->set( 'reporttime', wfReportTime() ); + $tpl->set( 'reporttime', wfReportTime( $out->getCSPNonce() ) ); // Avoid PHP 7.1 warning of passing $this by reference $skinTemplate = $this; diff --git a/includes/specialpage/ChangesListSpecialPage.php b/includes/specialpage/ChangesListSpecialPage.php index ac13f113b2..9e61ef738b 100644 --- a/includes/specialpage/ChangesListSpecialPage.php +++ b/includes/specialpage/ChangesListSpecialPage.php @@ -785,7 +785,8 @@ abstract class ChangesListSpecialPage extends SpecialPage { $out->addHTML( ResourceLoader::makeInlineScript( - ResourceLoader::makeMessageSetScript( $messages ) + ResourceLoader::makeMessageSetScript( $messages ), + $out->getCSPNonce() ) ); diff --git a/tests/phpunit/includes/ContentSecurityPolicyTest.php b/tests/phpunit/includes/ContentSecurityPolicyTest.php new file mode 100644 index 0000000000..f0fa6118b3 --- /dev/null +++ b/tests/phpunit/includes/ContentSecurityPolicyTest.php @@ -0,0 +1,310 @@ +setMwGlobals( [ + 'wgAllowExternalImages' => false, + 'wgAllowExternalImagesFrom' => [], + 'wgAllowImageTag' => false, + 'wgEnableImageWhitelist' => false, + 'wgCrossSiteAJAXdomains' => [ + 'sister-site.somewhere.com', + '*.wikipedia.org', + '??.wikinews.org' + ], + 'wgScriptPath' => '/w', + 'wgForeignFileRepos' => [ [ + 'class' => ForeignAPIRepo::class, + 'name' => 'wikimediacommons', + 'apibase' => 'https://commons.wikimedia.org/w/api.php', + 'url' => 'https://upload.wikimedia.org/wikipedia/commons', + 'thumbUrl' => 'https://upload.wikimedia.org/wikipedia/commons/thumb', + 'hashLevels' => 2, + 'transformVia404' => true, + 'fetchDescription' => true, + 'descriptionCacheExpiry' => 43200, + 'apiThumbCacheExpiry' => 0, + 'directory' => $wgUploadDirectory, + 'backend' => 'wikimediacommons-backend', + ] ], + ] ); + // Note, there are some obscure globals which + // could affect the results which aren't included above. + + RepoGroup::destroySingleton(); + $context = RequestContext::getMain(); + $resp = $context->getRequest()->response(); + $conf = $context->getConfig(); + $csp = new ContentSecurityPolicy( 'secret', $resp, $conf ); + $this->csp = TestingAccessWrapper::newFromObject( $csp ); + + return parent::setUp(); + } + + /** + * @dataProvider providerFalsePositiveBrowser + * @covers ContentSecurityPolicy::falsePositiveBrowser + */ + public function testFalsePositiveBrowser( $ua, $expected ) { + $actual = ContentSecurityPolicy::falsePositiveBrowser( $ua ); + $this->assertEquals( $expected, $actual, $ua ); + } + + public function providerFalsePositiveBrowser() { + // @codingStandardsIgnoreStart Generic.Files.LineLength + return [ + [ 'Mozilla/5.0 (X11; Linux i686; rv:41.0) Gecko/20100101 Firefox/41.0', true ], + [ 'Mozilla/5.0 (X11; U; Linux i686; en-ca) AppleWebKit/531.2+ (KHTML, like Gecko) Version/5.0 Safari/531.2+ Debian/squeeze (2.30.6-1) Epiphany/2.30.6', false ] + ]; + // @codingStandardsIgnoreEnd Generic.Files.LineLength + } + + /** + * @dataProvider providerMakeCSPDirectives + * @covers ContentSecurityPolicy::makeCSPDirectives + */ + public function testMakeCSPDirectives( + $policy, + $expectedFull, + $expectedReport, + $expectedRestricted + ) { + $actualFull = $this->csp->makeCSPDirectives( $policy, ContentSecurityPolicy::FULL_MODE ); + $actualReport = $this->csp->makeCSPDirectives( + $policy, ContentSecurityPolicy::REPORT_ONLY_MODE + ); + $actualRestricted = $this->csp->makeCSPDirectives( + $policy, ContentSecurityPolicy::FULL_MODE_RESTRICTED + ); + $policyJson = formatJson::encode( $policy ); + $this->assertEquals( $expectedFull, $actualFull, "full: " . $policyJson ); + $this->assertEquals( $expectedReport, $actualReport, "report: " . $policyJson ); + $this->assertEquals( $expectedRestricted, $actualRestricted, "restricted: " . $policyJson ); + } + + public function providerMakeCSPDirectives() { + // @codingStandardsIgnoreStart Generic.Files.LineLength + return [ + [ false, '', '', '' ], + [ + true, + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json", + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1", + "script-src 'unsafe-eval' 'self' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'", + ], + [ + [], + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json", + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1", + "script-src 'unsafe-eval' 'self' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'", + ], + [ + [ 'script-src' => [ 'http://example.com', 'http://something,else.com' ] ], + "script-src 'unsafe-eval' 'self' 'nonce-secret' http://example.com http://something%2Celse.com 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json", + "script-src 'unsafe-eval' 'self' 'nonce-secret' http://example.com http://something%2Celse.com 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1", + "script-src 'unsafe-eval' 'self' http://example.com http://something%2Celse.com sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'", + ], + [ + [ 'unsafeFallback' => false ], + "script-src 'unsafe-eval' 'self' 'nonce-secret' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json", + "script-src 'unsafe-eval' 'self' 'nonce-secret' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1", + "script-src 'unsafe-eval' 'self' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'", + ], + [ + [ 'unsafeFallback' => true ], + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json", + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1", + "script-src 'unsafe-eval' 'self' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'", + ], + [ + [ 'default-src' => false ], + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json", + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1", + "script-src 'unsafe-eval' 'self' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'", + ], + [ + [ 'default-src' => true ], + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org sister-site.somewhere.com *.wikipedia.org; style-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org sister-site.somewhere.com *.wikipedia.org 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json", + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org sister-site.somewhere.com *.wikipedia.org; style-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org sister-site.somewhere.com *.wikipedia.org 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1", + "script-src 'unsafe-eval' 'self' sister-site.somewhere.com *.wikipedia.org; default-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org sister-site.somewhere.com *.wikipedia.org; style-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org sister-site.somewhere.com *.wikipedia.org 'unsafe-inline'", + ], + [ + [ 'default-src' => [ 'https://foo.com', 'http://bar.com', 'baz.de' ] ], + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org https://foo.com http://bar.com baz.de sister-site.somewhere.com *.wikipedia.org; style-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org https://foo.com http://bar.com baz.de sister-site.somewhere.com *.wikipedia.org 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json", + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org https://foo.com http://bar.com baz.de sister-site.somewhere.com *.wikipedia.org; style-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org https://foo.com http://bar.com baz.de sister-site.somewhere.com *.wikipedia.org 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1", + "script-src 'unsafe-eval' 'self' sister-site.somewhere.com *.wikipedia.org; default-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org https://foo.com http://bar.com baz.de sister-site.somewhere.com *.wikipedia.org; style-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org https://foo.com http://bar.com baz.de sister-site.somewhere.com *.wikipedia.org 'unsafe-inline'", + ], + [ + [ 'includeCORS' => false ], + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline'; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json", + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline'; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1", + "script-src 'unsafe-eval' 'self'; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'", + ], + [ + [ 'includeCORS' => false, 'default-src' => true ], + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline'; default-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org; style-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json", + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline'; default-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org; style-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1", + "script-src 'unsafe-eval' 'self'; default-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org; style-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org 'unsafe-inline'", + ], + [ + [ 'includeCORS' => true ], + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json", + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1", + "script-src 'unsafe-eval' 'self' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'", + ], + [ + [ 'report-uri' => false ], + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'", + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'", + "script-src 'unsafe-eval' 'self' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'", + ], + [ + [ 'report-uri' => true ], + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json", + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1", + "script-src 'unsafe-eval' 'self' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'", + ], + [ + [ 'report-uri' => 'https://example.com/index.php?foo;report=csp' ], + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri https://example.com/index.php?foo%3Breport=csp", + "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri https://example.com/index.php?foo%3Breport=csp", + "script-src 'unsafe-eval' 'self' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'", + ], + ]; + } + + /** + * @covers ContentSecurityPolicy::makeCSPDirectives + */ + public function testMakeCSPDirectivesImage() { + global $wgAllowImageTag; + $origImg = wfSetVar( $wgAllowImageTag, true ); + + $actual = $this->csp->makeCSPDirectives( true, ContentSecurityPolicy::FULL_MODE ); + + $wgAllowImageTag = $origImg; + + $expected = "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json"; + $this->assertEquals( $expected, $actual ); + } + + /** + * @covers ContentSecurityPolicy::makeCSPDirectives + */ + public function testMakeCSPDirectivesReportUri() { + $actual = $this->csp->makeCSPDirectives( + true, + ContentSecurityPolicy::REPORT_ONLY_MODE + ); + $expected = "script-src 'unsafe-eval' 'self' 'nonce-secret' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1"; + $this->assertEquals( $expected, $actual ); + // @codingStandardsIgnoreEnd Generic.Files.LineLength + } + + /** + * @covers ContentSecurityPolicy::getHeaderName + */ + public function testGetHeaderName() { + $this->assertEquals( + $this->csp->getHeaderName( ContentSecurityPolicy::REPORT_ONLY_MODE ), + 'Content-Security-Policy-Report-Only' + ); + $this->assertEquals( + $this->csp->getHeaderName( ContentSecurityPolicy::FULL_MODE ), + 'Content-Security-Policy' + ); + } + + /** + * @covers ContentSecurityPolicy::getReportUri + */ + public function testGetReportUri() { + $full = $this->csp->getReportUri( ContentSecurityPolicy::FULL_MODE ); + $fullExpected = '/w/api.php?action=cspreport&format=json'; + $this->assertEquals( $full, $fullExpected, 'normal report uri' ); + + $report = $this->csp->getReportUri( ContentSecurityPolicy::REPORT_ONLY_MODE ); + $reportExpected = $fullExpected . '&reportonly=1'; + $this->assertEquals( $report, $reportExpected, 'report only' ); + + global $wgScriptPath; + $origPath = wfSetVar( $wgScriptPath, '/tl;dr/a,%20wiki' ); + $esc = $this->csp->getReportUri( ContentSecurityPolicy::FULL_MODE ); + $escExpected = '/tl%3Bdr/a%2C%20wiki/api.php?action=cspreport&format=json'; + $wgScriptPath = $origPath; + $this->assertEquals( $esc, $escExpected, 'test esc rules' ); + } + + /** + * @dataProvider providerPrepareUrlForCSP + * @covers ContentSecurityPolicy::prepareUrlForCSP + */ + public function testPrepareUrlForCSP( $url, $expected ) { + $actual = $this->csp->prepareUrlForCSP( $url ); + $this->assertEquals( $actual, $expected, $url ); + } + + public function providerPrepareUrlForCSP() { + global $wgServer; + return [ + [ $wgServer, false ], + [ 'https://example.com', 'https://example.com' ], + [ 'https://example.com:200', 'https://example.com:200' ], + [ 'http://example.com', 'http://example.com' ], + [ 'example.com', 'example.com' ], + [ '*.example.com', '*.example.com' ], + [ 'https://*.example.com', 'https://*.example.com' ], + [ '//example.com', 'example.com' ], + [ 'https://example.com/path', 'https://example.com' ], + [ 'https://example.com/path:', 'https://example.com' ], + [ 'https://example.com/Wikipedia:NPOV', 'https://example.com' ], + [ 'https://tl;dr.com', 'https://tl%3Bdr.com' ], + [ 'yes,no.com', 'yes%2Cno.com' ], + [ '/relative-url', false ], + [ '/relativeUrl:withColon', false ], + [ 'data:', 'data:' ], + [ 'blob:', 'blob:' ], + ]; + } + + /** + * @covers ContentSecurityPolicy::escapeUrlForCSP + */ + public function testEscapeUrlForCSP() { + $escaped = $this->csp->escapeUrlForCSP( ',;%2B' ); + $this->assertEquals( $escaped, '%2C%3B%2B' ); + } + + /** + * @dataProvider providerCSPIsEnabled + * @covers ContentSecurityPolicy::isEnabled + */ + public function testCSPIsEnabled( $main, $reportOnly, $expected ) { + global $wgCSPReportOnlyHeader, $wgCSPHeader; + global $wgCSPHeader; + $oldReport = wfSetVar( $wgCSPReportOnlyHeader, $reportOnly ); + $oldMain = wfSetVar( $wgCSPHeader, $main ); + $res = ContentSecurityPolicy::isEnabled( RequestContext::getMain()->getConfig() ); + wfSetVar( $wgCSPReportOnlyHeader, $oldReport ); + wfSetVar( $wgCSPHeader, $oldMain ); + $this->assertEquals( $res, $expected ); + } + + public function providerCSPIsEnabled() { + return [ + [ true, true, true ], + [ false, true, true ], + [ true, false, true ], + [ false, false, false ], + [ false, [], true ], + [ [], false, true ], + [ [ 'default-src' => [ 'foo.example.com' ] ], false, true ], + ]; + } +} diff --git a/tests/phpunit/includes/OutputPageTest.php b/tests/phpunit/includes/OutputPageTest.php index 91655eaeee..73447c95c8 100644 --- a/tests/phpunit/includes/OutputPageTest.php +++ b/tests/phpunit/includes/OutputPageTest.php @@ -321,7 +321,7 @@ class OutputPageTest extends MediaWikiTestCase { // Single only=scripts load [ [ 'test.foo', ResourceLoaderModule::TYPE_SCRIPTS ], - "" ], @@ -334,10 +334,36 @@ class OutputPageTest extends MediaWikiTestCase { // Private embed (only=scripts) [ [ 'test.quux', ResourceLoaderModule::TYPE_SCRIPTS ], - "" ], + // Load private module (combined) + [ + [ 'test.quux', ResourceLoaderModule::TYPE_COMBINED ], + "" + ], + // Load no modules + [ + [ [], ResourceLoaderModule::TYPE_COMBINED ], + '', + ], + // noscript group + [ + [ 'test.noscript', ResourceLoaderModule::TYPE_STYLES ], + '' + ], + // Load two modules in separate groups + [ + [ [ 'test.group.foo', 'test.group.bar' ], ResourceLoaderModule::TYPE_COMBINED ], + "" + ], ]; // phpcs:enable } @@ -352,6 +378,7 @@ class OutputPageTest extends MediaWikiTestCase { $this->setMwGlobals( [ 'wgResourceLoaderDebug' => false, 'wgLoadScript' => 'http://127.0.0.1:8080/w/load.php', + 'wgCSPReportOnlyHeader' => true, ] ); $class = new ReflectionClass( OutputPage::class ); $method = $class->getMethod( 'makeResourceLoaderLink' ); @@ -360,6 +387,9 @@ class OutputPageTest extends MediaWikiTestCase { $ctx->setSkin( SkinFactory::getDefaultInstance()->makeSkin( 'fallback' ) ); $ctx->setLanguage( 'en' ); $out = new OutputPage( $ctx ); + $nonce = $class->getProperty( 'CSPNonce' ); + $nonce->setAccessible( true ); + $nonce->setValue( $out, 'secret' ); $rl = $out->getResourceLoader(); $rl->setMessageBlobStore( new NullMessageBlobStore() ); $rl->register( [ @@ -380,6 +410,18 @@ class OutputPageTest extends MediaWikiTestCase { 'styles' => '/* pref-animate=off */ .mw-icon { transition: none; }', 'group' => 'private', ] ), + 'test.noscript' => new ResourceLoaderTestModule( [ + 'styles' => '.stuff { color: red; }', + 'group' => 'noscript', + ] ), + 'test.group.foo' => new ResourceLoaderTestModule( [ + 'script' => 'mw.doStuff( "foo" );', + 'group' => 'foo', + ] ), + 'test.group.bar' => new ResourceLoaderTestModule( [ + 'script' => 'mw.doStuff( "bar" );', + 'group' => 'bar', + ] ), ] ); $links = $method->invokeArgs( $out, $args ); $actualHtml = strval( $links ); diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php index ea3d199efe..e763a19466 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php @@ -218,7 +218,7 @@ class ResourceLoaderClientHtmlTest extends PHPUnit\Framework\TestCase { // phpcs:enable $expected = self::expandVariables( $expected ); - $this->assertEquals( $expected, $client->getHeadHtml() ); + $this->assertEquals( $expected, $client->getHeadHtml( false ) ); } /** @@ -237,7 +237,7 @@ class ResourceLoaderClientHtmlTest extends PHPUnit\Framework\TestCase { . ''; // phpcs:enable - $this->assertEquals( $expected, $client->getHeadHtml() ); + $this->assertEquals( $expected, $client->getHeadHtml( false ) ); } /** @@ -256,7 +256,7 @@ class ResourceLoaderClientHtmlTest extends PHPUnit\Framework\TestCase { . ''; // phpcs:enable - $this->assertEquals( $expected, $client->getHeadHtml() ); + $this->assertEquals( $expected, $client->getHeadHtml( false ) ); } /** @@ -408,7 +408,7 @@ class ResourceLoaderClientHtmlTest extends PHPUnit\Framework\TestCase { public function testMakeLoad( array $extraQuery, array $modules, $type, $expected ) { $context = self::makeContext( $extraQuery ); $context->getResourceLoader()->register( self::makeSampleModules() ); - $actual = ResourceLoaderClientHtml::makeLoad( $context, $modules, $type, $extraQuery ); + $actual = ResourceLoaderClientHtml::makeLoad( $context, $modules, $type, $extraQuery, false ); $expected = self::expandVariables( $expected ); $this->assertEquals( $expected, (string)$actual ); } -- 2.20.1