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    /**
67     * @param Origin $origin
68     * @return bool
69     */
70    private function allowOrigin( Origin $origin ): bool {
71        $allowed = array_merge( [ $this->getCanonicalDomain() ],
72            $this->options->get( MainConfigNames::CrossSiteAJAXdomains ) );
73        $excluded = $this->options->get( MainConfigNames::CrossSiteAJAXdomainExceptions );
74
75        return $origin->match( $allowed, $excluded );
76    }
77
78    /**
79     * @return string
80     */
81    private function getCanonicalDomain(): string {
82        $res = parse_url( $this->options->get( MainConfigNames::CanonicalServer ) );
83        '@phan-var array $res';
84
85        $host = $res['host'] ?? '';
86        $port = $res['port'] ?? null;
87
88        return $port ? "$host:$port" : $host;
89    }
90
91    /**
92     * Modify response to allow for CORS.
93     *
94     * This method should be executed for every response from the REST API
95     * including errors.
96     *
97     * @param RequestInterface $request
98     * @param ResponseInterface $response
99     * @return ResponseInterface
100     */
101    public function modifyResponse( RequestInterface $request, ResponseInterface $response ): ResponseInterface {
102        if ( !$this->options->get( MainConfigNames::AllowCrossOrigin ) ) {
103            return $response;
104        }
105
106        $allowedOrigin = '*';
107
108        if ( $this->options->get( MainConfigNames::RestAllowCrossOriginCookieAuth ) ) {
109            // @TODO Since we only Vary the response if (1) the method is OPTIONS or (2) the user is
110            //       registered, it is safe to only add the Vary: Origin when those two conditions
111            //       are met since a response to a logged-in user's request is not cachable.
112            //       Therefore, logged out users should always get `Access-Control-Allow-Origin: *`
113            //       on all non-OPTIONS request and logged-in users *may* get
114            //      `Access-Control-Allow-Origin: <requested origin>`
115
116            // Vary All Requests by the Origin header.
117            $response->addHeader( 'Vary', 'Origin' );
118
119            // If the Origin header is missing, there is nothing to check against.
120            if ( $request->hasHeader( 'Origin' ) ) {
121                $origin = Origin::parseHeaderList( $request->getHeader( 'Origin' ) );
122                if ( $this->allowOrigin( $origin ) ) {
123                    // Only set the allowed origin for preflight requests, or for main requests where a registered
124                    // user is authenticated. This prevents having to Vary all requests by the Origin.
125                    // Anonymous users will always get '*', registered users *may* get the requested origin back.
126                    if ( $request->getMethod() === 'OPTIONS' || $this->user->isRegistered() ) {
127                        $allowedOrigin = $origin->getSingleOrigin();
128                    }
129                }
130            }
131        }
132
133        // If the Origin was determined to be something other than *any* allow the session
134        // cookies to be sent with the main request. If this is the main request, allow the
135        // response to be read.
136        //
137        // If the client includes the credentials on a simple request (HEAD, GET, etc.), but
138        // they do not pass this check, the browser will refuse to allow the client to read the
139        // response. The client may resolve this by repeating the request without the
140        // credentials.
141        if ( $allowedOrigin !== '*' ) {
142            $response->setHeader( 'Access-Control-Allow-Credentials', 'true' );
143        }
144
145        $response->setHeader( 'Access-Control-Allow-Origin', $allowedOrigin );
146
147        return $response;
148    }
149
150    /**
151     * Create a CORS preflight response.
152     *
153     * @param array $allowedMethods
154     * @return Response
155     */
156    public function createPreflightResponse( array $allowedMethods ): Response {
157        $response = $this->responseFactory->createNoContent();
158        $response->setHeader( 'Access-Control-Allow-Methods', $allowedMethods );
159
160        $allowedHeaders = $this->options->get( MainConfigNames::AllowedCorsHeaders );
161        $allowedHeaders = array_merge( $allowedHeaders, array_diff( [
162            // Authorization header must be explicitly listed which prevent the use of '*'
163            'Authorization',
164            // REST must allow Content-Type to be operational
165            'Content-Type',
166            // REST relies on conditional requests for some endpoints
167            'If-Match',
168            'If-None-Match',
169            'If-Modified-Since',
170        ], $allowedHeaders ) );
171        $response->setHeader( 'Access-Control-Allow-Headers', $allowedHeaders );
172
173        return $response;
174    }
175}