From d2dc99baf84dd968aa366b9f00a3c9f9b003f8c2 Mon Sep 17 00:00:00 2001 From: Nicolas Le Goff Date: Thu, 10 Apr 2014 17:40:56 +0200 Subject: [PATCH] PHRAS-58 Add CORS feature --- config/configuration.sample.yml | 9 + lib/Alchemy/Phrasea/Application/Api.php | 2 + .../Event/Subscriber/ApiCorsSubscriber.php | 207 ++++++++++++++++++ lib/conf.d/configuration.yml | 9 + .../Subscriber/ApiCorsSubscriberTest.php | 106 +++++++++ 5 files changed, 333 insertions(+) create mode 100644 lib/Alchemy/Phrasea/Core/Event/Subscriber/ApiCorsSubscriber.php create mode 100644 tests/Alchemy/Tests/Phrasea/Core/Event/Subscriber/ApiCorsSubscriberTest.php diff --git a/config/configuration.sample.yml b/config/configuration.sample.yml index 153482aea3..ff17d20561 100644 --- a/config/configuration.sample.yml +++ b/config/configuration.sample.yml @@ -142,3 +142,12 @@ h264-pseudo-streaming: type: nginx mapping: [] plugins: [] +api_cors: + enabled: false + allow_credentials: false + allow_origin: [] + allow_headers: [] + allow_methods: [] + expose_headers: [] + max_age: 0 + hosts: [] diff --git a/lib/Alchemy/Phrasea/Application/Api.php b/lib/Alchemy/Phrasea/Application/Api.php index 431c81bba3..d51c6cd070 100644 --- a/lib/Alchemy/Phrasea/Application/Api.php +++ b/lib/Alchemy/Phrasea/Application/Api.php @@ -12,6 +12,7 @@ namespace Alchemy\Phrasea\Application; use Alchemy\Phrasea\Application as PhraseaApplication; +use Alchemy\Phrasea\Core\Event\Subscriber\ApiCorsSubscriber; use Alchemy\Phrasea\Core\PhraseaEvents; use Alchemy\Phrasea\Controller\Api\Oauth2; use Alchemy\Phrasea\Controller\Api\V1; @@ -70,6 +71,7 @@ return call_user_func(function ($environment = PhraseaApplication::ENV_PROD) { $app->mount('/api/v1', new V1()); $app['dispatcher']->addSubscriber(new ApiOauth2ErrorsSubscriber($app['phraseanet.exception_handler'])); + $app['dispatcher']->addSubscriber(new ApiCorsSubscriber($app)); $app['dispatcher']->dispatch(PhraseaEvents::API_LOAD_END, new ApiLoadEndEvent()); return $app; diff --git a/lib/Alchemy/Phrasea/Core/Event/Subscriber/ApiCorsSubscriber.php b/lib/Alchemy/Phrasea/Core/Event/Subscriber/ApiCorsSubscriber.php new file mode 100644 index 0000000000..f1f4ab2e3b --- /dev/null +++ b/lib/Alchemy/Phrasea/Core/Event/Subscriber/ApiCorsSubscriber.php @@ -0,0 +1,207 @@ +app = $app; + } + + public static function getSubscribedEvents() + { + return array( + KernelEvents::REQUEST => array('onKernelRequest', 128), + ); + } + + public function onKernelRequest(GetResponseEvent $event) + { + if (!$this->app['phraseanet.configuration']['api_cors']['enabled']) { + return; + } + if (HttpKernelInterface::MASTER_REQUEST !== $event->getRequestType()) { + return; + } + $request = $event->getRequest(); + + if (!preg_match('{api/v(\d+)}i', $request->getPathInfo() ?: '/')) { + return; + } + + // skip if not a CORS request + if (!$request->headers->has('Origin') || $request->headers->get('Origin') == $request->getSchemeAndHttpHost()) { + return; + } + + $options = array_merge(array( + 'allow_credentials'=> false, + 'allow_origin'=> array(), + 'allow_headers'=> array(), + 'allow_methods'=> array(), + 'expose_headers'=> array(), + 'max_age'=> 0, + 'hosts'=> array(), + ), $this->app['phraseanet.configuration']['api_cors']); + + // skip if the host is not matching + if (!$this->checkHost($request, $options)) { + return; + } + // perform preflight checks + if ('OPTIONS' === $request->getMethod()) { + $event->setResponse($this->getPreflightResponse($request, $options)); + + return; + } + if (!$this->checkOrigin($request, $options)) { + $response = new Response('', 403, array('Access-Control-Allow-Origin' => 'null')); + $event->setResponse($response); + + return; + } + + $this->app['dispatcher']->addListener(KernelEvents::RESPONSE, array($this, 'onKernelResponse')); + $this->options = $options; + } + + public function onKernelResponse(FilterResponseEvent $event) + { + if (HttpKernelInterface::MASTER_REQUEST !== $event->getRequestType()) { + return; + } + + $response = $event->getResponse(); + $request = $event->getRequest(); + // add CORS response headers + $response->headers->set('Access-Control-Allow-Origin', $request->headers->get('Origin')); + if ($this->options['allow_credentials']) { + $response->headers->set('Access-Control-Allow-Credentials', 'true'); + } + if ($this->options['expose_headers']) { + $response->headers->set('Access-Control-Expose-Headers', strtolower(implode(', ', $this->options['expose_headers']))); + } + } + + protected function getPreflightResponse(Request $request, array $options) + { + $response = new Response(); + + if ($options['allow_credentials']) { + $response->headers->set('Access-Control-Allow-Credentials', 'true'); + } + if ($options['allow_methods']) { + $response->headers->set('Access-Control-Allow-Methods', implode(', ', $options['allow_methods'])); + } + if ($options['allow_headers']) { + $headers = in_array('*', $options['allow_headers']) + ? $request->headers->get('Access-Control-Request-Headers') + : implode(', ', array_map('strtolower', $options['allow_headers'])); + $response->headers->set('Access-Control-Allow-Headers', $headers); + } + if ($options['max_age']) { + $response->headers->set('Access-Control-Max-Age', $options['max_age']); + } + + if (!$this->checkOrigin($request, $options)) { + $response->headers->set('Access-Control-Allow-Origin', 'null'); + + return $response; + } + + $response->headers->set('Access-Control-Allow-Origin', $request->headers->get('Origin')); + + // check request method + if (!in_array(strtoupper($request->headers->get('Access-Control-Request-Method')), $options['allow_methods'], true)) { + $response->setStatusCode(405); + + return $response; + } + + /** + * We have to allow the header in the case-set as we received it by the client. + * Firefox f.e. sends the LINK method as "Link", and we have to allow it like this or the browser will deny the + * request. + */ + if (!in_array($request->headers->get('Access-Control-Request-Method'), $options['allow_methods'], true)) { + $options['allow_methods'][] = $request->headers->get('Access-Control-Request-Method'); + $response->headers->set('Access-Control-Allow-Methods', implode(', ', $options['allow_methods'])); + } + + // check request headers + $headers = $request->headers->get('Access-Control-Request-Headers'); + if ($options['allow_headers'] !== true && $headers) { + $headers = trim(strtolower($headers)); + foreach (preg_split('{, *}', $headers) as $header) { + if (in_array($header, self::$simpleHeaders, true)) { + continue; + } + if (!in_array($header, $options['allow_headers'], true)) { + $response->setStatusCode(400); + $response->setContent('Unauthorized header '.$header); + break; + } + } + } + + return $response; + } + + protected function checkOrigin(Request $request, array $options) + { + // check origin + $origin = $request->headers->get('Origin'); + if (in_array('*', $options['allow_origin']) || in_array($origin, $options['allow_origin'])) { + return true; + } + + return false; + } + + protected function checkHost(Request $request, array $options) + { + if (count($options['hosts']) === 0) { + return true; + } + + foreach ($options['hosts'] as $hostRegexp) { + if (preg_match('{'.$hostRegexp.'}i', $request->getHost())) { + return true; + } + } + + return false; + } +} diff --git a/lib/conf.d/configuration.yml b/lib/conf.d/configuration.yml index 75b6126844..dc53d2aa34 100644 --- a/lib/conf.d/configuration.yml +++ b/lib/conf.d/configuration.yml @@ -145,3 +145,12 @@ h264-pseudo-streaming: type: nginx mapping: [] plugins: [] +api_cors: + enabled: false + allow_credentials: false + allow_origin: [] + allow_headers: [] + allow_methods: [] + expose_headers: [] + max_age: 0 + hosts: [] diff --git a/tests/Alchemy/Tests/Phrasea/Core/Event/Subscriber/ApiCorsSubscriberTest.php b/tests/Alchemy/Tests/Phrasea/Core/Event/Subscriber/ApiCorsSubscriberTest.php new file mode 100644 index 0000000000..f4d63635ba --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Core/Event/Subscriber/ApiCorsSubscriberTest.php @@ -0,0 +1,106 @@ +request(array('enabled' => true, 'hosts' => array('http://api.domain.com'))); + $this->assertArrayNotHasKey('access-control-allow-origin', $response->headers->all()); + + $response = $this->request(array('enabled' => true, 'hosts' => array('localhost'))); + $this->assertArrayHasKey('access-control-allow-origin', $response->headers->all()); + } + + public function testExposeHeaders() + { + $response = $this->request( + array('enabled' => true, 'allow_origin' => array('*'), 'expose_headers' => array('HTTP_X_CUSTOM')), + 'GET' + ); + $this->assertArrayHasKey('access-control-expose-headers', $response->headers->all()); + $this->assertEquals('http_x_custom', $response->headers->get('access-control-expose-headers')); + } + + public function testAllowMethods() + { + $response = $this->request( + array('enabled' => true, 'allow_origin' => array('*'), 'allow_methods' => array('GET', 'POST', 'PUT')), + 'OPTIONS' + ); + $this->assertArrayHasKey('access-control-allow-methods', $response->headers->all()); + $this->assertEquals(implode(', ', array('GET', 'POST', 'PUT')), $response->headers->get('access-control-allow-methods')); + } + + public function testAllowHeaders() + { + $response = $this->request( + array('enabled' => true, 'allow_origin' => array('*'), 'allow_headers' => array('HTTP_X_CUSTOM')), + 'OPTIONS' + ); + $this->assertArrayHasKey('access-control-allow-headers', $response->headers->all()); + $this->assertEquals('http_x_custom', $response->headers->get('access-control-allow-headers')); + } + + public function testCORSIsEnable() + { + $response = $this->request(array('enabled' => true)); + $this->assertArrayHasKey('access-control-allow-origin', $response->headers->all()); + } + + public function testCORSIsDisable() + { + $response = $this->request(array('enabled' => false)); + $this->assertArrayNotHasKey('access-control-allow-origin', $response->headers->all()); + } + + public function testAllowOrigin() + { + $response = $this->request(array('enabled' => true, 'allow_origin' => array('*'))); + $this->assertArrayHasKey('access-control-allow-origin', $response->headers->all()); + $this->assertEquals($this->origin, $response->headers->get('access-control-allow-origin')); + } + + public function testCredentialIsEnabled() + { + $response = $this->request(array('enabled' => true, 'allow_credentials' => true, 'allow_origin' => array('*'))); + $this->assertArrayHasKey('access-control-allow-credentials', $response->headers->all()); + } + + /** + * @param array $conf + * @param string $method + * @param array $extraHeaders + * + * @return \Symfony\Component\HttpFoundation\Response + */ + private function request(array $conf, $method = 'GET', array $extraHeaders = array()) + { + $app = new Application('test'); + $app['phraseanet.configuration']['api_cors'] = $conf; + $app['dispatcher']->addSubscriber(new ApiCorsSubscriber($app)); + $app->get('/api/v1/test-route', function () { + return ''; + }); + $client = new Client($app); + $client->request($method, '/api/v1/test-route', + array(), + array(), + array_merge( + $extraHeaders, + array( + 'HTTP_Origin' => $this->origin, + ) + ) + ); + + return $client->getResponse(); + } +}