Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
CorsUtils
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
6 / 6
18
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 authorize
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 allowOrigin
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getCanonicalDomain
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 modifyResponse
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
8
 createPreflightResponse
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Rest;
4
5use MediaWiki\Config\ServiceOptions;
6use MediaWiki\MainConfigNames;
7use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface;
8use MediaWiki\Rest\HeaderParser\Origin;
9use MediaWiki\User\UserIdentity;
10
11/**
12 * @internal
13 */
14class CorsUtils implements BasicAuthorizerInterface {
15
16    public const CONSTRUCTOR_OPTIONS = [
17        MainConfigNames::AllowedCorsHeaders,
18        MainConfigNames::AllowCrossOrigin,
19        MainConfigNames::RestAllowCrossOriginCookieAuth,
20        MainConfigNames::CanonicalServer,
21        MainConfigNames::CrossSiteAJAXdomains,
22        MainConfigNames::CrossSiteAJAXdomainExceptions,
23    ];
24
25    private ServiceOptions $options;
26    private ResponseFactory $responseFactory;
27    private UserIdentity $user;
28
29    public function __construct(
30        ServiceOptions $options,
31        ResponseFactory $responseFactory,
32        UserIdentity $user
33    ) {
34        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
35        $this->options = $options;
36        $this->responseFactory = $responseFactory;
37        $this->user = $user;
38    }
39
40    /**
41     * Only allow registered users to make unsafe cross-origin requests.
42     *
43     * @param RequestInterface $request
44     * @param Handler $handler
45     * @return string|null If the request is denied, the string error code. If
46     *   the request is allowed, null.
47     */
48    public function authorize( RequestInterface $request, Handler $handler ) {
49        // Handlers that need write access are by definition a cache-miss, therefore there is no
50        // need to vary by the origin.
51        if (
52            $handler->needsWriteAccess()
53            && $request->hasHeader( 'Origin' )
54            && !$this->user->isRegistered()
55        ) {
56            $origin = Origin::parseHeaderList( $request->getHeader( 'Origin' ) );
57
58            if ( !$this->allowOrigin( $origin ) ) {
59                return 'rest-cross-origin-anon-write';
60            }
61        }
62
63        return null;
64    }
65
66    private function allowOrigin( Origin $origin ): bool {
67        $allowed = array_merge( [ $this->getCanonicalDomain() ],
68            $this->options->get( MainConfigNames::CrossSiteAJAXdomains ) );
69        $excluded = $this->options->get( MainConfigNames::CrossSiteAJAXdomainExceptions );
70
71        return $origin->match( $allowed, $excluded );
72    }
73
74    private function getCanonicalDomain(): string {
75        $res = parse_url( $this->options->get( MainConfigNames::CanonicalServer ) );
76        '@phan-var array $res';
77
78        $host = $res['host'] ?? '';
79        $port = $res['port'] ?? null;
80
81        return $port ? "$host:$port" : $host;
82    }
83
84    /**
85     * Modify response to allow for CORS.
86     *
87     * This method should be executed for every response from the REST API
88     * including errors.
89     *
90     * @param RequestInterface $request
91     * @param ResponseInterface $response
92     * @return ResponseInterface
93     */
94    public function modifyResponse( RequestInterface $request, ResponseInterface $response ): ResponseInterface {
95        if ( !$this->options->get( MainConfigNames::AllowCrossOrigin ) ) {
96            return $response;
97        }
98
99        $allowedOrigin = '*';
100
101        if ( $this->options->get( MainConfigNames::RestAllowCrossOriginCookieAuth ) ) {
102            // @TODO Since we only Vary the response if (1) the method is OPTIONS or (2) the user is
103            //       registered, it is safe to only add the Vary: Origin when those two conditions
104            //       are met since a response to a logged-in user's request is not cachable.
105            //       Therefore, logged out users should always get `Access-Control-Allow-Origin: *`
106            //       on all non-OPTIONS request and logged-in users *may* get
107            //      `Access-Control-Allow-Origin: <requested origin>`
108
109            // Vary All Requests by the Origin header.
110            $response->addHeader( 'Vary', 'Origin' );
111
112            // If the Origin header is missing, there is nothing to check against.
113            if ( $request->hasHeader( 'Origin' ) ) {
114                $origin = Origin::parseHeaderList( $request->getHeader( 'Origin' ) );
115                if ( $this->allowOrigin( $origin ) ) {
116                    // Only set the allowed origin for preflight requests, or for main requests where a registered
117                    // user is authenticated. This prevents having to Vary all requests by the Origin.
118                    // Anonymous users will always get '*', registered users *may* get the requested origin back.
119                    if ( $request->getMethod() === 'OPTIONS' || $this->user->isRegistered() ) {
120                        $allowedOrigin = $origin->getSingleOrigin();
121                    }
122                }
123            }
124        }
125
126        // If the Origin was determined to be something other than *any* allow the session
127        // cookies to be sent with the main request. If this is the main request, allow the
128        // response to be read.
129        //
130        // If the client includes the credentials on a simple request (HEAD, GET, etc.), but
131        // they do not pass this check, the browser will refuse to allow the client to read the
132        // response. The client may resolve this by repeating the request without the
133        // credentials.
134        if ( $allowedOrigin !== '*' ) {
135            $response->setHeader( 'Access-Control-Allow-Credentials', 'true' );
136        }
137
138        $response->setHeader( 'Access-Control-Allow-Origin', $allowedOrigin );
139
140        return $response;
141    }
142
143    /**
144     * Create a CORS preflight response.
145     *
146     * @param array $allowedMethods
147     * @return Response
148     */
149    public function createPreflightResponse( array $allowedMethods ): Response {
150        $response = $this->responseFactory->createNoContent();
151        $response->setHeader( 'Access-Control-Allow-Methods', $allowedMethods );
152
153        $allowedHeaders = $this->options->get( MainConfigNames::AllowedCorsHeaders );
154        $allowedHeaders = array_merge( $allowedHeaders, array_diff( [
155            // Authorization header must be explicitly listed which prevent the use of '*'
156            'Authorization',
157            // REST must allow Content-Type to be operational
158            'Content-Type',
159            // REST relies on conditional requests for some endpoints
160            'If-Match',
161            'If-None-Match',
162            'If-Modified-Since',
163        ], $allowedHeaders ) );
164        $response->setHeader( 'Access-Control-Allow-Headers', $allowedHeaders );
165
166        return $response;
167    }
168}