diff --git a/RELEASE-NOTES-1.32 b/RELEASE-NOTES-1.32 index 9fd3161f1e6..8e4491008ac 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 ec0d59f4791..6aa832c0b21 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 b38bd666e4d..9404e1448df 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 00000000000..21d7d57dcde --- /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 dcf648e79ce..72375fbfafd 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 4f6b7b4bbba..6d39e3a03de 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 9569bc1fb4d..7e8df7ee48b 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 3bcf13132f1..019e0785f9d 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 fbc7b604390..52dfc1164e7 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 bb8ab329989..d0a9c4248a3 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 340bc2f5bdd..5dfa7e36c32 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 1d5d534aced..507688dfcc1 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 ac13f113b21..9e61ef738ba 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 00000000000..f0fa6118b3d --- /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 91655eaeeec..73447c95c83 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 ea3d199efe9..e763a194662 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 ); }