response = $response; $this->mwConfig = $mwConfig; $this->hookRunner = new HookRunner( $hookContainer ); } /** * Get the CSP directives for the wiki. * @return string[] Array of CSP directives (header name => header value). The array keys will be * ContentSecurityPolicy::FULL_MODE and ContentSecurityPolicy::REPORT_ONLY_MODE; they might not * be present if the wiki is configured no to use the given type of CSP. * @phan-return array{Content-Security-Policy?:string,Content-Security-Policy-Report-Only?:string} * @since 1.42 */ public function getDirectives() { $cspConfig = $this->mwConfig->get( MainConfigNames::CSPHeader ); $cspConfigReportOnly = $this->mwConfig->get( MainConfigNames::CSPReportOnlyHeader ); $cspPolicy = $this->makeCSPDirectives( $cspConfig, self::FULL_MODE ); $cspReportOnlyPolicy = $this->makeCSPDirectives( $cspConfigReportOnly, self::REPORT_ONLY_MODE ); return array_filter( [ $this->getHeaderName( self::FULL_MODE ) => $cspPolicy, $this->getHeaderName( self::REPORT_ONLY_MODE ) => $cspReportOnlyPolicy, ] ); } /** * Send CSP headers based on wiki config * * Main method that callers (OutputPage) are expected to use. * As a general rule, you would never call this in an extension unless * you have disabled OutputPage and are fully controlling the output. * * @since 1.35 */ public function sendHeaders() { $directives = $this->getDirectives(); foreach ( $directives as $headerName => $policy ) { $this->response->header( "$headerName: $policy" ); } // This used to insert a tag here, per advice at // https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/ // The goal was to prevent nonce from working after the page hit onready, // This would help in old browsers that didn't support nonces, and // also assist for Varnish-cached pages which repeat nonces. // However, this is incompatible with how ResourceLoader runs code // from mw.loader.store, so it was removed. } /** * @param int $reportOnly Either self::REPORT_ONLY_MODE or self::FULL_MODE * @return string Name of http header * @throws UnexpectedValueException */ private function getHeaderName( $reportOnly ) { if ( $reportOnly === self::REPORT_ONLY_MODE ) { return 'Content-Security-Policy-Report-Only'; } if ( $reportOnly === self::FULL_MODE ) { return 'Content-Security-Policy'; } throw new UnexpectedValueException( "Mode '$reportOnly' not recognised" ); } /** * Determine what CSP policies to set for this page * * @param array|bool $policyConfig Policy configuration * (Either $wgCSPHeader or $wgCSPReportOnlyHeader) * @param int $mode self::REPORT_ONLY_MODE, self::FULL_MODE * @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; if ( self::isNonceRequired( $mwConfig ) || self::isNonceRequiredArray( [ $policyConfig ] ) ) { wfDeprecated( 'wgCSPHeader "useNonces" option', '1.41' ); } $additionalSelfUrls = $this->getAdditionalSelfUrls(); $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript(); // 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:' ]; $imgSrc = false; $scriptSrc = [ "'unsafe-eval'", "blob:", "'self'" ]; $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 ( $policyConfig['unsafeFallback'] ?? true ) { // 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 ( $policyConfig['includeCORS'] ?? true ) { $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 ); } } $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc ); $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc ); $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [ "'unsafe-inline'" ] ); $this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode ); $this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode ); if ( 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 restrictive default. if ( !is_array( $defaultSrc ) || !in_array( '*', $defaultSrc ) || !in_array( 'data:', $defaultSrc ) || !in_array( 'blob:', $defaultSrc ) ) { // A future todo might be to make the allow options only // add all the allowed 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( MainConfigNames::AllowExternalImages ) || $mwConfig->get( MainConfigNames::AllowExternalImagesFrom ) ) { $imgSrc = [ '*', 'data:', 'blob:' ]; } elseif ( $mwConfig->get( MainConfigNames::EnableImageWhitelist ) ) { $whitelist = wfMessage( 'external_image_whitelist' ) ->inContentLanguage() ->plain(); if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) { $imgSrc = [ '*', 'data:', 'blob:' ]; } } } // Default value 'none'. true is none, false is nothing, string is single directive, // array is list. if ( !isset( $policyConfig['object-src'] ) || $policyConfig['object-src'] === true ) { $objectSrc = [ "'none'" ]; } else { $objectSrc = (array)( $policyConfig['object-src'] ?: [] ); } $objectSrc = array_map( [ $this, 'escapeUrlForCSP' ], $objectSrc ); $directives = []; if ( $scriptSrc ) { $directives[] = 'script-src ' . implode( ' ', array_unique( $scriptSrc ) ); } if ( $defaultSrc ) { $directives[] = 'default-src ' . implode( ' ', array_unique( $defaultSrc ) ); } if ( $cssSrc ) { $directives[] = 'style-src ' . implode( ' ', array_unique( $cssSrc ) ); } if ( $imgSrc ) { $directives[] = 'img-src ' . implode( ' ', array_unique( $imgSrc ) ); } if ( $objectSrc ) { $directives[] = 'object-src ' . implode( ' ', $objectSrc ); } if ( $reportUri ) { $directives[] = 'report-uri ' . $reportUri; } $this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode ); return implode( '; ', $directives ); } /** * Get the default report uri. * * @param int $mode self::*_MODE constant. * @return string The URI to send reports to. * @throws UnexpectedValueException if given invalid mode. */ private function getReportUri( $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 = wfGetUrlUtils()->parse( $url ); if ( !$bits && strpos( $url, '/' ) === false ) { // probably something like example.com. // try again protocol-relative. $url = '//' . $url; $bits = wfGetUrlUtils()->parse( $url ); } if ( $bits && isset( $bits['host'] ) && $bits['host'] !== $this->mwConfig->get( MainConfigNames::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; } /** * @return string[] Additional sources for loading scripts from */ private function getAdditionalSelfUrlsScript() { $additionalUrls = []; // wgExtensionAssetsPath for ?debug=true mode $pathVars = [ MainConfigNames::LoadScript, MainConfigNames::ExtensionAssetsPath, MainConfigNames::ResourceBasePath, ]; foreach ( $pathVars as $path ) { $url = $this->mwConfig->get( $path ); $preparedUrl = $this->prepareUrlForCSP( $url ); if ( $preparedUrl ) { $additionalUrls[] = $preparedUrl; } } $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources ); foreach ( $RLSources as $sources ) { foreach ( $sources as $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 string[] 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 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 = static function ( $repo, &$urls ) { $urls[] = $repo->getZoneUrl( 'public' ); $urls[] = $repo->getZoneUrl( 'transcoded' ); $urls[] = $repo->getZoneUrl( 'thumb' ); $urls[] = $repo->getDescriptionStylesheetUrl(); }; $repoGroup = MediaWikiServices::getInstance()->getRepoGroup(); $localRepo = $repoGroup->getRepo( 'local' ); $callback( $localRepo, $pathUrls ); $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] ); // Globals that might point to a different domain $pathGlobals = [ MainConfigNames::LoadScript, MainConfigNames::ExtensionAssetsPath, MainConfigNames::StylePath, MainConfigNames::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( MainConfigNames::ResourceLoaderSources ); foreach ( $RLSources as $sources ) { foreach ( $sources as $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( MainConfigNames::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 ); } /** * Should we set nonce attribute * * @param Config $config * @return bool */ public static function isNonceRequired( Config $config ) { $configs = [ $config->get( MainConfigNames::CSPHeader ), $config->get( MainConfigNames::CSPReportOnlyHeader ), ]; return self::isNonceRequiredArray( $configs ); } /** * Does a specific config require a nonce * * @param array $configs An array of CSP config arrays * @return bool */ private static function isNonceRequiredArray( array $configs ) { foreach ( $configs as $headerConfig ) { if ( is_array( $headerConfig ) && isset( $headerConfig['useNonces'] ) && $headerConfig['useNonces'] ) { return true; } } return false; } /** * Get the nonce if nonce is in use * * Not currently supported or implemented. * * @since 1.35 * @return false */ public function getNonce() { return false; } /** * If possible you should use a more specific source type then default. * * So for example, if an extension added a special page that loaded something * it might call $this->getOutput()->getCSP()->addDefaultSrc( '*.example.com' ); * * @since 1.35 * @param string $source Source to add. * e.g. blob:, *.example.com, %https://example.com, example.com/foo */ public function addDefaultSrc( $source ) { $this->extraDefaultSrc[] = $this->prepareUrlForCSP( $source ); } /** * So for example, if an extension added a special page that loaded external CSS * it might call $this->getOutput()->getCSP()->addStyleSrc( '*.example.com' ); * * @since 1.35 * @param string $source Source to add. * e.g. blob:, *.example.com, %https://example.com, example.com/foo */ public function addStyleSrc( $source ) { $this->extraStyleSrc[] = $this->prepareUrlForCSP( $source ); } /** * So for example, if an extension added a special page that loaded something * it might call $this->getOutput()->getCSP()->addScriptSrc( '*.example.com' ); * * @since 1.35 * @warning Be careful including external scripts, as they can take over accounts. * @param string $source Source to add. * e.g. blob:, *.example.com, %https://example.com, example.com/foo */ public function addScriptSrc( $source ) { $this->extraScriptSrc[] = $this->prepareUrlForCSP( $source ); } } /** @deprecated class alias since 1.40 */ class_alias( ContentSecurityPolicy::class, 'ContentSecurityPolicy' );