'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
'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',
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.
--- /dev/null
+<?php
+/**
+ * Handle sending Content-Security-Policy headers
+ *
+ * @see https://www.w3.org/TR/CSP2/
+ *
+ * Copyright © 2015–2018 Brian Wolff
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @since 1.32
+ * @file
+ */
+class ContentSecurityPolicy {
+ const REPORT_ONLY_MODE = 1;
+ const FULL_MODE = 2;
+ /** Used for meta tag. Does not include report urls or nonce sources */
+ const FULL_MODE_RESTRICTED = 3;
+
+ /** @var string The nonce to use for inline scripts (from OutputPage) */
+ private $nonce;
+ /** @var Config The site configuration object */
+ private $mwConfig;
+ /** @var WebResponse */
+ private $response;
+
+ /**
+ * @param string $nonce
+ * @param WebResponse $response
+ * @param Config $mwConfig
+ */
+ public function __construct( $nonce, WebResponse $response, Config $mwConfig ) {
+ $this->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 <meta> 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 <meta> 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;
+ }
+}
*/
$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.
*
$script .= '});';
+ $nonce = $wgOut->getCSPNonce();
+ $wgOut->addScript( ResourceLoader::makeInlineScript( $script, $nonce ) );
+
$toolbar = '<div id="toolbar"></div>';
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;
* 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'] );
if ( $wgShowHostnames ) {
$reportVars['wgHostname'] = wfHostname();
}
- return Skin::makeVariablesScript( $reportVars );
+ return Skin::makeVariablesScript( $reportVars, $nonce );
}
/**
* literal "</script>" 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 = "/*<![CDATA[*/$contents/*]]>*/";
* "<script src=foo.js></script>".
*
* @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 );
}
*/
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
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() ) );
}
/**
* @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";
}
/**
$response->header( "X-Frame-Options: $frameOptions" );
}
+ ContentSecurityPolicy::sendHeaders( $this );
+
if ( $this->mArticleBodyOnly ) {
echo $this->mBodytext;
} else {
}
$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 ) );
ResourceLoaderContext::newDummyContext(),
[ 'html5shiv' ],
ResourceLoaderModule::TYPE_SCRIPTS,
- [ 'sync' => true ]
+ [ 'sync' => true ],
+ $this->getCSPNonce()
) .
'<![endif]-->';
$this->getRlClientContext(),
$modules,
$only,
- $extraQuery
+ $extraQuery,
+ $this->getCSPNonce()
);
}
$chunks[] = ResourceLoader::makeInlineScript(
ResourceLoader::makeConfigSetScript(
[ 'wgPageParseReport' => $this->limitReportJSData ]
- )
+ ),
+ $this->getCSPNonce()
);
}
);
}
}
+
+ /**
+ * Get (and set if not yet set) the CSP nonce.
+ *
+ * This value needs to be included in any <script> tags on the
+ * page.
+ *
+ * @return string|bool Nonce or false to mean don't output nonce
+ * @since 1.32
+ */
+ public function getCSPNonce() {
+ if ( !ContentSecurityPolicy::isEnabled( $this->getConfig() ) ) {
+ return false;
+ }
+ if ( $this->CSPNonce === null ) {
+ // XXX It might be expensive to generate randomness
+ // on every request, on windows.
+ $rand = MWCryptRand::generate( 15 );
+ $this->CSPNonce = base64_encode( $rand );
+ }
+ return $this->CSPNonce;
+ }
}
}
if (
- ( isset( $report['blocked-uri'] ) &&
- isset( $falsePositives[$report['blocked-uri']] ) )
- || ( isset( $report['source-file'] ) &&
- isset( $falsePositives[$report['source-file']] ) )
+ (
+ ContentSecurityPolicy::falsePositiveBrowser( $userAgent ) &&
+ $report['blocked-uri'] === "self"
+ ) ||
+ (
+ isset( $report['blocked-uri'] ) &&
+ isset( $falsePositives[$report['blocked-uri']] )
+ ) ||
+ (
+ isset( $report['source-file'] ) &&
+ isset( $falsePositives[$report['source-file']] )
+ )
) {
- // Report caused by Ad-Ware
+ // False positive due to:
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1026520
+
$flags[] = 'false-positive';
}
return $flags;
// Cannot use OutputPage::addJsConfigVars because those are already outputted
// by the time this method is called.
$html = ResourceLoader::makeInlineScript(
- ResourceLoader::makeConfigSetScript( [ 'debugInfo' => $debugInfo ] )
+ ResourceLoader::makeConfigSetScript( [ 'debugInfo' => $debugInfo ] ),
+ $context->getOutput()->getCSPNonce()
);
}
* startup module if the client has adequate support for MediaWiki JavaScript code.
*
* @param string $script JavaScript code
+ * @param string $nonce Content-security-policy nonce, from OutputPage::getCSPNonce()
* @return WrappedString HTML
*/
- public static function makeInlineScript( $script ) {
+ public static function makeInlineScript( $script, $nonce = null ) {
$js = self::makeLoaderConditionalScript( $script );
+ $escNonce = '';
+ if ( $nonce === null ) {
+ wfWarn( __METHOD__ . " did not get nonce. Will break CSP" );
+ } elseif ( $nonce !== false ) {
+ // If it was false, CSP is disabled, so no nonce attribute.
+ // Nonce should be only base64 characters, so should be safe,
+ // but better to be safely escaped than sorry.
+ $escNonce = ' nonce="' . htmlspecialchars( $nonce ) . '"';
+ }
+
return new WrappedString(
- Html::inlineScript( $js ),
- '<script>(window.RLQ=window.RLQ||[]).push(function(){',
+ Html::inlineScript( $js, $nonce ),
+ "<script$escNonce>(window.RLQ=window.RLQ||[]).push(function(){",
'});</script>'
);
}
* - 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 = [];
// 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
);
}
$states = array_merge( $this->exemptStates, $data['states'] );
if ( $states ) {
$chunks[] = ResourceLoader::makeInlineScript(
- ResourceLoader::makeLoaderStateScript( $states )
+ ResourceLoader::makeLoaderStateScript( $states ),
+ $nonce
);
}
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
);
}
if ( $data['scripts'] ) {
$chunks[] = $this->getLoad(
$data['scripts'],
- ResourceLoaderModule::TYPE_SCRIPTS
+ ResourceLoaderModule::TYPE_SCRIPTS,
+ $nonce
);
}
if ( $data['styles'] ) {
$chunks[] = $this->getLoad(
$data['styles'],
- ResourceLoaderModule::TYPE_STYLES
+ ResourceLoaderModule::TYPE_STYLES,
+ $nonce
);
}
if ( $data['embed']['styles'] ) {
$chunks[] = $this->getLoad(
$data['embed']['styles'],
- ResourceLoaderModule::TYPE_STYLES
+ ResourceLoaderModule::TYPE_STYLES,
+ $nonce
);
}
$chunks[] = $this->getLoad(
'startup',
ResourceLoaderModule::TYPE_SCRIPTS,
+ $nonce,
$startupQuery
);
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,
* @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 = [];
$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 );
}
);
} else {
$chunks[] = ResourceLoader::makeInlineScript(
- $rl->makeModuleResponse( $context, $moduleSet )
+ $rl->makeModuleResponse( $context, $moduleSet ),
+ $nonce
);
}
} else {
] );
} else {
$chunk = ResourceLoader::makeInlineScript(
- Xml::encodeJsCall( 'mw.loader.load', [ $url ] )
+ Xml::encodeJsCall( 'mw.loader.load', [ $url ] ),
+ $nonce
);
}
}
/**
* @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 '';
$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;
$out->addHTML(
ResourceLoader::makeInlineScript(
- ResourceLoader::makeMessageSetScript( $messages )
+ ResourceLoader::makeMessageSetScript( $messages ),
+ $out->getCSPNonce()
)
);
--- /dev/null
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+class ContentSecurityPolicyTest extends MediaWikiTestCase {
+ /** @var ContentSecurityPolicy */
+ private $csp;
+
+ protected function setUp() {
+ global $wgUploadDirectory;
+ $this->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 ],
+ ];
+ }
+}
// Single only=scripts load
[
[ 'test.foo', ResourceLoaderModule::TYPE_SCRIPTS ],
- "<script>(window.RLQ=window.RLQ||[]).push(function(){"
+ "<script nonce=\"secret\">(window.RLQ=window.RLQ||[]).push(function(){"
. 'mw.loader.load("http://127.0.0.1:8080/w/load.php?debug=false\u0026lang=en\u0026modules=test.foo\u0026only=scripts\u0026skin=fallback");'
. "});</script>"
],
// Private embed (only=scripts)
[
[ 'test.quux', ResourceLoaderModule::TYPE_SCRIPTS ],
- "<script>(window.RLQ=window.RLQ||[]).push(function(){"
+ "<script nonce=\"secret\">(window.RLQ=window.RLQ||[]).push(function(){"
. "mw.test.baz({token:123});\nmw.loader.state({\"test.quux\":\"ready\"});"
. "});</script>"
],
+ // Load private module (combined)
+ [
+ [ 'test.quux', ResourceLoaderModule::TYPE_COMBINED ],
+ "<script nonce=\"secret\">(window.RLQ=window.RLQ||[]).push(function(){"
+ . "mw.loader.implement(\"test.quux@1ev0ijv\",function($,jQuery,require,module){"
+ . "mw.test.baz({token:123});},{\"css\":[\".mw-icon{transition:none}"
+ . "\"]});});</script>"
+ ],
+ // Load no modules
+ [
+ [ [], ResourceLoaderModule::TYPE_COMBINED ],
+ '',
+ ],
+ // noscript group
+ [
+ [ 'test.noscript', ResourceLoaderModule::TYPE_STYLES ],
+ '<noscript><link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?debug=false&lang=en&modules=test.noscript&only=styles&skin=fallback"/></noscript>'
+ ],
+ // Load two modules in separate groups
+ [
+ [ [ 'test.group.foo', 'test.group.bar' ], ResourceLoaderModule::TYPE_COMBINED ],
+ "<script nonce=\"secret\">(window.RLQ=window.RLQ||[]).push(function(){"
+ . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?debug=false\u0026lang=en\u0026modules=test.group.bar\u0026skin=fallback");'
+ . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?debug=false\u0026lang=en\u0026modules=test.group.foo\u0026skin=fallback");'
+ . "});</script>"
+ ],
];
// phpcs:enable
}
$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' );
$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( [
'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 );
// phpcs:enable
$expected = self::expandVariables( $expected );
- $this->assertEquals( $expected, $client->getHeadHtml() );
+ $this->assertEquals( $expected, $client->getHeadHtml( false ) );
}
/**
. '<script async="" src="/w/load.php?debug=false&lang=nl&modules=startup&only=scripts&skin=fallback&target=example"></script>';
// phpcs:enable
- $this->assertEquals( $expected, $client->getHeadHtml() );
+ $this->assertEquals( $expected, $client->getHeadHtml( false ) );
}
/**
. '<script async="" src="/w/load.php?debug=false&lang=nl&modules=startup&only=scripts&skin=fallback"></script>';
// phpcs:enable
- $this->assertEquals( $expected, $client->getHeadHtml() );
+ $this->assertEquals( $expected, $client->getHeadHtml( false ) );
}
/**
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 );
}