Completed
Branch develop (a1a607)
by Neomerx
02:00
created

Analyzer   B

Complexity

Total Complexity 47

Size/Duplication

Total Lines 420
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 5

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
wmc 47
lcom 1
cbo 5
dl 0
loc 420
ccs 135
cts 135
cp 1
rs 8.64
c 0
b 0
f 0

15 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 5 1
A instance() 0 4 1
A setLogger() 0 5 1
A analyze() 0 10 1
B analyzeImplementation() 0 37 7
A analyzeAsRequest() 0 22 3
A analyzeAsPreFlight() 0 38 4
B createPreFlightResponseHeaders() 0 36 7
A getRequestedHeadersInLowerCase() 0 17 4
A getOriginHeader() 0 11 3
A checkIsSameHost() 0 38 5
B checkIsCrossOrigin() 0 42 6
A createResult() 0 4 1
A getFactory() 0 5 1
A getRequestHostHeader() 0 7 2

How to fix   Complexity   

Complex Class

Complex classes like Analyzer often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Analyzer, and based on these observations, apply Extract Interface, too.

1
<?php declare(strict_types=1);
2
3
namespace Neomerx\Cors;
4
5
/**
6
 * Copyright 2015-2019 [email protected]
7
 *
8
 * Licensed under the Apache License, Version 2.0 (the "License");
9
 * you may not use this file except in compliance with the License.
10
 * You may obtain a copy of the License at
11
 *
12
 * http://www.apache.org/licenses/LICENSE-2.0
13
 *
14
 * Unless required by applicable law or agreed to in writing, software
15
 * distributed under the License is distributed on an "AS IS" BASIS,
16
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
 * See the License for the specific language governing permissions and
18
 * limitations under the License.
19
 */
20
21
use Neomerx\Cors\Contracts\AnalysisResultInterface;
22
use Neomerx\Cors\Contracts\AnalysisStrategyInterface;
23
use Neomerx\Cors\Contracts\AnalyzerInterface;
24
use Neomerx\Cors\Contracts\Constants\CorsRequestHeaders;
25
use Neomerx\Cors\Contracts\Constants\CorsResponseHeaders;
26
use Neomerx\Cors\Contracts\Constants\SimpleRequestHeaders;
27
use Neomerx\Cors\Contracts\Constants\SimpleRequestMethods;
28
use Neomerx\Cors\Contracts\Factory\FactoryInterface;
29
use Neomerx\Cors\Log\LoggerAwareTrait;
30
use Psr\Http\Message\RequestInterface;
31
use Psr\Log\LoggerInterface;
32
33
/**
34
 * @package Neomerx\Cors
35
 *
36
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
37
 */
