Passed
Push — master ( 2a5e49...a65062 )
by Mihail
02:05
created

CorsMiddleware::process()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 8
nc 4
nop 2
dl 0
loc 15
ccs 9
cts 9
cp 1
crap 4
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 none of them are set and 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 16
    public function __construct(Configuration $config)
63
    {
64 16
        $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 16
        $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 16
        $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 16
        $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 16
        $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 16
        $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 16
    public function process(
73
        ServerRequestInterface $request,
74
        RequestHandlerInterface $handler): ResponseInterface
75
    {
76 16
        $response = $handler->handle($request);
77 13
        if (false === $request->hasHeader('Origin')) {
78 1
            return $response;
79
        }
80 12
        if ($this->isPreFlightRequest($request)) {
81 3
            return $this->responseForPreFlightRequest($request, $response);
82
        }
83 9
        if ($this->isSimpleRequest($request)) {
84 8
            return $this->responseForSimpleRequest($request, $response);
85
        }
86 1
        return $response;
87
    }
88
89 9
    private function isSimpleRequest(ServerRequestInterface $request): bool
90
    {
91
        // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests
92 9
        if (false === in_array($request->getMethod(), static::SAFE_METHODS, true)) {
93 1
            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 12
    private function isPreFlightRequest(ServerRequestInterface $request): bool
106
    {
107
        // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#preflighted_requests
108 12
        return Request::OPTIONS === $request->getMethod()
109 12
            && $request->hasHeader('Access-Control-Request-Method');
110
    }
111
112 10
    private function responseForSimpleRequest(
113
        ServerRequestInterface $request,
114
        ResponseInterface $response): ResponseInterface
115
    {
116 10
        if ($this->disabled) {
117
            // https://fetch.spec.whatwg.org/#http-responses
118 2
            return $response->withStatus(HttpStatus::FORBIDDEN);
119
        }
120 8
        $withCredentials = $request->hasHeader('Cookie');
121 8
        $response = $response->withHeader(
122
            'Access-Control-Allow-Origin',
123 8
            $origin = $this->getOrigin($request, $withCredentials)
124
        );
125 8
        if ('*' !== $origin) {
126 6
            $response = $response->withHeader('Access-Control-Allow-Credentials', 'true');
127
        }
128 8
        return $response->withAddedHeader('Vary', 'Origin');
129
    }
130
131
    /**
132
     * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS#preflighted_requests_in_cors
133
     * @param ServerRequestInterface $request
134
     * @param ResponseInterface      $response
135
     * @return ResponseInterface
136
     */
137 3
    private function responseForPreFlightRequest(
138
        ServerRequestInterface $request,
139
        ResponseInterface $response): ResponseInterface
140
    {
141 3
        if ($this->disabled) {
142
            // https://fetch.spec.whatwg.org/#http-responses
143 1
            return $response->withStatus(HttpStatus::FORBIDDEN);
144
        }
145 2
        $response = $this->responseForSimpleRequest($request, $response);
146 2
        $withCredentials = $request->hasHeader('Cookie');
147 2
        $response = $response->withHeader(
148
            'Access-Control-Allow-Methods',
149 2
            $this->getAllowedMethods($request, $withCredentials)
150
        );
151 2
        if ($headers = $this->getAllowedHeaders($request, $withCredentials)) {
152
            $response = $response->withHeader('Access-Control-Allow-Headers', $headers);
153
        }
154 2
        if ($expose = $this->getExposedHeaders($withCredentials)) {
155 2
            $response = $response->withHeader('Access-Control-Expose-Headers', $expose);
156
        }
157 2
        if ($this->maxAge > 0) {
158
            $response = $response->withHeader('Access-Control-Max-Age', (string)$this->maxAge);
159
        }
160
        return $response
161 2
            ->withStatus(HttpStatus::NO_CONTENT)
162 2
            ->withHeader('Content-Type', 'text/plain')
163 2
            ->withoutHeader('Cache-Control')
164 2
            ->withoutHeader('Allow');
165
    }
166
167 8
    private function getOrigin(
168
        ServerRequestInterface $request,
169
        bool $withCredentials): string
170
    {
171 8
        $origin = $this->origin ?: '*';
172 8
        if ($withCredentials && str_contains($origin, '*')) {
173 4
            return $request->getHeaderLine('Origin');
174
        }
175 4
        return $origin;
176
    }
177
178 2
    private function getAllowedMethods(
179
        ServerRequestInterface $request,
180
        bool $withCredentials): string
181
    {
182 2
        $methods = match (true) {
183 2
            !empty($this->methods) => $this->methods,
184 2
            !empty($method = $request->getAttribute('@http_methods')) => join(',', $method),
185
            default => 'HEAD,OPTIONS',
186
        };
187 2
        if ($withCredentials && str_contains($methods, '*')) {
188
            return 'GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS';
189
        }
190 2
        return $methods;
191
    }
192
193 2
    private function getAllowedHeaders(
194
        ServerRequestInterface $request,
195
        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

195
        /** @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...
196
    {
197 2
        $headers = $this->headers ?: $request->getHeaderLine('Access-Control-Request-Headers');
198 2
        if (str_contains($headers, '*')) {
199
            // Return here and let the client process the consequences of
200
            // the forced headers from configuration, or request headers
201
            return $headers;
202
        }
203 2
        $result = [];
204 2
        foreach (preg_split('/, */', $headers) as $header) {
205 2
            if (isset(self::SIMPLE_HEADERS[strtolower($header)])) {
206
//                continue;
207
            }
208 2
            $result[] = $header;
209
        }
210 2
        return join(',', $result);
211
    }
212
213 2
    private function getExposedHeaders(bool $withCredentials): string
214
    {
215 2
        return ($withCredentials && str_contains($this->expose, '*'))
216 2
            ? '' : $this->expose;
217
    }
218
}
219