diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 8695190..4dc1dad 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -26,6 +26,46 @@ public function getConfigTreeBuilder(): TreeBuilder }) ->end() ->end() + ->arrayNode('cors') + ->addDefaultsIfNotSet() + ->canBeEnabled() + ->children() + ->arrayNode('allow_origins') + ->scalarPrototype()->end() + ->beforeNormalization() + ->always(function ($value) { + return (array) $value; + }) + ->end() + ->end() + ->variableNode('allow_methods') + ->defaultTrue() + ->beforeNormalization() + ->always(function ($value) { + return $value === true ? true : (array) $value; + }) + ->end() + ->end() + ->variableNode('allow_headers') + ->defaultTrue() + ->beforeNormalization() + ->always(function ($value) { + return $value === true ? true : (array) $value; + }) + ->end() + ->end() + ->variableNode('expose_headers') + ->defaultTrue() + ->beforeNormalization() + ->always(function ($value) { + return $value === true ? true : (array) $value; + }) + ->end() + ->end() + ->booleanNode('allow_credentials')->defaultTrue()->end() + ->integerNode('maximum_age')->defaultNull()->end() + ->end() + ->end() ->arrayNode('limit_default_options') ->children() ->integerNode('default_limit')->end() diff --git a/DependencyInjection/VanioApiExtension.php b/DependencyInjection/VanioApiExtension.php index 066e589..3a6e9f3 100644 --- a/DependencyInjection/VanioApiExtension.php +++ b/DependencyInjection/VanioApiExtension.php @@ -22,11 +22,12 @@ public function load(array $configs, ContainerBuilder $container): void $subscribers = [ 'format_listener' => 'vanio_api.request.format_listener', 'request_body_listener' => 'vanio_api.request.request_body_listener', + 'cors' => 'vanio_api.request.cors_listener', 'access_denied_listener' => 'vanio_api.security.access_denied_listener', ]; foreach ($subscribers as $name => $id) { - if ($config[$name]) { + if ($config[$name] && !isset($config[$name]['enabled']) || $config[$name]['enabled']) { $container->getDefinition($id)->setAbstract(false)->addTag('kernel.event_subscriber'); } } diff --git a/Request/CorsListener.php b/Request/CorsListener.php new file mode 100644 index 0000000..0da9075 --- /dev/null +++ b/Request/CorsListener.php @@ -0,0 +1,199 @@ +allowedOrigins = $allowedOrigins; + $this->allowedMethods = $this->allowedMethods === true + ? array_map('strtoupper', $allowedMethods) + : self::METHODS; + $this->allowedHeaders = $allowedHeaders; + $this->areCredentialsAllowed = $areCredentialsAllowed; + $this->maximumAge = $maximumAge; + } + + /** + * @return mixed[] + */ + public static function getSubscribedEvents(): array + { + return [KernelEvents::REQUEST => ['onRequest', 1024]]; + } + + /** + * @internal + */ + public function onRequest( + GetResponseEvent $event, + string $eventName, + EventDispatcherInterface $eventDispatcher + ): void { + if (!$event->isMasterRequest()) { + return; + } + + $request = $event->getRequest(); + $origin = $request->headers->get('Origin'); + + if ($origin === null || $origin === $request->getSchemeAndHttpHost()) { + return; + } elseif ($request->isMethod('OPTIONS')) { + $event->setResponse($this->createPreflightResponse($request)); + + return; + } elseif ($this->checkOrigin($request)) { + $eventDispatcher->addListener(KernelEvents::RESPONSE, [$this, 'onResponse']); + } + } + + /** + * @internal + */ + public function onResponse( + FilterResponseEvent $event, + string $eventName, + EventDispatcherInterface $eventDispatcher + ): void { + if (!$event->isMasterRequest()) { + return; + } + + $eventDispatcher->removeListener($eventName, [$this, 'onResponse']); + $response = $event->getResponse(); + $response->headers->set('Access-Control-Allow-Origin', $event->getRequest()->headers->get('Origin')); + + if ($this->exposedHeaders) { + $response->headers->set('Access-Control-Expose-Headers', implode(', ', $this->exposedHeaders)); + } + + if ($this->areCredentialsAllowed) { + $response->headers->set('Access-Control-Allow-Credentials', 'true'); + } + } + + private function createPreflightResponse(Request $request): Response + { + $response = new Response; + $response->headers->set('Content-Type', 'text/plain'); + $headers = $request->headers->get('Access-Control-Request-Headers'); + + if ($allowedMethods = $this->allowedMethods) { + $response->headers->set('Access-Control-Allow-Methods', implode(', ', $allowedMethods)); + } + + if ($this->allowedHeaders) { + if ($allowedHeaders = $this->allowedHeaders === true ? $headers : implode(', ', $this->allowedHeaders)) { + $response->headers->set('Access-Control-Allow-Headers', $allowedHeaders); + } + } + + if ($this->areCredentialsAllowed) { + $response->headers->set('Access-Control-Allow-Credentials', 'true'); + } + + if ($this->maximumAge) { + $response->headers->set('Access-Control-Max-Age', $this->maximumAge); + } + + if (!$this->checkOrigin($request)) { + $response->headers->set('Access-Control-Allow-Origin', 'null'); + + return $response; + } + + $response->headers->set('Access-Control-Allow-Origin', $request->headers->get('Origin')); + $method = strtoupper($request->headers->get('Access-Control-Request-Method')); + + if (!in_array($method, $allowedMethods, true)) { + return $response->setStatusCode(405); + } elseif (!in_array($method, $allowedMethods, true)) { + $allowedMethods[] = $method; + $response->headers->set('Access-Control-Allow-Methods', implode(', ', $allowedMethods)); + } + + if ($this->allowedHeaders !== true && $headers) { + foreach (preg_split('~, *~', trim(strtolower($headers))) as $header) { + if (!in_array($header, self::SIMPLE_HEADERS, true)) { + continue; + } elseif (!in_array($header, $this->allowedHeaders, true)) { + $response + ->setStatusCode(400) + ->setContent(sprintf('Unauthorized header "%s".', $header)); + } + } + } + + return $response; + } + + private function checkOrigin(Request $request): bool + { + if ($this->allowedOrigins === true) { + return true; + } + + $origin = new Uri($request->headers->get('Origin')); + + foreach ((array) $this->allowedOrigins as $allowedOrigin) { + $allowedOrigin = Strings::contains($allowedOrigin, '//') + ? $allowedOrigin + : sprintf('//%s', $allowedOrigin); + $allowedOrigin = new Uri($allowedOrigin); + + if ( + $origin->host() === $allowedOrigin->host() + && (!$allowedOrigin->scheme() || $allowedOrigin->scheme() === $origin->scheme()) + ) { + return true; + } + } + + return false; + } +} diff --git a/Resources/config/config.xml b/Resources/config/config.xml index a3b853b..5e366a7 100644 --- a/Resources/config/config.xml +++ b/Resources/config/config.xml @@ -9,6 +9,7 @@ Vanio\ApiBundle\Form\EntityTypeExtension Vanio\ApiBundle\Request\FormatListener Vanio\ApiBundle\Request\RequestBodyListener + Vanio\ApiBundle\Request\CorsListener Vanio\ApiBundle\Request\LimitParamConverter Vanio\ApiBundle\Request\PropertiesParamConverter Vanio\ApiBundle\Request\FilterParamConverter @@ -76,6 +77,14 @@ %vanio_api.formats% + + %vanio_api.cors.allow_origins% + %vanio_api.cors.allow_methods% + %vanio_api.cors.allow_headers% + %vanio_api.cors.expose_headers% + %vanio_api.cors.allow_credentials% + + %vanio_api.limit_default_options% diff --git a/Tests/DependencyInjection/VanioApiExtensionTest.php b/Tests/DependencyInjection/VanioApiExtensionTest.php index 20bb80c..de7d704 100644 --- a/Tests/DependencyInjection/VanioApiExtensionTest.php +++ b/Tests/DependencyInjection/VanioApiExtensionTest.php @@ -18,6 +18,15 @@ function test_default_configuration() 'serializer_doctrine_type_mapping' => [], 'api_doc_type_mapping' => [], 'api_doc_request_with_credentials' => false, + 'cors' => [ + 'enabled' => false, + 'allow_origins' => [], + 'allow_methods' => true, + 'allow_headers' => true, + 'expose_headers' => true, + 'allow_credentials' => true, + 'maximum_age' => null, + ], ], $config); } } diff --git a/composer.json b/composer.json index cc3562f..48fac6f 100644 --- a/composer.json +++ b/composer.json @@ -12,11 +12,12 @@ "php": "^7.2", "jms/serializer-bundle": "^2.3.1", "symfony/framework-bundle": "^3.3.16", - "symfony/security-bundle": "^3.3.16" + "symfony/security-bundle": "^3.3.16", + "vanio/stdlib": "^0.1.0" }, "require-dev": { - "phpunit/phpunit": "^7.0", - "vanio/coding-standards": "^0.3" + "phpunit/phpunit": "^7.0.0", + "vanio/coding-standards": "^0.3.0" }, "suggest": { "vanio/vanio-web-bundle": "For API documentation", diff --git a/composer.lock b/composer.lock index e6e30c9..b6a0b02 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dab33ab39fb705a0e3cfa76301edc540", + "content-hash": "eab17e0c0211745b59926ca366e16677", "packages": [ { "name": "doctrine/annotations", @@ -1972,6 +1972,67 @@ "description": "Symfony SecurityBundle", "homepage": "https://symfony.com", "time": "2018-04-06T07:35:25+00:00" + }, + { + "name": "vanio/stdlib", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/vaniocz/stdlib.git", + "reference": "1eba532786307b892e2814db32d9e8b2c7cbb9e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vaniocz/stdlib/zipball/1eba532786307b892e2814db32d9e8b2c7cbb9e1", + "reference": "1eba532786307b892e2814db32d9e8b2c7cbb9e1", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^5.5", + "vanio/coding-standards": "^0.1@dev" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Vanio\\Stdlib\\": "src/" + }, + "exclude-from-classmap": [ + "/tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marek Štípek", + "email": "marek.stipek@vanio.cz" + }, + { + "name": "Adam Hojka" + } + ], + "description": "General purpose classes extending the PHP language.", + "homepage": "https://github.com/vaniocz/stdlib", + "keywords": [ + "enum", + "object utility", + "objects", + "standard library", + "string utility", + "strings", + "uri value object" + ], + "time": "2017-11-19T13:46:43+00:00" } ], "packages-dev": [ @@ -3379,12 +3440,12 @@ "source": { "type": "git", "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "53a28408d345044c0360c2c1b4a2aaebf4a3b8c9" + "reference": "b88026ff826ccbe7b77e8fcd6b9e0ffb77a7a39b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/53a28408d345044c0360c2c1b4a2aaebf4a3b8c9", - "reference": "53a28408d345044c0360c2c1b4a2aaebf4a3b8c9", + "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/b88026ff826ccbe7b77e8fcd6b9e0ffb77a7a39b", + "reference": "b88026ff826ccbe7b77e8fcd6b9e0ffb77a7a39b", "shasum": "" }, "require": { @@ -3422,7 +3483,7 @@ "phpcs", "standards" ], - "time": "2018-05-02T05:47:50+00:00" + "time": "2018-05-09T04:44:56+00:00" }, { "name": "theseer/tokenizer", @@ -3470,12 +3531,12 @@ "source": { "type": "git", "url": "https://github.com/vaniocz/coding-standards.git", - "reference": "6030ea1ad8b08f3c59cd942deed1810a55fc99c3" + "reference": "82db8e7b53e1db57085b858a868c81da98271d43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vaniocz/coding-standards/zipball/6030ea1ad8b08f3c59cd942deed1810a55fc99c3", - "reference": "6030ea1ad8b08f3c59cd942deed1810a55fc99c3", + "url": "https://api.github.com/repos/vaniocz/coding-standards/zipball/82db8e7b53e1db57085b858a868c81da98271d43", + "reference": "82db8e7b53e1db57085b858a868c81da98271d43", "shasum": "" }, "require": { @@ -3512,7 +3573,7 @@ "coding standards", "conventions" ], - "time": "2018-05-02T02:17:32+00:00" + "time": "2018-05-14T00:56:37+00:00" }, { "name": "webmozart/assert",