Passed
Push — master ( eb7873...ae1a84 )
by Mihail
02:02
created

CorsMiddleware   A

Complexity

Total Complexity 32

Size/Duplication

Total Lines 142
Duplicated Lines 0 %

Test Coverage

Coverage 98.36%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 59
dl 0
loc 142
ccs 60
cts 61
cp 0.9836
rs 9.84
c 3
b 0
f 0
wmc 32

10 Methods

Rating   Name   Duplication   Size   Complexity  
A skipProcess() 0 7 2
A isPreFlightRequest() 0 5 2
A getExposedHeaders() 0 5 3
A __construct() 0 8 4
A process() 0 12 3
A getAllowedHeaders() 0 5 3
A responseForPreFlightRequest() 0 23 5
A responseForActualRequest() 0 8 2
A addOriginToResponse() 0 15 5
A getAllowedMethods() 0 6 3
1
<?php
2
3
namespace Koded\Framework\Middleware;
4
5
use Koded\Http\Interfaces\{HttpStatus, Request};
6
use Koded\Http\ServerResponse;
7
use Koded\Stdlib\Configuration;
8
use Psr\Http\Message\{ResponseInterface, ServerRequestInterface};
9
use Psr\Http\Server\{MiddlewareInterface, RequestHandlerInterface};
10
use function join;
11
use function str_contains;
12
use function strtoupper;
13
use function trim;
14
15
/**
16
 * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
17
 * @link https://fetch.spec.whatwg.org/#cors-protocol-and-credentials
18
 */
