Passed
Push — master ( fdef6d...985930 )
by Mihail
02:31
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
10
/**
11
 * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
12
 * @link https://fetch.spec.whatwg.org/#cors-protocol-and-credentials
13
 */
14
class CorsMiddleware implements MiddlewareInterface
15
{
16
    private const SAFE_METHODS = [
17
        Request::GET,
18
        Request::POST,
19
        Request::HEAD
20
    ];
21
22
    /**
23
     * HTTP/1.1 Server-driven negotiation headers
24
     * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation
25
     */
26
    private const SIMPLE_HEADERS = [
27
        'accept' => true,
28
        'accept-language' => true,
29
        'content-language' => true,
30
        'content-type' => true
31
    ];
32
33
    private bool $isDisabled;
34
    private string $origin;
35
    private string $methods;
36
    private string $headers;
37
    private string $expose;
38
    private int $maxAge;
39
40
    /**
41
     * The configuration is used to force/override the CORS header values.
42
     * By default none of them are set and have a commonly used values.
43
     *
44
     * Configuration directives:
45
     *
46
     *      cors.disable
47
     *      cors.origin
48
     *      cors.headers
49
     *      cors.methods
50
     *      cors.maxAge
51
     *      cors.expose
52
     *
53
     * @param Configuration $config
54
     */
55 18
    public function __construct(Configuration $config)
56
    {
57 18
        $this->isDisabled = (bool)$config->get('cors.disable');
58 18
        $this->origin = \trim($config->get('cors.origin'));
59 18
        $this->methods = \strtoupper(\trim($config->get('cors.methods')));
60 18
        $this->headers = \trim($config->get('cors.headers'));
61 18
        $this->expose = \trim($config->get('cors.expose'));
62 18
        $this->maxAge = (int)$config->get('cors.maxAge');
63 18
    }
64
65 18
    public function process(
66
        ServerRequestInterface|Request $request,
67
        RequestHandlerInterface $handler): ResponseInterface
68
    {
69 18
        $response = $handler->handle($request);
70 15
        if (false === $request->hasHeader('Origin')) {
71 1
            return $response;
72
        }
73 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

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