Passed
Push — master ( 6a5936...88a431 )
by Mihail
12:09
created

CorsMiddleware::skipProcess()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 3
c 0
b 0
f 0
nc 2
nop 1
dl 0
loc 7
ccs 4
cts 4
cp 1
crap 2
rs 10
1
<?php
2
3
namespace Koded\Framework\Middleware;
4
5
use Koded\Http\Interfaces\{HttpStatus, Request};
6
use Koded\Http\ServerResponse;
7
use Koded\Stdlib\Configuration;
8
use Psr\Http\Message\{ResponseInterface, ServerRequestInterface};
9
use Psr\Http\Server\{MiddlewareInterface, RequestHandlerInterface};
10
use function join;
11
use function str_contains;
12
use function strtoupper;
13
use function trim;
14
15
/**
16
 * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
17
 * @link https://fetch.spec.whatwg.org/#cors-protocol-and-credentials
18
 */
19
class CorsMiddleware implements MiddlewareInterface
20
{
21
    private readonly bool $disabled;
22
    private readonly string $origin;
23
    private readonly string $methods;
24
    private readonly string $headers;
25
    private readonly string $expose;
26
    private readonly int $maxAge;
27
28
    /**
29
     * The configuration is used to force/override the CORS header values.
30
     * By default, most of them are not predefined or have a commonly used values.
31
     *
32
     * Configuration directives:
33
     *
34
     *      cors.disable
35
     *      cors.origin
36
     *      cors.headers
37
     *      cors.methods
38
     *      cors.maxAge
39
     *      cors.expose
40
     *
41
     * @param Configuration $config
42
     */
43 22
    public function __construct(Configuration $config)
44
    {
45 22
        $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...
46 22
        $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...
47 22
        $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...
48 22
        $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...
49 22
        $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...
50 22
        $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...
51
    }
52
53 22
    public function process(
54
        ServerRequestInterface $request,
55
        RequestHandlerInterface $handler
56
    ): ResponseInterface {
57 22
        $response = $handler->handle($request);
58 21
        if ($this->skipProcess($request)) {
59 4
            return $response;
60
        }
61 17
        if ($this->isPreFlightRequest($request)) {
62 6
            return $this->responseForPreFlightRequest($request);
63
        }
64 11
        return $this->responseForActualRequest($request, $response);
65
    }
66
67 17
    private function isPreFlightRequest(ServerRequestInterface $request): bool
68
    {
69
        // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#preflighted_requests
70 17
        return Request::OPTIONS === $request->getMethod()
71 17
            && $request->hasHeader('Access-Control-Request-Method');
72
    }
73
74
    /**
75
     * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS#preflighted_requests_in_cors
76
     * @param ServerRequestInterface $request
77
     * @return ResponseInterface
78
     */
79 6
    private function responseForPreFlightRequest(ServerRequestInterface $request): ResponseInterface
80
    {
81 6
        if ($this->disabled) {
82
            // https://fetch.spec.whatwg.org/#http-responses
83 1
            return new ServerResponse('CORS is disabled', HttpStatus::FORBIDDEN);
84
        }
85 5
        $response = $this
86 5
            ->addOriginToResponse($request, new ServerResponse('', HttpStatus::NO_CONTENT))
87 5
            ->withHeader('Access-Control-Allow-Methods', $this->getAllowedMethods($request))
88 5
            ->withHeader('Access-Control-Allow-Credentials', 'true')
89 5
            ->withHeader('Content-Type', 'text/plain')
90 5
            ->withAddedHeader('Vary', 'Origin')
91 5
            ->withoutHeader('Cache-Control')
92 5
            ->withoutHeader('Allow');
93
94 5
        if ($headers = $this->getAllowedHeaders($request)) {
95 2
            $response = $response->withHeader('Access-Control-Allow-Headers', $headers);
96
        }
97 5
        if ($expose = $this->getExposedHeaders($request)) {
98 1
            $response = $response->withHeader('Access-Control-Expose-Headers', $expose);
99
        }
100 5
        if ($this->maxAge > 0) {
101
            $response = $response->withHeader('Access-Control-Max-Age', (string)$this->maxAge);
102
        }
103 5
        return $response;
104
    }
105
106 16
    private function addOriginToResponse(
107
        ServerRequestInterface $request,
108
        ResponseInterface $response
109
    ): ResponseInterface {
110 16
        if ($this->disabled) {
111
            // https://fetch.spec.whatwg.org/#http-responses
112 2
            return $response->withStatus(HttpStatus::FORBIDDEN);
113
        }
114 14
        $origin = $this->origin ?: $request->getHeaderLine('Origin') ?: '*';
115 14
        if ($request->hasHeader('Cookie')) {// || false === str_contains($origin, '*')) {
116 8
            $response = $response
117 8
                ->withHeader('Access-Control-Allow-Credentials', 'true')
118 8
                ->withAddedHeader('Vary', 'Origin');
119
        }
120 14
        return $response->withHeader('Access-Control-Allow-Origin', $origin);
121
    }
122
123 11
    private function responseForActualRequest(
124
        ServerRequestInterface $request,
125
        ResponseInterface $response
126
    ): ResponseInterface {
127 11
        if ($expose = $this->getExposedHeaders($request)) {
128 2
            $response = $response->withHeader('Access-Control-Expose-Headers', $expose);
129
        }
130 11
        return $this->addOriginToResponse($request, $response);
131
    }
132
133 5
    private function getAllowedMethods(ServerRequestInterface $request): string
134
    {
135 5
        return match (true) {
136 5
            !empty($this->methods) && !str_contains('*', $this->methods) => $this->methods,
137 5
            !empty($allowed = $request->getAttribute('@http_methods')) => join(',', $allowed),
138 5
            default => $request->getHeaderLine('Access-Control-Request-Method') ?: 'HEAD,OPTIONS'
139 5
        };
140
    }
141
142 5
    private function getAllowedHeaders(ServerRequestInterface $request): string
143
    {
144 5
        return ($this->headers && false === str_contains($this->headers, '*'))
145 1
            ? $this->headers
146 5
            : $request->getHeaderLine('Access-Control-Request-Headers');
147
    }
148
149 16
    private function getExposedHeaders(ServerRequestInterface $request): string
0 ignored issues
show
Unused Code introduced by
The parameter $request 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

149
    private function getExposedHeaders(/** @scrutinizer ignore-unused */ ServerRequestInterface $request): 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...
150
    {
151 16
        return ($this->expose && false === str_contains($this->expose, '*'))
152 3
            ? $this->expose
153 16
            : '';
154
    }
155
156 21
    private function skipProcess(ServerRequestInterface $request): bool
157
    {
158 21
        if (false === $request->hasHeader('Origin')) {
159 3
            return true;
160
        }
161
        // Same origin?
162 18
        return $request->getHeaderLine('Origin') === $request->getUri()->getScheme() . '://' . $request->getUri()->getHost();
163
    }
164
}
165