diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dc289d..0bca9f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# 1.5.0 + +- You may now configure rules per-route within zend-mvc route configuration. + When detected, these will override any rules that were general to the + application. See the ["Configuring the Module" section of the + README](README.md#configuring-the-module) for full details. + # 1.4.1 - ZfrCors now properly disallows `Access-Control-Allow-Origin: *` when the diff --git a/README.md b/README.md index 4961515..e441971 100644 --- a/README.md +++ b/README.md @@ -50,22 +50,91 @@ of -1. This means that this listener is executed AFTER the route has been matche ### Configuring the module -As of now, all the various options are set globally for all routes: - - * `allowed_origins`: (array) List of allowed origins. To allow any origin, you can use the wildcard (`*`) character. If - multiple origins are specified, ZfrCors will automatically check the `"Origin"` header's value, and only return the - allowed domain (if any) in the `"Allow-Access-Control-Origin"` response header. To allow any sub-domain, you can prefix - the domain with the wildcard character (i.e. *.example.com). Please note that you don't need to - add your host URI (so if your website is hosted as "example.com", "example.com" is automatically allowed. - * `allowed_methods`: (array) List of allowed HTTP methods. Those methods will be returned for the preflight request to - indicate which methods are allowed to the user agent. You can even specify custom HTTP verbs. - * `allowed_headers`: (array) List of allowed headers that will be returned for the preflight request. This indicates - to the user agent which headers are permitted to be sent when doing the actual request. - * `max_age`: (int) Maximum age (seconds) the preflight request should be cached by the user agent. This prevents the - user agent from sending a preflight request for each request. - * `exposed_headers`: (array) List of response headers that are allowed to be read in the user agent. Please note that - some browsers do not implement this feature correctly. - * `allowed_credentials`: (boolean) If true, it allows the browser to send cookies along with the request. +As by default, all the various options are set globally for all routes: + +- `allowed_origins`: (array) List of allowed origins. To allow any origin, you can use the wildcard (`*`) character. If + multiple origins are specified, ZfrCors will automatically check the `"Origin"` header's value, and only return the + allowed domain (if any) in the `"Allow-Access-Control-Origin"` response header. To allow any sub-domain, you can prefix + the domain with the wildcard character (i.e. `*.example.com`). Please note that you don't need to + add your host URI (so if your website is hosted as "example.com", "example.com" is automatically allowed. +- `allowed_methods`: (array) List of allowed HTTP methods. Those methods will be returned for the preflight request to + indicate which methods are allowed to the user agent. You can even specify custom HTTP verbs. +- `allowed_headers`: (array) List of allowed headers that will be returned for the preflight request. This indicates + to the user agent which headers are permitted to be sent when doing the actual request. +- `max_age`: (int) Maximum age (seconds) the preflight request should be cached by the user agent. This prevents the + user agent from sending a preflight request for each request. +- `exposed_headers`: (array) List of response headers that are allowed to be read in the user agent. Please note that + some browsers do not implement this feature correctly. +- `allowed_credentials`: (boolean) If true, it allows the browser to send cookies along with the request. + +If you want to configure specific routes, you can add `ZfrCors\Options\CorsOptions::ROUTE_PARAM` to your route configuration: + +```php + [ + 'allowed_origins' => ['*'], + 'allowed_methods' => ['GET', 'POST', 'DELETE'], + ], + 'router' => [ + 'routes' => [ + 'readOnlyRoute' => [ + 'type' => 'literal', + 'options' => [ + 'route' => '/foo/bar', + 'defaults' => [ + // This will replace allowed_methods configuration to only allow GET requests + // and only allow a specific origin instead of the wildcard origin + ZfrCors\Options\CorsOptions::ROUTE_PARAM => [ + 'allowed_origins' => ['http://example.org'], + 'allowed_methods' => ['GET'], + ], + ], + ], + ], + 'someAjaxCalls' => [ + 'type' => 'literal', + 'options' => [ + 'route' => '/ajax', + 'defaults' => [ + // This overrides the wildcard origin + ZfrCors\Options\CorsOptions::ROUTE_PARAM => [ + 'allowed_origins' => ['http://example.org'], + ], + ], + ], + 'may_terminate' => false, + 'child_routes' => [ + 'blog' => [ + 'type' => 'literal', + 'options' => [ + 'route' => '/blogpost', + 'defaults' => [ + // This would only allow `http://example.org` to GET this route + 'allowed_methods' => ['GET'], + ], + ], + 'may_terminate' => true, + 'child_routes' => [ + 'delete' => [ + 'type' => 'segment', + 'options' => [ + 'route' => ':id', + // This would only allow origin `http://example.org` to apply DELETE on this route + 'defaults' => [ + 'allowed_methods' => ['DELETE'], + ], + ], + ], + ], + ], + ], + ], + ], + ], +]; +``` ### Preflight request diff --git a/src/ZfrCors/Mvc/CorsRequestListener.php b/src/ZfrCors/Mvc/CorsRequestListener.php index 0834b86..a9da2ab 100644 --- a/src/ZfrCors/Mvc/CorsRequestListener.php +++ b/src/ZfrCors/Mvc/CorsRequestListener.php @@ -103,7 +103,7 @@ public function onCorsPreflight(MvcEvent $event) // Preflight -- return a response now! $this->isPreflight = true; - return $this->corsService->createPreflightCorsResponse($request); + return $this->corsService->createPreflightCorsResponseWithRouteOptions($request, $event->getRouteMatch()); } /** diff --git a/src/ZfrCors/Options/CorsOptions.php b/src/ZfrCors/Options/CorsOptions.php index b342242..f1525cd 100644 --- a/src/ZfrCors/Options/CorsOptions.php +++ b/src/ZfrCors/Options/CorsOptions.php @@ -28,6 +28,8 @@ */ class CorsOptions extends AbstractOptions { + const ROUTE_PARAM = 'cors'; + /** * Set the list of allowed origins domain with protocol. * diff --git a/src/ZfrCors/Service/CorsService.php b/src/ZfrCors/Service/CorsService.php index 983d7e4..0715461 100644 --- a/src/ZfrCors/Service/CorsService.php +++ b/src/ZfrCors/Service/CorsService.php @@ -18,6 +18,8 @@ namespace ZfrCors\Service; +use Zend\Mvc\Router\Http\RouteMatch as DeprecatedRouteMatch; +use Zend\Router\Http\RouteMatch; use Zend\Http\Header; use Zend\Uri\UriFactory; use ZfrCors\Exception\DisallowedOriginException; @@ -120,6 +122,25 @@ public function createPreflightCorsResponse(HttpRequest $request) return $response; } + /** + * Create a preflight response by adding the correspoding headers which are merged with per-route configuration + * + * @param HttpRequest $request + * @param RouteMatch|DeprecatedRouteMatch|null $routeMatch + * + * @return HttpResponse + */ + public function createPreflightCorsResponseWithRouteOptions(HttpRequest $request, $routeMatch = null) + { + $options = $this->options; + if ($routeMatch instanceof RouteMatch || $routeMatch instanceof DeprecatedRouteMatch) { + $options->setFromArray($routeMatch->getParam(CorsOptions::ROUTE_PARAM) ?: []); + } + $response = $this->createPreflightCorsResponse($request); + + return $response; + } + /** * Populate a simple CORS response * diff --git a/tests/ZfrCorsTest/Service/CorsServiceTest.php b/tests/ZfrCorsTest/Service/CorsServiceTest.php index d4cce20..727c9ab 100644 --- a/tests/ZfrCorsTest/Service/CorsServiceTest.php +++ b/tests/ZfrCorsTest/Service/CorsServiceTest.php @@ -22,6 +22,8 @@ use Zend\Http\Response as HttpResponse; use Zend\Http\Request as HttpRequest; use Zend\Mvc\MvcEvent; +use Zend\Mvc\Router\Http\RouteMatch as DeprecatedRouteMatch; +use Zend\Router\Http\RouteMatch; use ZfrCors\Options\CorsOptions; use ZfrCors\Service\CorsService; @@ -336,6 +338,69 @@ public function testCanDetectCorsRequestFromSameHostButDifferentScheme() $this->assertTrue($this->corsService->isCorsRequest($request)); } + public function testCanHandleUnconfiguredRouteMatch() + { + $routeMatch = class_exists(DeprecatedRouteMatch::class) ? new DeprecatedRouteMatch([]) : new RouteMatch([]); + + $request = new HttpRequest(); + $request->getHeaders()->addHeaderLine('Origin', 'http://example.com'); + $response = $this->corsService->createPreflightCorsResponseWithRouteOptions($request, $routeMatch); + + $headers = $response->getHeaders(); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('', $response->getContent()); + $this->assertEquals('http://example.com', $headers->get('Access-Control-Allow-Origin')->getFieldValue()); + $this->assertEquals( + 'GET, POST, PUT, DELETE, OPTIONS', + $headers->get('Access-Control-Allow-Methods')->getFieldValue() + ); + $this->assertEquals('Content-Type, Accept', $headers->get('Access-Control-Allow-Headers')->getFieldValue()); + $this->assertEquals(10, $headers->get('Access-Control-Max-Age')->getFieldValue()); + $this->assertEquals(0, $headers->get('Content-Length')->getFieldValue()); + + $this->assertEquals('true', $headers->get('Access-Control-Allow-Credentials')->getFieldValue()); + } + + public function testCanHandleConfiguredRouteMatch() + { + + $routeMatchParameters = [ + CorsOptions::ROUTE_PARAM => [ + 'allowed_origins' => ['http://example.org'], + 'allowed_methods' => ['POST', 'DELETE', 'OPTIONS'], + 'allowed_headers' => ['Content-Type', 'Accept', 'Cookie'], + 'exposed_headers' => ['Location'], + 'max_age' => 5, + 'allowed_credentials' => false, + ], + ]; + + $routeMatch = class_exists(DeprecatedRouteMatch::class) ? new DeprecatedRouteMatch($routeMatchParameters) : + new RouteMatch($routeMatchParameters); + + $request = new HttpRequest(); + $request->getHeaders()->addHeaderLine('Origin', 'http://example.org'); + $response = $this->corsService->createPreflightCorsResponseWithRouteOptions($request, $routeMatch); + + $headers = $response->getHeaders(); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('', $response->getContent()); + $this->assertEquals('http://example.org', $headers->get('Access-Control-Allow-Origin')->getFieldValue()); + $this->assertEquals( + 'POST, DELETE, OPTIONS', + $headers->get('Access-Control-Allow-Methods')->getFieldValue() + ); + $this->assertEquals( + 'Content-Type, Accept, Cookie', + $headers->get('Access-Control-Allow-Headers')->getFieldValue() + ); + $this->assertEquals(5, $headers->get('Access-Control-Max-Age')->getFieldValue()); + $this->assertEquals(0, $headers->get('Content-Length')->getFieldValue()); + + $this->assertFalse($headers->has('Access-Control-Allow-Credentials')); + } + /** * @see https://github.com/zf-fr/zfr-cors/issues/44 * @expectedException \ZfrCors\Exception\InvalidOriginException