38
class Analyzer implements AnalyzerInterface
39
{
40
    use LoggerAwareTrait {
41
        LoggerAwareTrait::setLogger as psrSetLogger;
42
    }
43
44
    /** HTTP method for pre-flight request */
45
    const PRE_FLIGHT_METHOD = 'OPTIONS';
46
47
    /**
48
     * @var array
49
     */
50
    private const SIMPLE_METHODS = [
51
        SimpleRequestMethods::GET  => true,
52
        SimpleRequestMethods::HEAD => true,
53
        SimpleRequestMethods::POST => true,
54
    ];
55
56
    /**
57
     * @var string[]
58
     */
59
    private const SIMPLE_LC_HEADERS_EXCLUDING_CONTENT_TYPE = [
60
        SimpleRequestHeaders::LC_ACCEPT,
61
        SimpleRequestHeaders::LC_ACCEPT_LANGUAGE,
62
        SimpleRequestHeaders::LC_CONTENT_LANGUAGE,
63
    ];
64
65
    /**
66
     * @var AnalysisStrategyInterface
67
     */
68
    private $strategy;
69
70
    /**
71
     * @var FactoryInterface
72
     */
73
    private $factory;
74
75
    /**
76
     * @param AnalysisStrategyInterface $strategy
77
     * @param FactoryInterface          $factory
78
     */
79 16
    public function __construct(AnalysisStrategyInterface $strategy, FactoryInterface $factory)
80
    {
81 16
        $this->factory  = $factory;
82 16
        $this->strategy = $strategy;
83 16
    }
84
85
    /**
86
     * Create analyzer instance.
87
     *
88
     * @param AnalysisStrategyInterface $strategy
89
     *
90
     * @return AnalyzerInterface
91
     */
92 16
    public static function instance(AnalysisStrategyInterface $strategy): AnalyzerInterface
93
    {
94 16
        return static::getFactory()->createAnalyzer($strategy);
95
    }
96
97
    /**
98
     * @inheritdoc
99
     */
100 1
    public function setLogger(LoggerInterface $logger)
101
    {
102 1
        $this->psrSetLogger($logger);
103 1
        $this->strategy->setLogger($logger);
104 1
    }
105
106
    /**
107
     * @inheritdoc
108
     *
109
     * @see http://www.w3.org/TR/cors/#resource-processing-model
110
     */
111 14
    public function analyze(RequestInterface $request): AnalysisResultInterface
112
    {
113 14
        $this->logDebug('CORS analysis for request started.');
114
115 14
        $result = $this->analyzeImplementation($request);
116
117 14
        $this->logDebug('CORS analysis for request completed.');
118
119 14
        return $result;
120
    }
121
122
    /**
123
     * @param RequestInterface $request
124
     *
125
     * @return AnalysisResultInterface
126
     */
127 14
    protected function analyzeImplementation(RequestInterface $request): AnalysisResultInterface
128
    {
129
        // check 'Host' request
130 14
        if ($this->strategy->isCheckHost() === true && $this->checkIsSameHost($request) === false) {
131 1
            return $this->createResult(AnalysisResultInterface::ERR_NO_HOST_HEADER);
132
        }
133
134
        // Request handlers have common part (#6.1.1 - #6.1.2 and #6.2.1 - #6.2.2)
135
136
        // #6.1.1 and #6.2.1
137 13
        if (empty($requestOrigin = $this->getOriginHeader($request)) === true) {
138 1
            $this->logInfo('Request is not CORS (request origin is empty).');
139 1
            return $this->createResult(AnalysisResultInterface::TYPE_REQUEST_OUT_OF_CORS_SCOPE);
140
        }
141 12
        if ($this->checkIsCrossOrigin($requestOrigin) === false) {
142 2
            return $this->createResult(AnalysisResultInterface::TYPE_REQUEST_OUT_OF_CORS_SCOPE);
143
        }
144
145
        // #6.1.2 and #6.2.2
146 10
        if ($this->strategy->isRequestOriginAllowed($requestOrigin) === false) {
147 3
            $this->logInfo(
148 3
                'Request origin is not allowed. Check config settings for Allowed Origins.',
149 3
                ['origin' => $requestOrigin]
150
            );
151 3
            return $this->createResult(AnalysisResultInterface::ERR_ORIGIN_NOT_ALLOWED);
152
        }
153
154
        // Since this point handlers have their own path for
155
        // - simple CORS and actual CORS request (#6.1.3 - #6.1.4)
156
        // - pre-flight request (#6.2.3 - #6.2.10)
157
158 7
        if ($request->getMethod() === self::PRE_FLIGHT_METHOD) {
159 5
            return $this->analyzeAsPreFlight($request, $requestOrigin);
160
        }
161
162 2
        return $this->analyzeAsRequest($request, $requestOrigin);
163
    }
164
165
    /**
166
     * Analyze request as simple CORS or/and actual CORS request (#6.1.3 - #6.1.4).
167
     *
168
     * @param RequestInterface $request
169
     * @param string           $requestOrigin
170
     *
171
     * @return AnalysisResultInterface
172
     */
173 2
    protected function analyzeAsRequest(RequestInterface $request, string $requestOrigin): AnalysisResultInterface
174
    {
175 2
        $this->logDebug('Request is identified as an actual CORS request.');
176
177 2
        $headers = [];
178
179
        // #6.1.3
180 2
        $headers[CorsResponseHeaders::ALLOW_ORIGIN] = $requestOrigin;
181 2
        if ($this->strategy->isRequestCredentialsSupported($request) === true) {
182 1
            $headers[CorsResponseHeaders::ALLOW_CREDENTIALS] = CorsResponseHeaders::VALUE_ALLOW_CREDENTIALS_TRUE;
183
        }
184
        // #6.4
185 2
        $headers[CorsResponseHeaders::VARY] = CorsRequestHeaders::ORIGIN;
186
187
        // #6.1.4
188 2
        $exposedHeaders = $this->strategy->getResponseExposedHeaders($request);
189 2
        if (empty($exposedHeaders) === false) {
190 1
            $headers[CorsResponseHeaders::EXPOSE_HEADERS] = $exposedHeaders;
191
        }
192
193 2
        return $this->createResult(AnalysisResultInterface::TYPE_ACTUAL_REQUEST, $headers);
194
    }
195
196
    /**
197
     * Analyze request as CORS pre-flight request (#6.2.3 - #6.2.10).
198
     *
199
     * @param RequestInterface $request
200
     * @param string           $requestOrigin
201
     *
202
     * @return AnalysisResultInterface
203
     *
204
     * @SuppressWarnings(PHPMD.NPathComplexity)
205
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
206
     */
207 5
    protected function analyzeAsPreFlight(RequestInterface $request, string $requestOrigin): AnalysisResultInterface
208
    {
209
        // #6.2.3
210 5
        $requestMethod = $request->getHeader(CorsRequestHeaders::METHOD);
211 5
        if (empty($requestMethod) === true) {
212 1
            $this->logDebug('Request is not CORS (header ' . CorsRequestHeaders::METHOD . ' is not specified).');
213
214 1
            return $this->createResult(AnalysisResultInterface::TYPE_REQUEST_OUT_OF_CORS_SCOPE);
215
        }
216 4
        $requestMethod = reset($requestMethod);
217
218
        // OK now we are sure it's a pre-flight request
219 4
        $this->logDebug('Request is identified as a pre-flight CORS request.');
220
221
        /** @var string $requestMethod */
222
223
        // #6.2.4
224 4
        $lcRequestHeaders = $this->getRequestedHeadersInLowerCase($request);
225
226
        // #6.2.5
227 4
        if ($this->strategy->isRequestMethodSupported($requestMethod) === false) {
0 ignored issues
show
Security Bug introduced by
It seems like $requestMethod defined by reset($requestMethod) on line 216 can also be of type false; however, Neomerx\Cors\Contracts\A...equestMethodSupported() does only seem to accept string, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
228 1
            $this->logInfo(
229 1
                'Request method is not supported. Check config settings for Allowed Methods.',
230 1
                ['method' => $requestMethod]
231
            );
232 1
            return $this->createResult(AnalysisResultInterface::ERR_METHOD_NOT_SUPPORTED);
233
        }
234
235
        // #6.2.6
236 3
        if ($this->strategy->isRequestAllHeadersSupported($lcRequestHeaders) === false) {
237 1
            return $this->createResult(AnalysisResultInterface::ERR_HEADERS_NOT_SUPPORTED);
238
        }
239
240
        // pre-flight response headers
241 2
        $headers = $this->createPreFlightResponseHeaders($request, $requestOrigin, $requestMethod, $lcRequestHeaders);
0 ignored issues
show
Security Bug introduced by
It seems like $requestMethod defined by reset($requestMethod) on line 216 can also be of type false; however, Neomerx\Cors\Analyzer::c...FlightResponseHeaders() does only seem to accept string, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
242
243 2
        return $this->createResult(AnalysisResultInterface::TYPE_PRE_FLIGHT_REQUEST, $headers);
244
    }
245
246
    /**
247
     * @param RequestInterface $request
248
     * @param string           $requestOrigin
249
     * @param string           $requestMethod
250
     * @param array            $lcRequestHeaders
251
     *
252
     * @return array
253
     */
254 2
    protected function createPreFlightResponseHeaders(
255
        RequestInterface $request,
256
        string $requestOrigin,
257
        string $requestMethod,
258
        array  $lcRequestHeaders
259
    ): array {
260 2
        $headers = [];
261
262
        // #6.2.7
263 2
        $headers[CorsResponseHeaders::ALLOW_ORIGIN] = $requestOrigin;
264 2
        if ($this->strategy->isRequestCredentialsSupported($request) === true) {
265 2
            $headers[CorsResponseHeaders::ALLOW_CREDENTIALS] = CorsResponseHeaders::VALUE_ALLOW_CREDENTIALS_TRUE;
266
        }
267
        // #6.4
268 2
        $headers[CorsResponseHeaders::VARY] = CorsRequestHeaders::ORIGIN;
269
270
        // #6.2.8
271 2
        if ($this->strategy->isPreFlightCanBeCached($request) === true) {
272 2
            $headers[CorsResponseHeaders::MAX_AGE] = $this->strategy->getPreFlightCacheMaxAge($request);
273
        }
274
275
        // #6.2.9
276 2
        $isSimpleMethod = isset(static::SIMPLE_METHODS[$requestMethod]);
277 2
        if ($isSimpleMethod === false || $this->strategy->isForceAddAllowedMethodsToPreFlightResponse() === true) {
278 2
            $headers[CorsResponseHeaders::ALLOW_METHODS] = $this->strategy->getRequestAllowedMethods($request);
279
        }
280
281
        // #6.2.10
282
        // Has only 'simple' headers excluding Content-Type
283 2
        $isSimpleExclCT = empty(array_diff($lcRequestHeaders, static::SIMPLE_LC_HEADERS_EXCLUDING_CONTENT_TYPE));
284 2
        if ($isSimpleExclCT === false || $this->strategy->isForceAddAllowedHeadersToPreFlightResponse() === true) {
285 2
            $headers[CorsResponseHeaders::ALLOW_HEADERS] = $this->strategy->getRequestAllowedHeaders($request);
286
        }
287
288 2
        return $headers;
289
    }
290
291
    /**
292
     * @param RequestInterface $request
293
     *
294
     * @return string[]
295
     */
296 4
    protected function getRequestedHeadersInLowerCase(RequestInterface $request): array
297
    {
298 4
        $requestHeaders = [];
299
300 4
        foreach ($request->getHeader(CorsRequestHeaders::HEADERS) as $headersList) {
301 3
            $headersList = strtolower($headersList);
302 3
            foreach (explode(CorsRequestHeaders::HEADERS_SEPARATOR, $headersList) as $header) {
303
                // after explode header names might have spaces in the beginnings and ends so trim them
304 3
                $header = trim($header);
305 3
                if (empty($header) === false) {
306 3
                    $requestHeaders[] = $header;
307
                }
308
            }
309
        }
310
311 4
        return $requestHeaders;
312
    }
313
314
    /**
315
     * @param RequestInterface $request
316
     *
317
     * @return string
318
     */
319 13
    protected function getOriginHeader(RequestInterface $request): string
320
    {
321 13
        if ($request->hasHeader(CorsRequestHeaders::ORIGIN) === true) {
322 13
            $header = $request->getHeader(CorsRequestHeaders::ORIGIN);
323 13
            if (empty($header) === false) {
324 12
                return reset($header);
325
            }
326
        }
327
328 1
        return '';
329
    }
330
331
    /**
332
     * @param RequestInterface $request
333
     *
334
     * @return bool
335
     */
336 14
    protected function checkIsSameHost(RequestInterface $request): bool
337
    {
338 14
        $serverOriginHost = $this->strategy->getServerOriginHost();
339 14
        $serverOriginPort = $this->strategy->getServerOriginPort();
340
341 14
        $host = $this->getRequestHostHeader($request);
342
343
        // parse `Host` header
344
        //
345
        // According to https://tools.ietf.org/html/rfc7230#section-5.4 `Host` header could be
346
        //
347
        //                     "uri-host" OR "uri-host:port"
348
        //
349
        // `parse_url` function thinks the first value is `path` and the second is `host` with `port`
350
        // which is a bit annoying so...
351 14
        $portOrNull = parse_url($host, PHP_URL_PORT);
352 14
        $hostUrl    = $portOrNull === null ? $host : parse_url($host, PHP_URL_HOST);
353
354
        // Neither MDN, nor RFC tell anything definitive about Host header comparison.
355
        // Browsers such as Firefox and Chrome do not add the optional port for
356
        // HTTP (80) and HTTPS (443).
357
        // So we require port match only if it specified in settings.
358
359 14
        $isHostUrlMatch = strcasecmp($serverOriginHost, $hostUrl) === 0;
360
        $isSameHost     =
361 14
            $isHostUrlMatch === true &&
362 14
            ($serverOriginPort === null || $serverOriginPort === $portOrNull);
363
364 14
        if ($isSameHost === false) {
365 1
            $this->logInfo(
366
                'Host header in request either absent or do not match server origin. ' .
367 1
                'Check config settings for Server Origin and Host Check.',
368 1
                ['host' => $host, 'server_origin_host' => $serverOriginHost, 'server_origin_port' => $serverOriginPort]
369
            );
370
        }
371
372 14
        return $isSameHost;
373
    }
374
375
    /**
376
     * @param string $requestOrigin
377
     *
378
     * @return bool
379
     *
380
     * @see http://tools.ietf.org/html/rfc6454#section-5
381
     */
382 12
    protected function checkIsCrossOrigin(string $requestOrigin): bool
383
    {
384 12
        $parsedUrl = parse_url($requestOrigin);
385 12
        if ($parsedUrl === false) {
386 1
            $this->logWarning('Request origin header URL cannot be parsed.', ['url' => $requestOrigin]);
387
388 1
            return false;
389
        }
390
391
        // check `host` parts
392 11
        $requestOriginHost = $parsedUrl['host'] ?? '';
393 11
        $serverOriginHost  = $this->strategy->getServerOriginHost();
394 11
        if (strcasecmp($requestOriginHost, $serverOriginHost) !== 0) {
395 8
            return true;
396
        }
397
398
        // check `port` parts
399 3
        $requestOriginPort = array_key_exists('port', $parsedUrl) === true ? (int)$parsedUrl['port'] : null;
400 3
        $serverOriginPort  = $this->strategy->getServerOriginPort();
401 3
        if ($requestOriginPort !== $serverOriginPort) {
402 1
            return true;
403
        }
404
405
        // check `scheme` parts
406 2
        $requestOriginScheme = $parsedUrl['scheme'] ?? '';
407 2
        $serverOriginScheme  = $this->strategy->getServerOriginScheme();
408 2
        if (strcasecmp($requestOriginScheme, $serverOriginScheme) !== 0) {
409 1
            return true;
410
        }
411
412 1
        $this->logInfo(
413 1
            'Request is not CORS (request origin equals to server one).',
414
            [
415 1
                'request_origin'       => $requestOrigin,
416 1
                'server_origin_scheme' => $serverOriginScheme,
417 1
                'server_origin_host'   => $serverOriginHost,
418 1
                'server_origin_port'   => $serverOriginPort
419
            ]
420
        );
421
422 1
        return false;
423
    }
424
425
    /**
426
     * @param int   $type
427
     * @param array $headers
428
     *
429
     * @return AnalysisResultInterface
430
     */
431 14
    protected function createResult($type, array $headers = []): AnalysisResultInterface
432
    {
433 14
        return $this->factory->createAnalysisResult($type, $headers);
434
    }
435
436
    /**
437
     * @return FactoryInterface
438
     */
439 16
    protected static function getFactory(): FactoryInterface
440
    {
441
        /** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
442 16
        return new \Neomerx\Cors\Factory\Factory();
443
    }
444
445
    /**
446
     * @param RequestInterface $request
447
     *
448
     * @return null|string
0 ignored issues
show
Documentation introduced by
Should the return type not be string|false?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
449
     */
450 14
    private function getRequestHostHeader(RequestInterface $request): ?string
451
    {
452 14
        $hostHeaderValue = $request->getHeader(CorsRequestHeaders::HOST);
453 14
        $host            = empty($hostHeaderValue) === true ? null : reset($hostHeaderValue);
454
455 14
        return $host;
456
    }
457
}
458