Passed
Push — master ( fa7b2e...1cfa67 )
by Mihail
14:51
created

CorsMiddleware::getOrigin()   A

Complexity

Conditions 4
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 4
nc 2
nop 2
dl 0
loc 9
ccs 5
cts 5
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 bool $isDisabled;
41
    private string $origin;
42
    private string $methods;
43
    private string $headers;
44
    private string $expose;
45
    private 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 18
    public function __construct(Configuration $config)
63
    {
64 18
        $this->isDisabled = (bool)$config->get('cors.disable');
65 18
        $this->origin = trim($config->get('cors.origin'));
66 18
        $this->methods = strtoupper(trim($config->get('cors.methods')));
67 18
        $this->headers = trim($config->get('cors.headers'));
68 18
        $this->expose = trim($config->get('cors.expose'));
69 18
        $this->maxAge = (int)$config->get('cors.maxAge');
70
    }
71
72 18
    public function process(
73
        ServerRequestInterface|Request $request,
74
        RequestHandlerInterface $handler): ResponseInterface
75
    {
76 18
        $response = $handler->handle($request);
77 15
        if (false === $request->hasHeader('Origin')) {
78 1
            return $response;
79
        }
80 14
        if ($request->getHeaderLine('Origin') === $request->getBaseUri()) {
0 ignored issues
show
Bug introduced by
The method getBaseUri() does not exist on Psr\Http\Message\ServerRequestInterface. Did you maybe mean getUri()? ( Ignorable by Annotation )

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

80
        if ($request->getHeaderLine('Origin') === $request->/** @scrutinizer ignore-call */ getBaseUri()) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
81 1
            return $response;
82
        }
83 13
        if ($this->isPreFlightRequest($request)) {
84 5
            return $this->responseForPreFlightRequest($request, $response);
85
        }
86 8
        if ($this->isSimpleRequest($request)) {
87 7
            return $this->responseForSimpleRequest($request, $response);
88
        }
89 1
        return $response;
90
    }
91
92 8
    private function isSimpleRequest(ServerRequestInterface $request): bool
93
    {
94
        // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests
95 8
        if (false === in_array($request->getMethod(), static::SAFE_METHODS, true)) {
96 1
            return false;
97
        }
98 7
        if ('' === $contentType = $request->getHeaderLine('Content-Type')) {
99 6
            return true;
100
        }
101 1
        $contentType = strtolower($contentType);
102
        return
103 1
            $contentType === 'application/x-www-form-urlencoded' ||
104
            $contentType === 'multipart/form-data' ||
105 1
            $contentType === 'text/plain';
106
    }
107
108 13
    private function isPreFlightRequest(ServerRequestInterface $request): bool
109
    {
110
        // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#preflighted_requests
111 13
        return Request::OPTIONS === $request->getMethod()
112 13
            && $request->hasHeader('Access-Control-Request-Method');
113
    }
114
115 11
    private function responseForSimpleRequest(
116
        ServerRequestInterface $request,
117
        ResponseInterface $response): ResponseInterface
118
    {
119 11
        if ($this->isDisabled) {
120
            // https://fetch.spec.whatwg.org/#http-responses
121 2
            return $response->withStatus(HttpStatus::FORBIDDEN);
122
        }
123 9
        $response = $response->withAddedHeader('Vary', 'Origin');
124 9
        if ($hasCredentials = $request->hasHeader('Cookie')) {
125 5
            $response = $response->withHeader('Access-Control-Allow-Credentials', 'true');
126
        }
127 9
        return $response->withHeader('Access-Control-Allow-Origin',
128 9
                                     $this->getOrigin($request, $hasCredentials));
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 5
    private function responseForPreFlightRequest(
138
        ServerRequestInterface $request,
139
        ResponseInterface $response): ResponseInterface
140
    {
141 5
        if ($this->isDisabled) {
142
            // https://fetch.spec.whatwg.org/#http-responses
143 1
            return $response->withStatus(HttpStatus::FORBIDDEN);
144
        }
145 4
        $response = $this->responseForSimpleRequest($request, $response);
146 4
        $hasCredentials = $request->hasHeader('Cookie');
147 4
        $response = $response->withHeader('Access-Control-Allow-Methods',
148 4
                                          $this->getAllowedMethods($request, $hasCredentials));
149 4
        if ($headers = $this->getAllowedHeaders($request, $hasCredentials)) {
150 2
            $response = $response->withHeader('Access-Control-Allow-Headers', $headers);
151
        }
152 4
        if ($expose = $this->getExposedHeaders($hasCredentials)) {
153 4
            $response = $response->withHeader('Access-Control-Expose-Headers', $expose);
154
        }
155 4
        if ($this->maxAge > 0) {
156
            $response = $response->withHeader('Access-Control-Max-Age', (string)$this->maxAge);
157
        }
158
        return $response
159 4
            ->withStatus(HttpStatus::NO_CONTENT)
160 4
            ->withHeader('Content-Type', 'text/plain')
161 4
            ->withoutHeader('Cache-Control')
162 4
            ->withoutHeader('Allow');
163
    }
164
165 9
    private function getOrigin(
166
        ServerRequestInterface $request,
167
        bool $hasCredentials): string
168
    {
169 9
        $origin = $this->origin ?: '*';
170 9
        if ($hasCredentials && str_contains($origin, '*')) {
171 4
            return $request->getHeaderLine('Origin');
172
        }
173 5
        return $origin;
174
    }
175
176 4
    private function getAllowedMethods(
177
        ServerRequestInterface $request,
178
        bool $hasCredentials): string
179
    {
180 4
        $methods = match (true) {
181 4
            !empty($this->methods) => $this->methods,
182 3
            !empty($method = $request->getAttribute('@http_methods')) => join(',', $method),
183
            default => 'HEAD,OPTIONS',
184
        };
185 4
        if ($hasCredentials && str_contains($methods, '*')) {
186
            return 'GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS';
187
        }
188 4
        return $methods;
189
    }
190
191 4
    private function getAllowedHeaders(
192
        ServerRequestInterface $request,
193
        bool $hasCredentials): string
194
    {
195 4
        $headers = $this->headers ?: $request->getHeaderLine('Access-Control-Request-Headers');
196 4
        if ($hasCredentials && str_contains($headers, '*')) {
197
            // Return here and let the client process the consequences
198
            // of the forced headers from configuration, or sent headers
199
            return $headers;
200
        }
201 4
        $result = [];
202 4
        foreach (preg_split('/, */', $headers) as $header) {
203 4
            if (isset(self::SIMPLE_HEADERS[strtolower($header)])) {
204 2
                continue;
205
            }
206 4
            $result[] = $header;
207
        }
208 4
        return join(',', $result);
209
    }
210
211 4
    private function getExposedHeaders(bool $hasCredentials): string
212
    {
213 4
        return ($hasCredentials && str_contains($this->expose, '*'))
214 4
            ? '' : $this->expose;
215
    }
216
}
217