wikimedia/mediawiki-core

View on GitHub
includes/Request/ContentSecurityPolicy.php

Summary

Maintainability
D
2 days
Test Coverage
<?php
/**
 * 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
 *
 * @file
 */

namespace MediaWiki\Request;

use MediaWiki\Config\Config;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use UnexpectedValueException;

/**
 * Handle sending Content-Security-Policy headers
 *
 * @author Copyright 2015–2018 Brian Wolff
 * @see https://www.w3.org/TR/CSP2/
 * @since 1.32
 */
class ContentSecurityPolicy {
    public const REPORT_ONLY_MODE = 1;
    public const FULL_MODE = 2;

    /** @var Config The site configuration object */
    private $mwConfig;
    /** @var WebResponse */
    private $response;

    /** @var string[] */
    private $extraDefaultSrc = [];
    /** @var string[] */
    private $extraScriptSrc = [];
    /** @var string[] */
    private $extraStyleSrc = [];

    /** @var HookRunner */
    private $hookRunner;

    /**
     * @note As a general rule, you would not construct this class directly
     *  but use the instance from OutputPage::getCSP()
     * @internal
     * @param WebResponse $response
     * @param Config $mwConfig
     * @param HookContainer $hookContainer
     * @since 1.35 Method signature changed
     */
    public function __construct(
        WebResponse $response,
        Config $mwConfig,
        HookContainer $hookContainer
    ) {
        $this->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 <meta> 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' );