PHRAS-58 Add CORS feature

This commit is contained in:
Nicolas Le Goff
2014-04-10 17:40:56 +02:00
parent 317372eb96
commit d2dc99baf8
5 changed files with 333 additions and 0 deletions

View File

@@ -142,3 +142,12 @@ h264-pseudo-streaming:
type: nginx type: nginx
mapping: [] mapping: []
plugins: [] plugins: []
api_cors:
enabled: false
allow_credentials: false
allow_origin: []
allow_headers: []
allow_methods: []
expose_headers: []
max_age: 0
hosts: []

View File

@@ -12,6 +12,7 @@
namespace Alchemy\Phrasea\Application; namespace Alchemy\Phrasea\Application;
use Alchemy\Phrasea\Application as PhraseaApplication; use Alchemy\Phrasea\Application as PhraseaApplication;
use Alchemy\Phrasea\Core\Event\Subscriber\ApiCorsSubscriber;
use Alchemy\Phrasea\Core\PhraseaEvents; use Alchemy\Phrasea\Core\PhraseaEvents;
use Alchemy\Phrasea\Controller\Api\Oauth2; use Alchemy\Phrasea\Controller\Api\Oauth2;
use Alchemy\Phrasea\Controller\Api\V1; 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->mount('/api/v1', new V1());
$app['dispatcher']->addSubscriber(new ApiOauth2ErrorsSubscriber($app['phraseanet.exception_handler'])); $app['dispatcher']->addSubscriber(new ApiOauth2ErrorsSubscriber($app['phraseanet.exception_handler']));
$app['dispatcher']->addSubscriber(new ApiCorsSubscriber($app));
$app['dispatcher']->dispatch(PhraseaEvents::API_LOAD_END, new ApiLoadEndEvent()); $app['dispatcher']->dispatch(PhraseaEvents::API_LOAD_END, new ApiLoadEndEvent());
return $app; return $app;

View File

@@ -0,0 +1,207 @@
<?php
/*
* This file is part of Phraseanet
*
* (c) 2005-2014 Alchemy
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Alchemy\Phrasea\Core\Event\Subscriber;
use Alchemy\Phrasea\Application;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
class ApiCorsSubscriber implements EventSubscriberInterface
{
/**
* Simple headers as defined in the spec should always be accepted
*/
protected static $simpleHeaders = array(
'accept',
'accept-language',
'content-language',
'origin',
);
private $app;
private $options;
public function __construct(Application $app)
{
$this->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;
}
}

View File

@@ -145,3 +145,12 @@ h264-pseudo-streaming:
type: nginx type: nginx
mapping: [] mapping: []
plugins: [] plugins: []
api_cors:
enabled: false
allow_credentials: false
allow_origin: []
allow_headers: []
allow_methods: []
expose_headers: []
max_age: 0
hosts: []

View File

@@ -0,0 +1,106 @@
<?php
namespace Alchemy\Tests\Phrasea\Core\Event\Subscriber;
use Alchemy\Phrasea\Application;
use Symfony\Component\HttpKernel\Client;
use Alchemy\Phrasea\Core\Event\Subscriber\ApiCorsSubscriber;
class ApiCorsSubscriberTest extends \PHPUnit_Framework_TestCase
{
private $origin = 'http://dev.phrasea.net';
public function testHostRestriction()
{
$response = $this->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();
}
}