diff --git a/app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/AllowCredentialsHeaderProvider.php b/app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/AllowCredentialsHeaderProvider.php new file mode 100755 index 0000000000000..d22dad79a59bf --- /dev/null +++ b/app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/AllowCredentialsHeaderProvider.php @@ -0,0 +1,85 @@ +corsConfiguration = $corsConfiguration; + $this->headerName = $headerName; + $this->requestValidator = $requestValidator; + } + + /** + * Get name of header + * + * @return string + */ + public function getName(): string + { + return $this->headerName; + } + + /** + * Check if header can be applied + * + * @return bool + */ + public function canApply(): bool + { + return $this->requestValidator->isOriginAllowed() && $this->corsConfiguration->isCredentialsAllowed(); + } + + /** + * Get value for header + * + * @return string + */ + public function getValue(): string + { + return self::ALLOW_CREDENTIALS; + } +} diff --git a/app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/AllowHeadersHeaderProvider.php b/app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/AllowHeadersHeaderProvider.php new file mode 100755 index 0000000000000..b8c7de1797241 --- /dev/null +++ b/app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/AllowHeadersHeaderProvider.php @@ -0,0 +1,82 @@ +corsConfiguration = $corsConfiguration; + $this->headerName = $headerName; + $this->requestValidator = $requestValidator; + } + + /** + * Get name of header + * + * @return string + */ + public function getName(): string + { + return $this->headerName; + } + + /** + * Check if header can be applied + * + * @return bool + */ + public function canApply(): bool + { + return $this->requestValidator->isOriginAllowed() && $this->getValue(); + } + + /** + * Get value for header + * + * @return string + */ + public function getValue(): string + { + return $this->corsConfiguration->getAllowedHeaders() + ? implode(',', $this->corsConfiguration->getAllowedHeaders()) + : ''; + } +} diff --git a/app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/AllowMethodsHeaderProvider.php b/app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/AllowMethodsHeaderProvider.php new file mode 100755 index 0000000000000..50c41534c8de9 --- /dev/null +++ b/app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/AllowMethodsHeaderProvider.php @@ -0,0 +1,87 @@ +corsConfiguration = $corsConfiguration; + $this->headerName = $headerName; + $this->requestValidator = $requestValidator; + } + + /** + * Get name of header + * + * @return string + */ + public function getName(): string + { + return $this->headerName; + } + + /** + * Check if header can be applied + * + * @return bool + */ + public function canApply(): bool + { + return $this->requestValidator->isOriginAllowed() && $this->getValue(); + } + + /** + * Get value for header + * + * @return string + */ + public function getValue(): string + { + return $this->corsConfiguration->getAllowedMethods() + ? implode(',', $this->corsConfiguration->getAllowedMethods()) + : self::GRAPHQL_CORS_ALLOWED_METHODS; + } +} diff --git a/app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/AllowOriginHeaderProvider.php b/app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/AllowOriginHeaderProvider.php new file mode 100755 index 0000000000000..da484b144d6c8 --- /dev/null +++ b/app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/AllowOriginHeaderProvider.php @@ -0,0 +1,94 @@ +corsConfiguration = $corsConfiguration; + $this->headerName = $headerName; + $this->request = $request; + $this->requestValidator = $requestValidator; + } + + /** + * Get name of header + * + * @return string + */ + public function getName(): string + { + return $this->headerName; + } + + /** + * Check if header can be applied + * + * @return bool + */ + public function canApply(): bool + { + return $this->requestValidator->isOriginAllowed() && $this->getValue(); + } + + public function getValue(): string + { + return $this->isAllOriginsAllowed() ? '*' : $this->request->getHeader('Origin'); + } + + /** + * if '*' is present, allow all origins + * + * @return bool + */ + private function isAllOriginsAllowed(): bool + { + return in_array('*', $this->corsConfiguration->getAllowedOrigins()); + } +} diff --git a/app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/MaxAgeHeaderProvider.php b/app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/MaxAgeHeaderProvider.php new file mode 100755 index 0000000000000..b7da996f72ce5 --- /dev/null +++ b/app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/MaxAgeHeaderProvider.php @@ -0,0 +1,85 @@ +corsConfiguration = $corsConfiguration; + $this->headerName = $headerName; + $this->requestValidator = $requestValidator; + } + + /** + * Get name of header + * + * @return string + */ + public function getName(): string + { + return $this->headerName; + } + + /** + * Check if header can be applied + * + * @return bool + */ + public function canApply(): bool + { + return $this->requestValidator->isOriginAllowed() && $this->getValue(); + } + + /** + * Get value for header + * + * @return string + */ + public function getValue(): string + { + return $this->corsConfiguration->getMaxAge() ?? self::GRAPH_CORS_MAX_AGE_DEFAULT; + } +} diff --git a/app/code/Magento/GraphQl/Model/Cors/ConfigurationProvider.php b/app/code/Magento/GraphQl/Model/Cors/ConfigurationProvider.php new file mode 100755 index 0000000000000..abc476483bfc0 --- /dev/null +++ b/app/code/Magento/GraphQl/Model/Cors/ConfigurationProvider.php @@ -0,0 +1,134 @@ +scopeConfig = $scopeConfig; + } + + /** + * returns the list of allowed origins + * + * @return array|null + */ + public function getAllowedOrigins(): ?array + { + return $this->processOptions($this->scopeConfig->getValue(self::GRAPHQL_CORS_ALLOWED_ORIGINS)); + } + + /** + * returns the list of allowed headers + * + * @return array|null + */ + public function getAllowedHeaders(): ?array + { + return $this->processOptions($this->scopeConfig->getValue(self::GRAPHQL_CORS_ALLOWED_HEADERS)); + } + + /** + * returns the list of allowed methods + * + * @return array|null + */ + public function getAllowedMethods(): ?array + { + return $this->processOptions($this->scopeConfig->getValue(self::GRAPHQL_CORS_ALLOWED_METHODS)); + } + + /** + * returns CORS max age value + * + * @return string + */ + public function getMaxAge(): string + { + return $this->scopeConfig->getValue(self::GRAPHQL_CORS_MAX_AGE); + } + + /** + * returns credentials value + * + * @return bool + */ + public function isCredentialsAllowed(): bool + { + return $this->scopeConfig->isSetFlag(self::GRAPHQL_CORS_ALLOW_CREDENTIALS); + } + + /** + * converts the comma separated values into array + * + * @param $option + * @param string $delimiter + * @return array + */ + public function processOptions($option, $delimiter = ','): array + { + //if no config is provided in env.php + if(!$option){ + $option = ''; + } + + $configurations = explode($delimiter, $option); + $trimmedConfigurations = array_map( + function ($trimmedConfiguration) { + return trim($trimmedConfiguration); + }, + $configurations + ); + + $trimmedConfigurations = array_values(array_filter($trimmedConfigurations, function ($trimmedConfiguration) { + return !empty($trimmedConfiguration); + })); + + return $trimmedConfigurations; + } +} diff --git a/app/code/Magento/GraphQl/Model/Cors/ConfigurationProviderInterface.php b/app/code/Magento/GraphQl/Model/Cors/ConfigurationProviderInterface.php new file mode 100755 index 0000000000000..77a2b33b4de43 --- /dev/null +++ b/app/code/Magento/GraphQl/Model/Cors/ConfigurationProviderInterface.php @@ -0,0 +1,47 @@ +configuration = $configuration; + $this->request = $request; + } + + /** + * Determines whether the requested origin is present in configuration + * + * @return bool + */ + private function isOriginExistsInConfiguration(): bool + { + return in_array($this->request->getHeader('Origin'), $this->configuration->getAllowedOrigins()); + } + + /** + * Determines whether all origins should be allowed + * + * @return bool + */ + private function isAllOriginsAllowed(): bool + { + return in_array('*', $this->configuration->getAllowedOrigins()); + } + + /** + * Determines whether the request is valid and applies CORS headers + * @return bool + */ + public function isOriginAllowed(): bool + { + if ($this->request instanceof HttpRequest) { + + if (!$this->originHeaderExists()) { + return false; + } + + return $this->isAllOriginsAllowed() || $this->isOriginExistsInConfiguration(); + } + + return false; + } + + /** + * Determines whether an origin header exists + * @return bool + */ + private function originHeaderExists(): bool + { + try { + return $this->request->getHeader('Origin') ? true : false; + } catch (\Exception $exception) { + return false; + } + } +} diff --git a/app/code/Magento/GraphQl/Model/Cors/Validator/RequestValidatorInterface.php b/app/code/Magento/GraphQl/Model/Cors/Validator/RequestValidatorInterface.php new file mode 100755 index 0000000000000..8c66626a40cc4 --- /dev/null +++ b/app/code/Magento/GraphQl/Model/Cors/Validator/RequestValidatorInterface.php @@ -0,0 +1,20 @@ +300 + + + Access-Control-Max-Age + + + + + Access-Control-Allow-Credentials + + + + + Access-Control-Allow-Headers + + + + + Access-Control-Allow-Methods + + + + + Access-Control-Allow-Origin + + diff --git a/app/code/Magento/GraphQl/etc/graphql/di.xml b/app/code/Magento/GraphQl/etc/graphql/di.xml index 77fce336374dd..31c798b0505fd 100644 --- a/app/code/Magento/GraphQl/etc/graphql/di.xml +++ b/app/code/Magento/GraphQl/etc/graphql/di.xml @@ -30,4 +30,19 @@ + + + + + + Magento\GraphQl\Controller\Cors\HttpResponseHeaderProvider\AllowHeadersHeaderProvider + Magento\GraphQl\Controller\Cors\HttpResponseHeaderProvider\AllowOriginHeaderProvider + Magento\GraphQl\Controller\Cors\HttpResponseHeaderProvider\AllowMethodsHeaderProvider + Magento\GraphQl\Controller\Cors\HttpResponseHeaderProvider\MaxAgeHeaderProvider + Magento\GraphQl\Controller\Cors\HttpResponseHeaderProvider\AllowCredentialsHeaderProvider + + + diff --git a/dev/tests/integration/testsuite/Magento/GraphQl/Controller/CorsGraphQlTest.php b/dev/tests/integration/testsuite/Magento/GraphQl/Controller/CorsGraphQlTest.php new file mode 100755 index 0000000000000..96e550d4881c9 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/GraphQl/Controller/CorsGraphQlTest.php @@ -0,0 +1,141 @@ +addHeaders([ + 'Origin' => $origin, + 'Content-Type' => 'application/json' + ]); + return $httpHeaders; + } + + /** + * Makes the GraphQl request + * + * @param string $origin + * @param string $method + * @return void + */ + private function addOriginAndSendGraphQlRequest(string $origin, string $method = 'POST'): void + { + $this->getRequest()->setMethod($method) + ->setHeaders($this->getHeadersForGraphQlRequest($origin)) + ->setContent('{"query": "{categoryList{name, id }}"}'); + + $this->dispatch('/graphql'); + } + + /** + * @magentoConfigFixture default/web/graphql/cors_allowed_origins https://www.example.com + * @magentoConfigFixture default/web/graphql/cors_allowed_headers Content-Type + * @magentoConfigFixture default/web/graphql/cors_allowed_methods GET,POST,OPTIONS + * @magentoConfigFixture default/web/graphql/cors_max_age 86400 + * @magentoConfigFixture default/web/graphql/cors_allow_credentials 1 + */ + public function testIsCorsHeadersPresentInGraphQlResponse() + { + $this->addOriginAndSendGraphQlRequest('https://www.example.com'); + $response = $this->getResponse(); + + $this->assertNotFalse($response->getHeader('Access-Control-Allow-Origin')); + $this->assertNotFalse($response->getHeader('Access-Control-Allow-Headers')); + $this->assertNotFalse($response->getHeader('Access-Control-Allow-Methods')); + $this->assertNotFalse($response->getHeader('Access-Control-Max-Age')); + + $result = json_decode($response->getContent(), true); + + $this->assertArrayNotHasKey("error", $result); + $this->assertEquals("Default Category", $result['data']['categoryList'][0]['name']); + $this->assertEquals(2, $result['data']['categoryList'][0]['id']); + } + + /** + * @magentoConfigFixture default/web/graphql/cors_allowed_origins https://www.example.com + * @magentoConfigFixture default/web/graphql/cors_allowed_headers Content-Type + * @magentoConfigFixture default/web/graphql/cors_allowed_methods GET,POST,OPTIONS + * @magentoConfigFixture default/web/graphql/cors_max_age 86400 + * @magentoConfigFixture default/web/graphql/cors_allow_credentials 1 + */ + public function testNormalRequestDoesNotContainsCorsHeaders() + { + $httpHeaders = new Headers(); + $httpHeaders->addHeaderLine('Origin: https://www.example.com'); + $this->dispatch('/'); + + $response = $this->getResponse(); + $this->assertFalse($response->getHeader('Access-Control-Allow-Origin')); + $this->assertFalse($response->getHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->getHeader('Access-Control-Allow-Methods')); + $this->assertFalse($response->getHeader('Access-Control-Max-Age')); + } + + /** + * @magentoConfigFixture default/web/graphql/cors_allowed_origins https://www.example.com + * @magentoConfigFixture default/web/graphql/cors_allowed_headers Content-Type + * @magentoConfigFixture default/web/graphql/cors_allowed_methods GET,POST,OPTIONS + * @magentoConfigFixture default/web/graphql/cors_max_age 86400 + * @magentoConfigFixture default/web/graphql/cors_allow_credentials 1 + */ + public function testCorsNotAddedIfOriginIsNotAllowed() + { + $this->addOriginAndSendGraphQlRequest('https://www.test.com'); + $response = $this->getResponse(); + $this->assertFalse($response->getHeader('Access-Control-Allow-Origin')); + $this->assertFalse($response->getHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->getHeader('Access-Control-Allow-Methods')); + $this->assertFalse($response->getHeader('Access-Control-Max-Age')); + } + + public function testCorsRequestFailsIfCorsConfigurationIsNotProvided() + { + $this->addOriginAndSendGraphQlRequest('https://www.example.com'); + $response = $this->getResponse(); + $this->assertFalse($response->getHeader('Access-Control-Allow-Origin')); + $this->assertFalse($response->getHeader('Access-Control-Allow-Headers')); + $this->assertFalse($response->getHeader('Access-Control-Allow-Methods')); + $this->assertFalse($response->getHeader('Access-Control-Max-Age')); + } + + /** + * @magentoConfigFixture default/web/graphql/cors_allowed_origins https://www.example.com + * @magentoConfigFixture default/web/graphql/cors_allowed_headers Content-Type + * @magentoConfigFixture default/web/graphql/cors_allowed_methods GET,POST,OPTIONS + * @magentoConfigFixture default/web/graphql/cors_max_age 86400 + * @magentoConfigFixture default/web/graphql/cors_allow_credentials 1 + */ + public function testIsCorsHeadersPresentInGraphQlOptionsResponse() + { + $this->addOriginAndSendGraphQlRequest('https://www.example.com', 'OPTIONS'); + + $response = $this->getResponse(); + $this->assertNotFalse($response->getHeader('Access-Control-Allow-Origin')); + $this->assertNotFalse($response->getHeader('Access-Control-Allow-Headers')); + $this->assertNotFalse($response->getHeader('Access-Control-Allow-Methods')); + $this->assertNotFalse($response->getHeader('Access-Control-Max-Age')); + + $result = json_decode($response->getBody(), true); + $this->assertArrayNotHasKey("error", $result); + } +}