Completed
Branch develop (004e03)
by Neomerx
04:43
created

Analyzer::checkIsSameHost()   A

Complexity

Conditions 5
Paths 12

Size

Total Lines 38

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 5

Importance

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