19
class CorsMiddleware implements MiddlewareInterface
20
{
21
    private readonly bool $disabled;
22
    private readonly string $origin;
23
    private readonly string $methods;
24
    private readonly string $headers;
25
    private readonly string $expose;
26
    private readonly int $maxAge;
27
28
    /**
29
     * The configuration is used to force/override the CORS header values.
30
     * By default, most of them are not predefined or have a commonly used values.
31
     *
32
     * Configuration directives:
33
     *
34
     *      cors.disable
35
     *      cors.origin
36
     *      cors.headers
37
     *      cors.methods
38
     *      cors.maxAge
39
     *      cors.expose
40
     *
41
     * @param Configuration $config
42
     */
43 20
    public function __construct(Configuration $config)
44
    {
45 20
        $this->disabled = (bool)$config->get('cors.disable');
0 ignored issues
show
Bug introduced by
The property disabled is declared read-only in Koded\Framework\Middleware\CorsMiddleware.
Loading history...
46 20
        $this->origin = trim($config->get('cors.origin'));
0 ignored issues
show
Bug introduced by
The property origin is declared read-only in Koded\Framework\Middleware\CorsMiddleware.
Loading history...
47 20
        $this->methods = strtoupper(trim($config->get('cors.methods'))) ?: '*';
0 ignored issues
show
Bug introduced by
The property methods is declared read-only in Koded\Framework\Middleware\CorsMiddleware.
Loading history...
48 20
        $this->headers = trim($config->get('cors.headers')) ?: '*';
0 ignored issues
show
Bug introduced by
The property headers is declared read-only in Koded\Framework\Middleware\CorsMiddleware.
Loading history...
49 20
        $this->expose = trim($config->get('cors.expose')) ?: '*';
0 ignored issues
show
Bug introduced by
The property expose is declared read-only in Koded\Framework\Middleware\CorsMiddleware.
Loading history...
50 20
        $this->maxAge = (int)$config->get('cors.maxAge');
0 ignored issues
show
Bug introduced by
The property maxAge is declared read-only in Koded\Framework\Middleware\CorsMiddleware.
Loading history...
51
    }
52
53 20
    public function process(
54
        ServerRequestInterface $request,
55
        RequestHandlerInterface $handler
56
    ): ResponseInterface {
57 20
        $response = $handler->handle($request);
58 17
        if ($this->skipProcess($request)) {
59 2
            return $response;
60
        }
61 15
        if ($this->isPreFlightRequest($request)) {
62 6
            return $this->responseForPreFlightRequest($request);
63
        }
64 9
        return $this->responseForActualRequest($request, $response);
65
    }
66
67 15
    private function isPreFlightRequest(ServerRequestInterface $request): bool
68
    {
69
        // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#preflighted_requests
70 15
        return Request::OPTIONS === $request->getMethod()
71 15
            && $request->hasHeader('Access-Control-Request-Method');
72
    }
73
74
    /**
75
     * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS#preflighted_requests_in_cors
76
     * @param ServerRequestInterface $request
77
     * @return ResponseInterface
78
     */
79 6
    private function responseForPreFlightRequest(ServerRequestInterface $request): ResponseInterface
80
    {
81 6
        if ($this->disabled) {
82
            // https://fetch.spec.whatwg.org/#http-responses
83 1
            return new ServerResponse('CORS is disabled', HttpStatus::FORBIDDEN);
84
        }
85 5
        $response = $this
86 5
            ->addOriginToResponse($request, new ServerResponse('', HttpStatus::NO_CONTENT))
87 5
            ->withHeader('Content-Type', 'text/plain')
88 5
            ->withHeader('Access-Control-Allow-Methods', $this->getAllowedMethods($request));
89
90 5
        if ($headers = $this->getAllowedHeaders($request)) {
91 2
            $response = $response->withHeader('Access-Control-Allow-Headers', $headers);
92
        }
93 5
        if ($expose = $this->getExposedHeaders($request)) {
94 5
            $response = $response->withHeader('Access-Control-Expose-Headers', $expose);
95
        }
96 5
        if ($this->maxAge > 0) {
97
            $response = $response->withHeader('Access-Control-Max-Age', (string)$this->maxAge);
98
        }
99
        return $response
100 5
            ->withoutHeader('Cache-Control')
101 5
            ->withoutHeader('Allow');
102
    }
103
104 14
    private function addOriginToResponse(
105
        ServerRequestInterface $request,
106
        ResponseInterface $response
107
    ): ResponseInterface {
108 14
        if ($this->disabled) {
109
            // https://fetch.spec.whatwg.org/#http-responses
110 2
            return $response->withStatus(HttpStatus::FORBIDDEN);
111
        }
112 12
        $origin = $this->origin ?: $request->getHeaderLine('Origin') ?: '*';
113 12
        if ($request->hasHeader('Cookie')) {// || false === str_contains($origin, '*')) {
114 7
            $response = $response
115 7
                ->withHeader('Access-Control-Allow-Credentials', 'true')
116 7
                ->withAddedHeader('Vary', 'Origin');
117
        }
118 12
        return $response->withHeader('Access-Control-Allow-Origin', $origin);
119
    }
120
121 9
    private function responseForActualRequest(
122
        ServerRequestInterface $request,
123
        ResponseInterface $response
124
    ): ResponseInterface {
125 9
        if ($expose = $this->getExposedHeaders($request)) {
126 9
            $response = $response->withHeader('Access-Control-Expose-Headers', $expose);
127
        }
128 9
        return $this->addOriginToResponse($request, $response);
129
    }
130
131 5
    private function getAllowedMethods(ServerRequestInterface $request): string
132
    {
133
        return match (true) {
134 5
            !empty($this->methods) && !str_contains('*', $this->methods) => $this->methods,
135 4
            !empty($allowed = $request->getAttribute('@http_methods')) => join(',', $allowed),
136 5
            default => $request->getHeaderLine('Access-Control-Request-Method') ?: 'HEAD,OPTIONS'
137
        };
138
    }
139
140 5
    private function getAllowedHeaders(ServerRequestInterface $request): string
141
    {
142 5
        return ($this->headers && false === str_contains($this->headers, '*'))
143 1
            ? $this->headers
144 5
            : $request->getHeaderLine('Access-Control-Request-Headers');
145
    }
146
147 14
    private function getExposedHeaders(ServerRequestInterface $request): string
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

147
    private function getExposedHeaders(/** @scrutinizer ignore-unused */ ServerRequestInterface $request): string

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
148
    {
149 14
        return ($this->expose && false === str_contains($this->expose, '*'))
150 14
            ? $this->expose
151 14
            : '';
152
    }
153
154 17
    private function skipProcess(ServerRequestInterface $request): bool
155
    {
156 17
        if (false === $request->hasHeader('Origin')) {
157 1
            return true;
158
        }
159
        // Same origin?
160 16
        return $request->getHeaderLine('Origin') === $request->getUri()->getScheme() . '://' . $request->getUri()->getHost();
161
    }
162
}
163