Passed
Push — master ( 4484b3...c128ed )
by Mihail
02:03
created

CorsMiddleware::__construct()   A

Complexity

Conditions 4
Paths 1

Size

Total Lines 8
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 6
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 8
ccs 7
cts 7
cp 1
crap 4
rs 10
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 17
    public function __construct(Configuration $config)
63
    {
64 17
        $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 17
        $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 17
        $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 17
        $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 17
        $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 17
        $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 17
    public function process(
73
        ServerRequestInterface $request,
74
        RequestHandlerInterface $handler): ResponseInterface
75
    {
76 17
        $response = $handler->handle($request);
77 14
        if (false === $request->hasHeader('Origin')) {
78 1
            return $response;
79
        }
80 13
        if ($this->isPreFlightRequest($request)) {
81 5
            return $this->responseForPreFlightRequest($request, $response);
82
        }
83 8
        if ($this->isSimpleRequest($request)) {
84 6
            return $this->responseForSimpleRequest($request, $response);
85
        }
86 2
        return $this->responseForActualRequest($request, $response);
87
    }
88
89 8
    private function isSimpleRequest(ServerRequestInterface $request): bool
90
    {
91
        // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests
92 8
        if (false === in_array($request->getMethod(), static::SAFE_METHODS, true)) {
93 2
            return false;
94
        }
95 6
        if ('' === $contentType = $request->getHeaderLine('Content-Type')) {
96 6
            return true;
97
        }
98
        $contentType = strtolower($contentType);
99
        return
100
            $contentType === 'application/x-www-form-urlencoded' ||
101
            $contentType === 'multipart/form-data' ||
102
            $contentType === 'text/plain';
103
    }
104
105 13
    private function isPreFlightRequest(ServerRequestInterface $request): bool
106
    {
107
        // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#preflighted_requests
108 13
        return Request::OPTIONS === $request->getMethod()
109 13
            && $request->hasHeader('Access-Control-Request-Method');
110
    }
111
112 12
    private function responseForSimpleRequest(
113
        ServerRequestInterface $request,
114
        ResponseInterface $response): ResponseInterface
115
    {
116 12
        if ($this->disabled) {
117
            // https://fetch.spec.whatwg.org/#http-responses
118 2
            return $response->withStatus(HttpStatus::FORBIDDEN);
119
        }
120
121 10
        $withCredentials = $request->hasHeader('Cookie');
122 10
        $origin = $this->getOrigin($request, $withCredentials);
123 10
        $response = $response->withHeader('Access-Control-Allow-Origin', $origin);
124
125 10
        if ($withCredentials) {
126 10
            $response = $response->withHeader('Access-Control-Allow-Credentials', 'true');
127
        }
128 10
        return $response->withAddedHeader('Vary', 'Origin');
129
    }
130
131 2
    private function responseForActualRequest(
132
        ServerRequestInterface $request,
133
        ResponseInterface $response) : ResponseInterface
134
    {
135 2
        $response = $this->responseForSimpleRequest($request, $response);
136 2
        if ($expose = $this->getExposedHeaders()) {
137 2
            $response = $response->withHeader('Access-Control-Expose-Headers', $expose);
138
        }
139 2
        return $response;
140
    }
141
142
    /**
143
     * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS#preflighted_requests_in_cors
144
     * @param ServerRequestInterface $request
145
     * @param ResponseInterface      $response
146
     * @return ResponseInterface
147
     */
148 5
    private function responseForPreFlightRequest(
149
        ServerRequestInterface $request,
150
        ResponseInterface $response): ResponseInterface
151
    {
152 5
        if ($this->disabled) {
153
            // https://fetch.spec.whatwg.org/#http-responses
154 1
            return $response->withStatus(HttpStatus::FORBIDDEN);
155
        }
156 4
        $response = $this->responseForSimpleRequest($request, $response);
157 4
        $response = $response->withHeader(
158
            'Access-Control-Allow-Methods',
159 4
            $this->getAllowedMethods($request)
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()) {
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 10
    private function getOrigin(ServerRequestInterface $request, bool &$withCredentials): string
178
    {
179 10
        $origin = $this->origin ?: $request->getHeaderLine('Origin') ?: '*';
180 10
        $withCredentials = false === str_contains($origin, '*');
181 10
        return $origin;
182
    }
183
184 4
    private function getAllowedMethods(ServerRequestInterface $request): string
185
    {
186 4
        $methods = match (true) {
187 4
            !empty($this->methods) && !str_contains('*', $this->methods) => $this->methods,
188 3
            !empty($allowed = $request->getAttribute('@http_methods')) => join(',', $allowed),
189
            default => 'HEAD,OPTIONS',
190
        };
191 4
        if (str_contains($methods, '*')) {
192
            return 'GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS';
193
        }
194 4
        return $methods;
195
    }
196
197 4
    private function getPreflightAllowedHeaders(ServerRequestInterface $request): string
198
    {
199 4
        if ($this->headers && false === str_contains($this->headers, '*')) {
200 1
            return $this->headers;
201
        }
202 3
        return $request->getHeaderLine('Access-Control-Request-Headers');
203
    }
204
205
    private function getAllowedHeaders(ServerRequestInterface $request): string
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...
206
    {
207
        $headers = $this->headers ?: $request->getHeaderLine('Access-Control-Request-Headers');
208
        if (str_contains($headers, '*')) {
209
            // Return here and let the client process the consequences of
210
            // the forced headers from configuration, or request headers
211
            return $headers;
212
        }
213
        $result = [];
214
        foreach (preg_split('/, */', $headers) as $header) {
215
            if (isset(self::SIMPLE_HEADERS[strtolower($header)])) {
216
                continue;
217
            }
218
            $result[] = $header;
219
        }
220
        return join(',', $result);
221
    }
222
223 6
    private function getExposedHeaders(): string
224
    {
225 6
        return (str_contains($this->expose, '*'))
226 6
            ? '' : $this->expose;
227
    }
228
}
229