Merge "Improve comments on fields and fix opening_text - needs no highlights."
[lhc/web/wiklou.git] / includes / api / ApiMain.php
index 93a5b60..592df53 100644 (file)
@@ -71,6 +71,7 @@ class ApiMain extends ApiBase {
                'compare' => 'ApiComparePages',
                'tokens' => 'ApiTokens',
                'checktoken' => 'ApiCheckToken',
+               'cspreport' => 'ApiCSPReport',
 
                // Write modules
                'purge' => 'ApiPurge',
@@ -137,7 +138,9 @@ class ApiMain extends ApiBase {
         */
        private $mPrinter;
 
-       private $mModuleMgr, $mResult, $mErrorFormatter, $mContinuationManager;
+       private $mModuleMgr, $mResult, $mErrorFormatter;
+       /** @var ApiContinuationManager|null */
+       private $mContinuationManager;
        private $mAction;
        private $mEnableWrite;
        private $mInternalMode, $mSquidMaxage;
@@ -148,6 +151,9 @@ class ApiMain extends ApiBase {
        private $mCacheControl = [];
        private $mParamsUsed = [];
 
+       /** @var bool|null Cached return value from self::lacksSameOriginSecurity() */
+       private $lacksSameOriginSecurity = null;
+
        /**
         * Constructs an instance of ApiMain that utilizes the module and format specified by $request.
         *
@@ -168,22 +174,53 @@ class ApiMain extends ApiBase {
 
                if ( isset( $request ) ) {
                        $this->getContext()->setRequest( $request );
+               } else {
+                       $request = $this->getRequest();
                }
 
-               $this->mInternalMode = ( $this->getRequest() instanceof FauxRequest );
+               $this->mInternalMode = ( $request instanceof FauxRequest );
 
                // Special handling for the main module: $parent === $this
                parent::__construct( $this, $this->mInternalMode ? 'main_int' : 'main' );
 
+               $config = $this->getConfig();
+
                if ( !$this->mInternalMode ) {
-                       // Impose module restrictions.
-                       // If the current user cannot read,
-                       // Remove all modules other than login
-                       global $wgUser;
+                       // Log if a request with a non-whitelisted Origin header is seen
+                       // with session cookies.
+                       $originHeader = $request->getHeader( 'Origin' );
+                       if ( $originHeader === false ) {
+                               $origins = [];
+                       } else {
+                               $originHeader = trim( $originHeader );
+                               $origins = preg_split( '/\s+/', $originHeader );
+                       }
+                       $sessionCookies = array_intersect(
+                               array_keys( $_COOKIE ),
+                               MediaWiki\Session\SessionManager::singleton()->getVaryCookies()
+                       );
+                       if ( $origins && $sessionCookies && (
+                               count( $origins ) !== 1 || !self::matchOrigin(
+                                       $origins[0],
+                                       $config->get( 'CrossSiteAJAXdomains' ),
+                                       $config->get( 'CrossSiteAJAXdomainExceptions' )
+                               )
+                       ) ) {
+                               MediaWiki\Logger\LoggerFactory::getInstance( 'cors' )->warning(
+                                       'Non-whitelisted CORS request with session cookies', [
+                                               'origin' => $originHeader,
+                                               'cookies' => $sessionCookies,
+                                               'ip' => $request->getIP(),
+                                               'userAgent' => $this->getUserAgent(),
+                                               'wiki' => wfWikiID(),
+                                       ]
+                               );
+                       }
 
+                       // If we're in a mode that breaks the same-origin policy, strip
+                       // user credentials for security.
                        if ( $this->lacksSameOriginSecurity() ) {
-                               // If we're in a mode that breaks the same-origin policy, strip
-                               // user credentials for security.
+                               global $wgUser;
                                wfDebug( "API: stripping user credentials when the same-origin policy is not applied\n" );
                                $wgUser = new User();
                                $this->getContext()->setUser( $wgUser );
@@ -208,7 +245,6 @@ class ApiMain extends ApiBase {
                        }
                }
 
-               $config = $this->getConfig();
                $this->mModuleMgr = new ApiModuleManager( $this );
                $this->mModuleMgr->addModules( self::$Modules, 'action' );
                $this->mModuleMgr->addModules( $config->get( 'APIModules' ), 'action' );
@@ -245,6 +281,41 @@ class ApiMain extends ApiBase {
                return $this->mResult;
        }
 
+       /**
+        * Get the security flag for the current request
+        * @return bool
+        */
+       public function lacksSameOriginSecurity() {
+               if ( $this->lacksSameOriginSecurity !== null ) {
+                       return $this->lacksSameOriginSecurity;
+               }
+
+               $request = $this->getRequest();
+
+               // JSONP mode
+               if ( $request->getVal( 'callback' ) !== null ) {
+                       $this->lacksSameOriginSecurity = true;
+                       return true;
+               }
+
+               // Anonymous CORS
+               if ( $request->getVal( 'origin' ) === '*' ) {
+                       $this->lacksSameOriginSecurity = true;
+                       return true;
+               }
+
+               // Header to be used from XMLHTTPRequest when the request might
+               // otherwise be used for XSS.
+               if ( $request->getHeader( 'Treat-as-Untrusted' ) !== false ) {
+                       $this->lacksSameOriginSecurity = true;
+                       return true;
+               }
+
+               // Allow extensions to override.
+               $this->lacksSameOriginSecurity = !Hooks::run( 'RequestHasSameOriginSecurity', [ $request ] );
+               return $this->lacksSameOriginSecurity;
+       }
+
        /**
         * Get the ApiErrorFormatter object associated with current request
         * @return ApiErrorFormatter
@@ -439,7 +510,8 @@ class ApiMain extends ApiBase {
                        $this->logRequest( $runTime );
                        if ( $this->mModule->isWriteMode() && $this->getRequest()->wasPosted() ) {
                                $this->getStats()->timing(
-                                       'api.' . $this->getModuleName() . '.executeTiming', 1000 * $runTime );
+                                       'api.' . $this->mModule->getModuleName() . '.executeTiming', 1000 * $runTime
+                               );
                        }
                } catch ( Exception $e ) {
                        $this->handleException( $e );
@@ -578,31 +650,49 @@ class ApiMain extends ApiBase {
                $request = $this->getRequest();
                $response = $request->response();
 
-               // Origin: header is a space-separated list of origins, check all of them
-               $originHeader = $request->getHeader( 'Origin' );
-               if ( $originHeader === false ) {
-                       $origins = [];
+               $matchOrigin = false;
+               $allowTiming = false;
+               $varyOrigin = true;
+
+               if ( $originParam === '*' ) {
+                       // Request for anonymous CORS
+                       $matchOrigin = true;
+                       $allowOrigin = '*';
+                       $allowCredentials = 'false';
+                       $varyOrigin = false; // No need to vary
                } else {
-                       $originHeader = trim( $originHeader );
-                       $origins = preg_split( '/\s+/', $originHeader );
-               }
+                       // Non-anonymous CORS, check we allow the domain
 
-               if ( !in_array( $originParam, $origins ) ) {
-                       // origin parameter set but incorrect
-                       // Send a 403 response
-                       $response->statusHeader( 403 );
-                       $response->header( 'Cache-Control: no-cache' );
-                       echo "'origin' parameter does not match Origin header\n";
+                       // Origin: header is a space-separated list of origins, check all of them
+                       $originHeader = $request->getHeader( 'Origin' );
+                       if ( $originHeader === false ) {
+                               $origins = [];
+                       } else {
+                               $originHeader = trim( $originHeader );
+                               $origins = preg_split( '/\s+/', $originHeader );
+                       }
 
-                       return false;
-               }
+                       if ( !in_array( $originParam, $origins ) ) {
+                               // origin parameter set but incorrect
+                               // Send a 403 response
+                               $response->statusHeader( 403 );
+                               $response->header( 'Cache-Control: no-cache' );
+                               echo "'origin' parameter does not match Origin header\n";
 
-               $config = $this->getConfig();
-               $matchOrigin = count( $origins ) === 1 && self::matchOrigin(
-                       $originParam,
-                       $config->get( 'CrossSiteAJAXdomains' ),
-                       $config->get( 'CrossSiteAJAXdomainExceptions' )
-               );
+                               return false;
+                       }
+
+                       $config = $this->getConfig();
+                       $matchOrigin = count( $origins ) === 1 && self::matchOrigin(
+                               $originParam,
+                               $config->get( 'CrossSiteAJAXdomains' ),
+                               $config->get( 'CrossSiteAJAXdomainExceptions' )
+                       );
+
+                       $allowOrigin = $originHeader;
+                       $allowCredentials = 'true';
+                       $allowTiming = $originHeader;
+               }
 
                if ( $matchOrigin ) {
                        $requestedMethod = $request->getHeader( 'Access-Control-Request-Method' );
@@ -626,10 +716,12 @@ class ApiMain extends ApiBase {
                                $response->header( 'Access-Control-Allow-Methods: POST, GET' );
                        }
 
-                       $response->header( "Access-Control-Allow-Origin: $originHeader" );
-                       $response->header( 'Access-Control-Allow-Credentials: true' );
+                       $response->header( "Access-Control-Allow-Origin: $allowOrigin" );
+                       $response->header( "Access-Control-Allow-Credentials: $allowCredentials" );
                        // http://www.w3.org/TR/resource-timing/#timing-allow-origin
-                       $response->header( "Timing-Allow-Origin: $originHeader" );
+                       if ( $allowTiming !== false ) {
+                               $response->header( "Timing-Allow-Origin: $allowTiming" );
+                       }
 
                        if ( !$preflight ) {
                                $response->header(
@@ -638,7 +730,10 @@ class ApiMain extends ApiBase {
                        }
                }
 
-               $this->getOutput()->addVaryHeader( 'Origin' );
+               if ( $varyOrigin ) {
+                       $this->getOutput()->addVaryHeader( 'Origin' );
+               }
+
                return true;
        }
 
@@ -730,6 +825,8 @@ class ApiMain extends ApiBase {
                $response = $this->getRequest()->response();
                $out = $this->getOutput();
 
+               $out->addVaryHeader( 'Treat-as-Untrusted' );
+
                $config = $this->getConfig();
 
                if ( $config->get( 'VaryOnXFP' ) ) {
@@ -1866,6 +1963,14 @@ class UsageException extends MWException {
                parent::__construct( $message, $code );
                $this->mCodestr = $codestr;
                $this->mExtraData = $extradata;
+
+               // This should never happen, so throw an exception about it that will
+               // hopefully get logged with a backtrace (T138585)
+               if ( !is_string( $codestr ) || $codestr === '' ) {
+                       throw new InvalidArgumentException( 'Invalid $codestr, was ' .
+                               ( $codestr === '' ? 'empty string' : gettype( $codestr ) )
+                       );
+               }
        }
 
        /**