CorsService::createResponse()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 3
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
1
<?php
2
3
namespace ShiftOneLabs\LaravelCors;
4
5
use Symfony\Component\HttpFoundation\Request;
6
use Symfony\Component\HttpFoundation\Response;
7
8
/**
9
 * Cors Service
10
 *
11
 * A lot of this code is derived from the asm89/stack-cors package. There were
12
 * a few things that needed tweaking, though, and the package's service class
13
 * uses private visibility, so I couldn't extend and override.
14
 *
15
 * @see https://github.com/asm89/stack-cors
16
 */
17
class CorsService
18
{
19
    /** @var array $options */
20
    protected $options;
21
22
    /**
23
     * Create a new service instance.
24
     *
25
     * @param  array  $options
26
     *
27
     * @return void
28
     */
29 598
    public function __construct(array $options = [])
30
    {
31 598
        $this->options = $this->normalizeOptions($options);
32 598
    }
33
34
    /**
35
     * Normalize the options into something more usable for the library.
36
     *
37
     * @param  array  $options
38
     *
39
     * @return array
40
     */
41 598
    protected function normalizeOptions(array $options = [])
42
    {
43
        $options += [
44 598
            'allowedOrigins' => [],
45 65
            'allowedOriginsPatterns' => [],
46 65
            'supportsCredentials' => false,
47 65
            'allowedHeaders' => [],
48 65
            'exposedHeaders' => [],
49 65
            'allowedMethods' => [],
50 65
            'maxAge' => 0,
51
        ];
52
53 598
        if (in_array('*', $options['allowedOrigins'])) {
54 368
            $options['allowedOrigins'] = true;
55 40
        }
56
57 598
        if (in_array('*', $options['allowedHeaders'])) {
58 184
            $options['allowedHeaders'] = true;
59 20
        } else {
60 506
            $options['allowedHeaders'] = array_map('strtolower', $options['allowedHeaders']);
61
        }
62
63 598
        if (in_array('*', $options['allowedMethods'])) {
64 276
            $options['allowedMethods'] = true;
65 30
        } else {
66 506
            $options['allowedMethods'] = array_map('strtoupper', $options['allowedMethods']);
67
        }
68
69 598
        return $options;
70
    }
71
72
    /**
73
     * Check if the request is a valid Cors request.
74
     *
75
     * @param  \Symfony\Component\HttpFoundation\Request  $request
76
     *
77
     * @return bool
78
     */
79 276
    public function isCorsRequest(Request $request)
80
    {
81 276
        return $request->headers->has('Origin') && $this->isCrossOrigin($request);
82
    }
83
84
    /**
85
     * Check if the request is a valid Cors preflight request.
86
     *
87
     * @param  \Symfony\Component\HttpFoundation\Request  $request
88
     *
89
     * @return bool
90
     */
91 184
    public function isPreflightRequest(Request $request)
92
    {
93 184
        return $this->isCorsRequest($request)
94 184
            && $request->getMethod() === 'OPTIONS'
95 184
            && $request->headers->has('Access-Control-Request-Method');
96
    }
97
98
    /**
99
     * Check the request origin to determine if the request is allowed.
100
     *
101
     * For the base CORS service, the request method is only checked during
102
     * preflight requests. If an invalid method is used for an actual
103
     * request, that should be handled at the application level.
104
     *
105
     * @param  \Symfony\Component\HttpFoundation\Request  $request
106
     *
107
     * @return bool
108
     */
109 92
    public function isActualRequestAllowed(Request $request)
110
    {
111 92
        return $this->isOriginAllowed($request);
112
    }
113
114
    /**
115
     * Add the valid Cors headers to the response.
116
     *
117
     * @param  \Symfony\Component\HttpFoundation\Response  $response
118
     * @param  \Symfony\Component\HttpFoundation\Request  $request
119
     *
120
     * @return \Symfony\Component\HttpFoundation\Response
121
     */
122 46
    public function addActualRequestHeaders(Response $response, Request $request)
123
    {
124 46
        if (!$this->isActualRequestAllowed($request)) {
125 46
            return $response;
126
        }
127
128 46
        if (!$response->headers->has('Vary')) {
129 46
            $response->headers->set('Vary', 'Origin');
130 5
        } else {
131
            $response->headers->set('Vary', $response->headers->get('Vary') . ', Origin');
132
        }
133
134 46
        $response->headers->set('Access-Control-Allow-Origin', $request->headers->get('Origin'));
135
136 46
        if ($this->options['supportsCredentials']) {
137 46
            $response->headers->set('Access-Control-Allow-Credentials', 'true');
138 5
        }
139
140 46
        if ($this->options['exposedHeaders']) {
141 46
            $response->headers->set('Access-Control-Expose-Headers', implode(', ', $this->options['exposedHeaders']));
142 5
        }
143
144 46
        return $response;
145
    }
146
147
    /**
148
     * Get the full response for a Cors preflight request.
149
     *
150
     * This method deviates from the official preflight request algorithm. In the
151
     * official algorithm, if any of the origin, method, or headers is invalid,
152
     * no allow headers are added. So, if the origin is valid, but the header
153
     * is not supported, the allow origin header would not be added, and the
154
     * client would report an origin error, even though the origin is valid.
155
     *
156
     * This algorithm will only not add headers if the origin is not allowed (to
157
     * prevent leaking information). If the origin is allowed, then add all the
158
     * CORS response headers so the client can validate if the response is
159
     * valid and give the appropriate error message if not.
160
     *
161
     * The only exception to this algorithm is when the request method is not
162
     * allowed and it is a simple request method. For simple request methods,
163
     * clients do not validate the method against the allowed method header,
164
     * so we need to remove the allowed origin header to reject the request.
165
     *
166
     * @see https://www.w3.org/TR/cors/#resource-preflight-requests
167
     *
168
     * @param  \Symfony\Component\HttpFoundation\Request  $request
169
     *
170
     * @return \Symfony\Component\HttpFoundation\Response
171
     */
172 46
    public function handlePreflightRequest(Request $request)
173
    {
174
        // Preflight responses, even rejected ones, should return a 204
175
        // no body response.
176 46
        $response = $this->createResponse(null, 204);
177
178 46
        if (!$response->headers->has('Vary')) {
179 46
            $response->headers->set('Vary', 'Origin');
180 5
        } else {
181
            $response->headers->set('Vary', $response->headers->get('Vary') . ', Origin');
182
        }
183
184 46
        if (!$this->isOriginAllowed($request)) {
185 46
            return $response;
186
        }
187
188 46
        $response->headers->set('Access-Control-Allow-Origin', $request->headers->get('Origin'));
189
190
        // Clients ignore the Access-Control-Allow-Methods header for simple
191
        // request methods. In order to reject requests for simple methods
192
        // that aren't allowed, we disallow the origin.
193 46
        if (!$this->isMethodAllowed($request) && $this->isSimpleMethod($request)) {
194 46
            $response->headers->remove('Access-Control-Allow-Origin');
195 5
        }
196
197 46
        $allowMethods = $this->options['allowedMethods'] === true
198 46
            ? strtoupper($request->headers->get('Access-Control-Request-Method'))
199 46
            : implode(', ', $this->options['allowedMethods']);
200 46
        $response->headers->set('Access-Control-Allow-Methods', $allowMethods);
201
202 46
        $allowHeaders = $this->options['allowedHeaders'] === true
203 46
            ? strtoupper($request->headers->get('Access-Control-Request-Headers'))
204 46
            : implode(', ', $this->options['allowedHeaders']);
205 46
        $response->headers->set('Access-Control-Allow-Headers', $allowHeaders);
206
207 46
        if ($this->options['supportsCredentials']) {
208 46
            $response->headers->set('Access-Control-Allow-Credentials', 'true');
209 5
        }
210
211 46
        if ($this->options['maxAge']) {
212 46
            $response->headers->set('Access-Control-Max-Age', $this->options['maxAge']);
213 5
        }
214
215 46
        if ($this->isMethodAllowed($request) && $this->isHeadersAllowed($request)) {
216 46
            $response->headers->set('X-CORS-PREFLIGHT-SUCCESS', 'true');
217 5
        }
218
219 46
        return $response;
220
    }
221
222
    /**
223
     * Check if a given response is a successful preflight response.
224
     *
225
     * @param  \Symfony\Component\HttpFoundation\Response  $response
226
     *
227
     * @return bool
228
     */
229 46
    public function isPreflightSuccessful(Response $response)
230
    {
231
        // Only successful preflight responses will have this header.
232 46
        return $response->headers->has('X-CORS-PREFLIGHT-SUCCESS');
233
    }
234
235
    /**
236
     * Check if a given response is a rejected preflight response.
237
     *
238
     * @param  \Symfony\Component\HttpFoundation\Response  $response
239
     *
240
     * @return bool
241
     */
242 46
    public function isPreflightRejected(Response $response)
243
    {
244 46
        return !$this->isPreflightSuccessful($response);
245
    }
246
247
    /**
248
     * Check if the request is actually cross origin (the origin isn't the same
249
     * as the request host).
250
     *
251
     * @param \Symfony\Component\HttpFoundation\Request $request
252
     *
253
     * @return bool
254
     */
255 230
    protected function isCrossOrigin(Request $request)
256
    {
257 230
        return $request->headers->get('Origin') !== $request->getSchemeAndHttpHost();
258
    }
259
260
    /**
261
     * Check if the request origin is allowed by the CORS configuration.
262
     *
263
     * @param  \Symfony\Component\HttpFoundation\Request  $request
264
     *
265
     * @return bool
266
     */
267 230
    protected function isOriginAllowed(Request $request)
268
    {
269 230
        if ($this->options['allowedOrigins'] === true) {
270 230
            return true;
271
        }
272
273 230
        $origin = $request->headers->get('Origin');
274
275 230
        if (in_array($origin, $this->options['allowedOrigins'])) {
276 92
            return true;
277
        }
278
279 230
        foreach ($this->options['allowedOriginsPatterns'] as $pattern) {
280
            if (preg_match($pattern, $origin)) {
281
                return true;
282
            }
283 25
        }
284
285 230
        return false;
286
    }
287
288
    /**
289
     * Check if the request method is allowed by the CORS configuration.
290
     *
291
     * @param  \Symfony\Component\HttpFoundation\Request  $request
292
     *
293
     * @return bool
294
     */
295 138
    protected function isMethodAllowed(Request $request)
296
    {
297 138
        if ($this->options['allowedMethods'] === true) {
298 138
            return true;
299
        }
300
301 138
        $method = $this->getActualRequestMethod($request);
302
303 138
        return in_array($method, $this->options['allowedMethods']);
304
    }
305
306
    /**
307
     * Check if the request headers are allowed by the CORS configuration.
308
     *
309
     * @param  \Symfony\Component\HttpFoundation\Request  $request
310
     *
311
     * @return bool
312
     */
313 46
    protected function isHeadersAllowed(Request $request)
314
    {
315 46
        if ($this->options['allowedHeaders'] === true || !$request->headers->has('Access-Control-Request-Headers')) {
316 46
            return true;
317
        }
318
319 46
        $headers = strtolower($request->headers->get('Access-Control-Request-Headers'));
320 46
        $headers = array_filter(array_map('trim', explode(',', $headers)));
321
322 46
        return empty(array_diff($headers, $this->options['allowedHeaders']));
323
    }
324
325
    /**
326
     * Check if this a simple request method.
327
     *
328
     * @see https://www.w3.org/TR/cors/#simple-method
329
     *
330
     * @param  \Symfony\Component\HttpFoundation\Request  $request
331
     *
332
     * @return bool
333
     */
334 46
    protected function isSimpleMethod(Request $request)
335
    {
336 46
        $method = $this->getActualRequestMethod($request);
337
338 46
        return in_array($method, ['GET', 'HEAD', 'POST']);
339
    }
340
341
    /**
342
     * Get the request method for the actual request, even if the current
343
     * request is a preflight request.
344
     *
345
     * @param  \Symfony\Component\HttpFoundation\Request  $request
346
     *
347
     * @return string
348
     */
349 138
    protected function getActualRequestMethod(Request $request)
350
    {
351 138
        return $this->isPreflightRequest($request)
352 56
            ? strtoupper($request->headers->get('Access-Control-Request-Method'))
353 138
            : $request->getMethod();
354
    }
355
356
    /**
357
     * Create a response object.
358
     *
359
     * @param  string|null  $content
360
     * @param  int  $status
361
     * @param  array  $headers
362
     *
363
     * @return \Symfony\Component\HttpFoundation\Response
364
     */
365 46
    protected function createResponse($content = '', $status = 200, $headers = [])
366
    {
367 46
        return new Response($content, $status, $headers);
368
    }
369
}
370