Passed
Push — master ( a65062...b7f261 )
by Mihail
02:12
created

CorsMiddleware::getPreflightAllowedHeaders()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 3
nc 2
nop 1
dl 0
loc 6
ccs 4
cts 4
cp 1
crap 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace Koded\Framework\Middleware;
4
5
use Koded\Http\Interfaces\{HttpStatus, Request};
6
use Koded\Stdlib\Configuration;
7
use Psr\Http\Message\{ResponseInterface, ServerRequestInterface};
8
use Psr\Http\Server\{MiddlewareInterface, RequestHandlerInterface};
9
use function in_array;
10
use function join;
11
use function preg_split;
12
use function str_contains;
13
use function strtolower;
14
use function strtoupper;
15
use function trim;
16
17
/**
18
 * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
19
 * @link https://fetch.spec.whatwg.org/#cors-protocol-and-credentials
20
 */
21
class CorsMiddleware implements MiddlewareInterface
22
{
23
    private const SAFE_METHODS = [
24
        Request::GET,
25
        Request::POST,
26
        Request::HEAD
27
    ];
28
29
    /**
30
     * HTTP/1.1 Server-driven negotiation headers
31
     * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation
32
     */
33
    private const SIMPLE_HEADERS = [
34
        'accept' => true,
35
        'accept-language' => true,
36
        'content-language' => true,
37
        'content-type' => true
38
    ];
39
40
    private readonly bool $disabled;
41
    private readonly string $origin;
42
    private readonly string $methods;
43
    private readonly string $headers;
44
    private readonly string $expose;
45
    private readonly int $maxAge;
46
47
    /**
48
     * The configuration is used to force/override the CORS header values.
49
     * By default, most of them are not predefined or have a commonly used values.
50
     *
51
     * Configuration directives:
52
     *
53
     *      cors.disable
54
     *      cors.origin
55
     *      cors.headers
56
     *      cors.methods
57
     *      cors.maxAge
58
     *      cors.expose
59
     *
60
     * @param Configuration $config
61
     */
62 19
    public function __construct(Configuration $config)
63
    {
64 19
        $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...
65 19
        $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...
66 19
        $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...
67 19
        $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...
68 19
        $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...
69 19
        $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...
70
    }
71
72 19
    public function process(
73
        ServerRequestInterface $request,
74
        RequestHandlerInterface $handler): ResponseInterface
75
    {
76 19
        $response = $handler->handle($request);
77 16
        if (false === $request->hasHeader('Origin')) {
78 1
            return $response;
79
        }
80 15
        if ($this->isPreFlightRequest($request)) {
81 5
            return $this->responseForPreFlightRequest($request, $response);
82
        }
83 10
        if ($this->isSimpleRequest($request)) {
84 8
            return $this->responseForSimpleRequest($request, $response);
85
        }
86 2
        return $this->responseForActualRequest($request, $response);
87
    }
88
89 10
    private function isSimpleRequest(ServerRequestInterface $request): bool
90
    {
91
        // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests
92 10
        if (false === in_array($request->getMethod(), static::SAFE_METHODS, true)) {
93 2
            return false;
94
        }
95 8
        if ('' === $contentType = $request->getHeaderLine('Content-Type')) {
96 7
            return true;
97
        }
98 1
        $contentType = strtolower($contentType);
99
        return
100 1
            $contentType === 'application/x-www-form-urlencoded' ||
101
            $contentType === 'multipart/form-data' ||
102 1
            $contentType === 'text/plain';
103
    }
104
105 15
    private function isPreFlightRequest(ServerRequestInterface $request): bool
106
    {
107
        // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#preflighted_requests
108 15
        return Request::OPTIONS === $request->getMethod()
109 15
            && $request->hasHeader('Access-Control-Request-Method');
110
    }
111
112 14
    private function responseForSimpleRequest(
113
        ServerRequestInterface $request,
114
        ResponseInterface $response): ResponseInterface
115
    {
116 14
        if ($this->disabled) {
117
            // https://fetch.spec.whatwg.org/#http-responses
118 2
            return $response->withStatus(HttpStatus::FORBIDDEN);
119
        }
120 12
        $withCredentials = $request->hasHeader('Cookie');
121 12
        if ($origin = $this->getOrigin($request, $withCredentials)) {
122 12
            $response = $response->withHeader('Access-Control-Allow-Origin', $origin);
123
        }
124 12
        if ($withCredentials) {
125 7
            $response = $response->withHeader('Access-Control-Allow-Credentials', 'true');
126
        }
127 12
        return $response->withAddedHeader('Vary', 'Origin');
128
    }
129
130 2
    private function responseForActualRequest(
131
        ServerRequestInterface $request,
132
        ResponseInterface $response) : ResponseInterface
133
    {
134 2
        $response = $this->responseForSimpleRequest($request, $response);
135 2
        if ($expose = $this->getExposedHeaders($request->hasHeader('Cookie'))) {
136 2
            $response = $response->withHeader('Access-Control-Expose-Headers', $expose);
137
        }
138 2
        return $response;
139
    }
140
141
    /**
142
     * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS#preflighted_requests_in_cors
143
     * @param ServerRequestInterface $request
144
     * @param ResponseInterface      $response
145
     * @return ResponseInterface
146
     */
147 5
    private function responseForPreFlightRequest(
148
        ServerRequestInterface $request,
149
        ResponseInterface $response): ResponseInterface
150
    {
151 5
        if ($this->disabled) {
152
            // https://fetch.spec.whatwg.org/#http-responses
153 1
            return $response->withStatus(HttpStatus::FORBIDDEN);
154
        }
155 4
        $response = $this->responseForSimpleRequest($request, $response);
156 4
        $withCredentials = $request->hasHeader('Cookie');
157 4
        $response = $response->withHeader(
158
            'Access-Control-Allow-Methods',
159 4
            $this->getAllowedMethods($request, $withCredentials)
160
        );
161 4
        if ($headers = $this->getPreflightAllowedHeaders($request)) {
162 2
            $response = $response->withHeader('Access-Control-Allow-Headers', $headers);
163
        }
164 4
        if ($expose = $this->getExposedHeaders($withCredentials)) {
165 4
            $response = $response->withHeader('Access-Control-Expose-Headers', $expose);
166
        }
167 4
        if ($this->maxAge > 0) {
168
            $response = $response->withHeader('Access-Control-Max-Age', (string)$this->maxAge);
169
        }
170
        return $response
171 4
            ->withStatus(HttpStatus::NO_CONTENT)
172 4
            ->withHeader('Content-Type', 'text/plain')
173 4
            ->withoutHeader('Cache-Control')
174 4
            ->withoutHeader('Allow');
175
    }
176
177 12
    private function getOrigin(
178
        ServerRequestInterface $request,
179
        bool $withCredentials): string
180
    {
181 12
        $origin = $this->origin ?: '*';
182 12
        if ($withCredentials && str_contains($origin, '*')) {
183 6
            return $request->getHeaderLine('Origin');
184
        }
185 6
        return $origin;
186
    }
187
188 4
    private function getAllowedMethods(
189
        ServerRequestInterface $request,
190
        bool $withCredentials): string
191
    {
192 4
        $methods = match (true) {
193 4
            !empty($this->methods) && !str_contains('*', $this->methods) => $this->methods,
194 3
            !empty($allowed = $request->getAttribute('@http_methods')) => join(',', $allowed),
195
            default => 'HEAD,OPTIONS',
196
        };
197 4
        if ($withCredentials && str_contains($methods, '*')) {
198
            return 'GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS';
199
        }
200 4
        return $methods;
201
    }
202
203 4
    private function getPreflightAllowedHeaders(ServerRequestInterface $request): string
204
    {
205 4
        if ($this->headers && !str_contains($this->headers, '*')) {
206 1
            return $this->headers;
207
        }
208 3
        return $request->getHeaderLine('Access-Control-Request-Headers');
209
    }
210
211
    private function getAllowedHeaders(
0 ignored issues
show
Unused Code introduced by
The method getAllowedHeaders() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
212
        ServerRequestInterface $request,
213
        bool $withCredentials): string
0 ignored issues
show
Unused Code introduced by
The parameter $withCredentials 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

213
        /** @scrutinizer ignore-unused */ bool $withCredentials): 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...
214
    {
215
        $headers = $this->headers ?: $request->getHeaderLine('Access-Control-Request-Headers');
216
        if (str_contains($headers, '*')) {
217
            // Return here and let the client process the consequences of
218
            // the forced headers from configuration, or request headers
219
            return $headers;
220
        }
221
        $result = [];
222
        foreach (preg_split('/, */', $headers) as $header) {
223
            if (isset(self::SIMPLE_HEADERS[strtolower($header)])) {
224
                continue;
225
            }
226
            $result[] = $header;
227
        }
228
        return join(',', $result);
229
    }
230
231 6
    private function getExposedHeaders(bool $withCredentials): string
232
    {
233 6
        return ($withCredentials && str_contains($this->expose, '*'))
234 6
            ? '' : $this->expose;
235
    }
236
}
237