From b5bb41f01aa33b40c051392f8f4e81891fcede95 Mon Sep 17 00:00:00 2001 From: Arvind Date: Tue, 29 Dec 2020 14:33:06 +0530 Subject: [PATCH 1/3] cors support --- .../AllowCredentialsHeaderProvider.php | 85 +++++++++++ .../AllowHeadersHeaderProvider.php | 82 +++++++++++ .../AllowMethodsHeaderProvider.php | 87 ++++++++++++ .../AllowOriginHeaderProvider.php | 94 ++++++++++++ .../MaxAgeHeaderProvider.php | 85 +++++++++++ .../Model/Cors/ConfigurationProvider.php | 134 ++++++++++++++++++ .../Cors/ConfigurationProviderInterface.php | 47 ++++++ .../Model/Cors/Validator/RequestValidator.php | 94 ++++++++++++ .../Validator/RequestValidatorInterface.php | 20 +++ .../Test/Integration/CorsGraphQlTest.php | 132 +++++++++++++++++ app/code/Magento/GraphQl/etc/di.xml | 25 ++++ app/code/Magento/GraphQl/etc/graphql/di.xml | 15 ++ 12 files changed, 900 insertions(+) create mode 100755 app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/AllowCredentialsHeaderProvider.php create mode 100755 app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/AllowHeadersHeaderProvider.php create mode 100755 app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/AllowMethodsHeaderProvider.php create mode 100755 app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/AllowOriginHeaderProvider.php create mode 100755 app/code/Magento/GraphQl/Controller/Cors/HttpResponseHeaderProvider/MaxAgeHeaderProvider.php create mode 100755 app/code/Magento/GraphQl/Model/Cors/ConfigurationProvider.php create mode 100755 app/code/Magento/GraphQl/Model/Cors/ConfigurationProviderInterface.php create mode 100755 app/code/Magento/GraphQl/Model/Cors/Validator/RequestValidator.php create mode 100755 app/code/Magento/GraphQl/Model/Cors/Validator/RequestValidatorInterface.php create mode 100755 app/code/Magento/GraphQl/Test/Integration/CorsGraphQlTest.php 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 @@ +addHeaders([ + 'Origin' => $origin, + 'Content-Type' => 'application/json' + ]); + return $httpHeaders; + } + + /** + * Returns GraphQl query string + * + * @return string + */ + private function getQuery(): string + { + return <<getRequest()->setMethod('POST') + ->setHeaders($this->getHeadersForGraphQlRequest($origin)) + ->setContent($this->getQuery()); + $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')); + } + + /** + * @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')); + } +} diff --git a/app/code/Magento/GraphQl/etc/di.xml b/app/code/Magento/GraphQl/etc/di.xml index 865e8f223db54..d2dc83aae5915 100644 --- a/app/code/Magento/GraphQl/etc/di.xml +++ b/app/code/Magento/GraphQl/etc/di.xml @@ -103,4 +103,29 @@ 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 + + + From 152ff3744c9824e19c6c51b4f0c5feacd17a0655 Mon Sep 17 00:00:00 2001 From: Arvind Date: Wed, 13 Jan 2021 10:17:56 +0530 Subject: [PATCH 2/3] refactored integration test files --- .../testsuite/Magento/GraphQl/Controller}/CorsGraphQlTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename {app/code/Magento/GraphQl/Test/Integration => dev/tests/integration/testsuite/Magento/GraphQl/Controller}/CorsGraphQlTest.php (99%) diff --git a/app/code/Magento/GraphQl/Test/Integration/CorsGraphQlTest.php b/dev/tests/integration/testsuite/Magento/GraphQl/Controller/CorsGraphQlTest.php similarity index 99% rename from app/code/Magento/GraphQl/Test/Integration/CorsGraphQlTest.php rename to dev/tests/integration/testsuite/Magento/GraphQl/Controller/CorsGraphQlTest.php index da86f74a81dd2..92c1a917777bf 100755 --- a/app/code/Magento/GraphQl/Test/Integration/CorsGraphQlTest.php +++ b/dev/tests/integration/testsuite/Magento/GraphQl/Controller/CorsGraphQlTest.php @@ -5,7 +5,7 @@ */ declare(strict_types=1); -namespace Magento\GraphQl\Test\Integration; +namespace Magento\GraphQl\Controller; use Magento\Framework\App\Response\Http; use Magento\TestFramework\TestCase\AbstractController as ControllerTestCase; From 849f053de749b578ebda01d9bd96993fdcb6fe38 Mon Sep 17 00:00:00 2001 From: Arvind Date: Mon, 18 Jan 2021 11:19:03 +0530 Subject: [PATCH 3/3] added assertions --- .../GraphQl/Controller/CorsGraphQlTest.php | 65 +++++++++++-------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/dev/tests/integration/testsuite/Magento/GraphQl/Controller/CorsGraphQlTest.php b/dev/tests/integration/testsuite/Magento/GraphQl/Controller/CorsGraphQlTest.php index 92c1a917777bf..96e550d4881c9 100755 --- a/dev/tests/integration/testsuite/Magento/GraphQl/Controller/CorsGraphQlTest.php +++ b/dev/tests/integration/testsuite/Magento/GraphQl/Controller/CorsGraphQlTest.php @@ -7,9 +7,8 @@ namespace Magento\GraphQl\Controller; -use Magento\Framework\App\Response\Http; -use Magento\TestFramework\TestCase\AbstractController as ControllerTestCase; use Laminas\Http\Headers; +use Magento\TestFramework\TestCase\AbstractController as ControllerTestCase; /** * Validates the headers for Graphql CORS requests @@ -32,37 +31,19 @@ private function getHeadersForGraphQlRequest($origin = 'https://example.com'): H return $httpHeaders; } - /** - * Returns GraphQl query string - * - * @return string - */ - private function getQuery(): string - { - return <<getRequest()->setMethod('POST') + $this->getRequest()->setMethod($method) ->setHeaders($this->getHeadersForGraphQlRequest($origin)) - ->setContent($this->getQuery()); + ->setContent('{"query": "{categoryList{name, id }}"}'); + $this->dispatch('/graphql'); } @@ -75,12 +56,19 @@ private function addOriginAndSendGraphQlRequest(string $origin): void */ public function testIsCorsHeadersPresentInGraphQlResponse() { - $this->addOriginAndSendGraphQlRequest("https://www.example.com"); + $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']); } /** @@ -112,7 +100,7 @@ public function testNormalRequestDoesNotContainsCorsHeaders() */ public function testCorsNotAddedIfOriginIsNotAllowed() { - $this->addOriginAndSendGraphQlRequest("https://www.test.com"); + $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')); @@ -122,11 +110,32 @@ public function testCorsNotAddedIfOriginIsNotAllowed() public function testCorsRequestFailsIfCorsConfigurationIsNotProvided() { - $this->addOriginAndSendGraphQlRequest("https://www.example.com"); + $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); + } }