Only return CORS headers in the response as required
[lhc/web/wiklou.git] / includes / api / ApiMain.php
index 10a99c9..ba22e39 100644 (file)
@@ -54,6 +54,7 @@ class ApiMain extends ApiBase {
                'query' => 'ApiQuery',
                'expandtemplates' => 'ApiExpandTemplates',
                'parse' => 'ApiParse',
+               'stashedit' => 'ApiStashEdit',
                'opensearch' => 'ApiOpenSearch',
                'feedcontributions' => 'ApiFeedContributions',
                'feedrecentchanges' => 'ApiFeedRecentChanges',
@@ -191,7 +192,7 @@ class ApiMain extends ApiBase {
                if ( $uselang === 'user' ) {
                        $uselang = $this->getUser()->getOption( 'language' );
                        $uselang = RequestContext::sanitizeLangCode( $uselang );
-                       wfRunHooks( 'UserGetLanguageObject', array( $this->getUser(), &$uselang, $this ) );
+                       Hooks::run( 'UserGetLanguageObject', array( $this->getUser(), &$uselang, $this ) );
                } elseif ( $uselang === 'content' ) {
                        global $wgContLang;
                        $uselang = $wgContLang->getCode();
@@ -417,7 +418,7 @@ class ApiMain extends ApiBase {
                }
 
                // Allow extra cleanup and logging
-               wfRunHooks( 'ApiMain::onException', array( $this, $e ) );
+               Hooks::run( 'ApiMain::onException', array( $this, $e ) );
 
                // Log it
                if ( !( $e instanceof UsageException ) ) {
@@ -457,6 +458,7 @@ class ApiMain extends ApiBase {
         *
         * @since 1.23
         * @param Exception $e
+        * @throws Exception
         */
        public static function handleApiBeforeMainException( Exception $e ) {
                ob_start();
@@ -485,6 +487,8 @@ class ApiMain extends ApiBase {
         * If the parameter and the header do match, the header is checked against $wgCrossSiteAJAXdomains
         * and $wgCrossSiteAJAXdomainExceptions, and if the origin qualifies, the appropriate CORS
         * headers are set.
+        * http://www.w3.org/TR/cors/#resource-requests
+        * http://www.w3.org/TR/cors/#resource-preflight-requests
         *
         * @return bool False if the caller should abort (403 case), true otherwise (all other cases)
         */
@@ -497,12 +501,14 @@ 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 = array();
                } else {
-                       $origins = explode( ' ', $originHeader );
+                       $originHeader = trim( $originHeader );
+                       $origins = preg_split( '/\s+/', $originHeader );
                }
 
                if ( !in_array( $originParam, $origins ) ) {
@@ -517,18 +523,43 @@ class ApiMain extends ApiBase {
                }
 
                $config = $this->getConfig();
-               $matchOrigin = self::matchOrigin(
+               $matchOrigin = count( $origins ) === 1 && self::matchOrigin(
                        $originParam,
                        $config->get( 'CrossSiteAJAXdomains' ),
                        $config->get( 'CrossSiteAJAXdomainExceptions' )
                );
 
                if ( $matchOrigin ) {
-                       $response->header( "Access-Control-Allow-Origin: $originParam" );
+                       $requestedMethod = $request->getHeader( 'Access-Control-Request-Method' );
+                       $preflight = $request->getMethod() === 'OPTIONS' && $requestedMethod !== false;
+                       if ( $preflight ) {
+                               // This is a CORS preflight request
+                               if ( $requestedMethod !== 'POST' && $requestedMethod !== 'GET' ) {
+                                       // If method is not a case-sensitive match, do not set any additional headers and terminate.
+                                       return true;
+                               }
+                               // We allow the actual request to send the following headers
+                               $requestedHeaders = $request->getHeader( 'Access-Control-Request-Headers' );
+                               if ( $requestedHeaders !== false ) {
+                                       if ( !self::matchRequestedHeaders( $requestedHeaders ) ) {
+                                               return true;
+                                       }
+                                       $response->header( 'Access-Control-Allow-Headers: ' . $requestedHeaders );
+                               }
+
+                               // We only allow the actual request to be GET or POST
+                               $response->header( 'Access-Control-Allow-Methods: POST, GET' );
+                       }
+
+                       $response->header( "Access-Control-Allow-Origin: $originHeader" );
                        $response->header( 'Access-Control-Allow-Credentials: true' );
-                       $this->getOutput()->addVaryHeader( 'Origin' );
+
+                       if ( !$preflight ) {
+                               $response->header( 'Access-Control-Expose-Headers: MediaWiki-API-Error, Retry-After, X-Database-Lag' );
+                       }
                }
 
+               $this->getOutput()->addVaryHeader( 'Origin' );
                return true;
        }
 
@@ -557,6 +588,41 @@ class ApiMain extends ApiBase {
                return false;
        }
 
+       /**
+        * Attempt to validate the value of Access-Control-Request-Headers against a list
+        * of headers that we allow the follow up request to send.
+        *
+        * @param string $requestedHeaders Comma seperated list of HTTP headers
+        * @return bool True if all requested headers are in the list of allowed headers
+        */
+       protected static function matchRequestedHeaders( $requestedHeaders ) {
+               if ( trim( $requestedHeaders ) === '' ) {
+                       return true;
+               }
+               $requestedHeaders = explode( ',', $requestedHeaders );
+               $allowedAuthorHeaders = array_flip( array(
+                       /* simple headers (see spec) */
+                       'accept',
+                       'accept-language',
+                       'content-language',
+                       'content-type',
+                       /* non-authorable headers in XHR, which are however requested by some UAs */
+                       'accept-encoding',
+                       'dnt',
+                       'origin',
+                       /* MediaWiki whitelist */
+                       'api-user-agent',
+               ) );
+               foreach ( $requestedHeaders as $rHeader ) {
+                       $rHeader = strtolower( trim( $rHeader ) );
+                       if ( !isset( $allowedAuthorHeaders[$rHeader] ) ) {
+                               wfDebugLog( 'api', 'CORS preflight failed on requested header: ' . $rHeader );
+                               return false;
+                       }
+               }
+               return true;
+       }
+
        /**
         * Helper function to convert wildcard string into a regex
         * '*' => '.*?'
@@ -573,7 +639,7 @@ class ApiMain extends ApiBase {
                        $wildcard
                );
 
-               return "/https?:\/\/$wildcard/";
+               return "/^https?:\/\/$wildcard$/";
        }
 
        protected function sendCacheHeaders() {
@@ -776,6 +842,8 @@ class ApiMain extends ApiBase {
        /**
         * Set up the module for response
         * @return ApiBase The module that will handle this action
+        * @throws MWException
+        * @throws UsageException
         */
        protected function setupModule() {
                // Instantiate the module requested by the user
@@ -876,7 +944,7 @@ class ApiMain extends ApiBase {
 
                // Allow extensions to stop execution for arbitrary reasons.
                $message = false;
-               if ( !wfRunHooks( 'ApiCheckCanExecute', array( $module, $user, &$message ) ) ) {
+               if ( !Hooks::run( 'ApiCheckCanExecute', array( $module, $user, &$message ) ) ) {
                        $this->dieUsageMsg( $message );
                }
        }
@@ -950,7 +1018,7 @@ class ApiMain extends ApiBase {
                // Execute
                $module->profileIn();
                $module->execute();
-               wfRunHooks( 'APIAfterExecute', array( &$module ) );
+               Hooks::run( 'APIAfterExecute', array( &$module ) );
                $module->profileOut();
 
                $this->reportUnusedParams();
@@ -1242,6 +1310,21 @@ class ApiMain extends ApiBase {
                return $this->mModuleMgr;
        }
 
+       /**
+        * Fetches the user agent used for this request
+        *
+        * The value will be the combination of the 'Api-User-Agent' header (if
+        * any) and the standard User-Agent header (if any).
+        *
+        * @return string
+        */
+       public function getUserAgent() {
+               return trim(
+                       $this->getRequest()->getHeader( 'Api-user-agent' ) . ' ' .
+                       $this->getRequest()->getHeader( 'User-agent' )
+               );
+       }
+
        /************************************************************************//**
         * @name   Deprecated
         * @{