diff --git a/src/Illuminate/Foundation/Configuration/Middleware.php b/src/Illuminate/Foundation/Configuration/Middleware.php index c4d90bb98850..720a7a3e7b32 100644 --- a/src/Illuminate/Foundation/Configuration/Middleware.php +++ b/src/Illuminate/Foundation/Configuration/Middleware.php @@ -483,6 +483,7 @@ public function getMiddlewareGroups() { $middleware = [ 'web' => array_values(array_filter([ + \Illuminate\Http\Middleware\HandleRouteCors::class, \Illuminate\Cookie\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, @@ -493,6 +494,7 @@ public function getMiddlewareGroups() ])), 'api' => array_values(array_filter([ + \Illuminate\Http\Middleware\HandleRouteCors::class, $this->statefulApi ? \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class : null, $this->apiLimiter ? 'throttle:'.$this->apiLimiter : null, \Illuminate\Routing\Middleware\SubstituteBindings::class, diff --git a/src/Illuminate/Http/Middleware/HandleCors.php b/src/Illuminate/Http/Middleware/HandleCors.php index efabddeb6f5d..c2264eb2e163 100644 --- a/src/Illuminate/Http/Middleware/HandleCors.php +++ b/src/Illuminate/Http/Middleware/HandleCors.php @@ -6,9 +6,14 @@ use Fruitcake\Cors\CorsService; use Illuminate\Contracts\Container\Container; use Illuminate\Http\Request; +use Illuminate\Routing\Route; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class HandleCors { + public const ROUTE_CORS_HANDLED_ATTRIBUTE = '_laravel_route_cors_handled'; + /** * The container instance. * @@ -50,18 +55,90 @@ public function __construct(Container $container, CorsService $cors) * @return \Illuminate\Http\Response */ public function handle($request, Closure $next) + { + if ($this->shouldSkip($request) || ! $this->hasMatchingPath($request)) { + return $next($request); + } + + if ($this->cors->isPreflightRequest($request)) { + $this->cors->setOptions($this->resolveOptionsForPreflight($request)); + + $response = $this->cors->handlePreflightRequest($request); + + $this->cors->varyHeader($response, 'Access-Control-Request-Method'); + + return $response; + } + + $response = $next($request); + + if ($request->attributes->get(static::ROUTE_CORS_HANDLED_ATTRIBUTE)) { + return $response; + } + + return $this->handleRequest($request, fn () => $response, $this->container['config']->get('cors', [])); + } + + /** + * Resolve the CORS options to use when answering a preflight request. + * + * @param \Illuminate\Http\Request $request + * @return array + */ + protected function resolveOptionsForPreflight(Request $request): array + { + $globalOptions = $this->container['config']->get('cors', []); + + $intendedMethod = strtoupper((string) $request->headers->get('Access-Control-Request-Method')); + + if ($intendedMethod === '') { + return $globalOptions; + } + + try { + $probe = $request->duplicate(); + $probe->setMethod($intendedMethod); + + $route = $this->container['router']->getRoutes()->match($probe); + } catch (NotFoundHttpException|MethodNotAllowedHttpException) { + return $globalOptions; + } + + if ($route instanceof Route && ($routeOptions = $route->effectiveCorsOptions()) !== null) { + return $this->normalizeCorsOptions($routeOptions); + } + + return $globalOptions; + } + + /** + * Determine whether the middleware should be skipped. + * + * @param \Illuminate\Http\Request $request + * @return bool + */ + protected function shouldSkip(Request $request): bool { foreach (static::$skipCallbacks as $callback) { if ($callback($request)) { - return $next($request); + return true; } } - if (! $this->hasMatchingPath($request)) { - return $next($request); - } + return false; + } - $this->cors->setOptions($this->container['config']->get('cors', [])); + /** + * Handle the request using the given CORS options. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @param array $options + * @return \Illuminate\Http\Response + */ + protected function handleRequest(Request $request, Closure $next, array $options) + { + $this->cors->setOptions($options); if ($this->cors->isPreflightRequest($request)) { $response = $this->cors->handlePreflightRequest($request); @@ -80,6 +157,25 @@ public function handle($request, Closure $next) return $this->cors->addActualRequestHeaders($response, $request); } + /** + * Normalize short-form CORS options into the shape expected by CorsService. + * + * @param array $options + * @return array + */ + protected function normalizeCorsOptions(array $options): array + { + return [ + 'allowed_origins' => $options['origins'] ?? ['*'], + 'allowed_methods' => $options['methods'] ?? ['*'], + 'allowed_headers' => $options['headers'] ?? ['*'], + 'exposed_headers' => $options['exposed_headers'] ?? [], + 'max_age' => $options['max_age'] ?? 0, + 'supports_credentials' => $options['credentials'] ?? false, + 'allowed_origins_patterns' => [], + ]; + } + /** * Get the path from the configuration to determine if the CORS service should run. * diff --git a/src/Illuminate/Http/Middleware/HandleRouteCors.php b/src/Illuminate/Http/Middleware/HandleRouteCors.php new file mode 100644 index 000000000000..884354c3052c --- /dev/null +++ b/src/Illuminate/Http/Middleware/HandleRouteCors.php @@ -0,0 +1,51 @@ +shouldSkip($request) || $this->cors->isPreflightRequest($request)) { + return $next($request); + } + + $routeOptions = $this->resolveRouteCorsOptions($request); + + if ($routeOptions === null) { + return $next($request); + } + + $request->attributes->set(static::ROUTE_CORS_HANDLED_ATTRIBUTE, true); + + return $this->handleRequest($request, $next, $this->normalizeCorsOptions($routeOptions)); + } + + /** + * Resolve the CORS options for the current route. + * + * @param \Illuminate\Http\Request $request + * @return array|null + */ + protected function resolveRouteCorsOptions(Request $request): ?array + { + $route = $request->route(); + + if (! $route instanceof Route) { + return null; + } + + return $route->effectiveCorsOptions(); + } +} diff --git a/src/Illuminate/Routing/AbstractRouteCollection.php b/src/Illuminate/Routing/AbstractRouteCollection.php index 6f7c73c61ee2..385e4cde2080 100644 --- a/src/Illuminate/Routing/AbstractRouteCollection.php +++ b/src/Illuminate/Routing/AbstractRouteCollection.php @@ -51,7 +51,7 @@ protected function handleMatchedRoute(Request $request, $route) * Determine if any routes match on another HTTP verb. * * @param \Illuminate\Http\Request $request - * @return array + * @return array */ protected function checkForAlternateVerbs($request) { @@ -60,11 +60,9 @@ protected function checkForAlternateVerbs($request) // Here we will spin through all verbs except for the current request verb and // check to see if any routes respond to them. If they do, we will return a // proper error response with the correct headers on the response string. - return array_values(array_filter( + return array_filter(array_combine( $methods, - function ($method) use ($request) { - return ! is_null($this->matchAgainstRoutes($this->get($method), $request, false)); - } + array_map(fn ($method) => $this->matchAgainstRoutes($this->get($method), $request, false), $methods) )); } @@ -99,13 +97,15 @@ protected function matchAgainstRoutes(array $routes, $request, $includingMethod * Get a route (if necessary) that responds when other available methods are present. * * @param \Illuminate\Http\Request $request - * @param string[] $methods + * @param array $routes * @return \Illuminate\Routing\Route * * @throws \Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException */ - protected function getRouteForMethods($request, array $methods) + protected function getRouteForMethods($request, array $routes) { + $methods = array_keys($routes); + if ($request->isMethod('OPTIONS')) { return (new Route('OPTIONS', $request->path(), function () use ($methods) { return new Response('', 200, ['Allow' => implode(',', $methods)]); diff --git a/src/Illuminate/Routing/Attributes/Cors.php b/src/Illuminate/Routing/Attributes/Cors.php new file mode 100644 index 000000000000..5156b33cfdba --- /dev/null +++ b/src/Illuminate/Routing/Attributes/Cors.php @@ -0,0 +1,51 @@ +|null $origins + * @param array|null $methods + * @param array|null $headers + * @param array|null $exposed_headers + * @param int|null $max_age + * @param bool|null $credentials + */ + public function __construct( + public ?array $origins = null, + public ?array $methods = null, + public ?array $headers = null, + public ?array $exposed_headers = null, + public ?int $max_age = null, + public ?bool $credentials = null, + ) { + } + + /** + * Get the CORS options as an array, excluding null values. + * + * @return array{ + * origins?: array, + * methods?: array, + * headers?: array, + * exposed_headers?: array, + * max_age?: int, + * credentials?: bool, + * } + */ + public function toArray(): array + { + return array_filter([ + 'origins' => $this->origins, + 'methods' => $this->methods, + 'headers' => $this->headers, + 'exposed_headers' => $this->exposed_headers, + 'max_age' => $this->max_age, + 'credentials' => $this->credentials, + ], fn ($value) => ! is_null($value)); + } +} diff --git a/src/Illuminate/Routing/Route.php b/src/Illuminate/Routing/Route.php index 341d07f0e36b..fd7ce8dc7864 100755 --- a/src/Illuminate/Routing/Route.php +++ b/src/Illuminate/Routing/Route.php @@ -1106,6 +1106,65 @@ public function can($ability, $models = []) : $this->middleware(['can:'.$ability.','.implode(',', Arr::wrap($models))]); } + /** + * Set the CORS options for the route. + * + * @param array $options + * @return $this + */ + public function cors(array $options) + { + $this->action['cors'] = $options; + + return $this; + } + + /** + * Get the effective CORS options for this route. + * + * Resolution order: method attribute > class attribute > route/group action. + * + * @return array|null + */ + public function effectiveCorsOptions() + { + return $this->controllerCorsOptions() + ?? ($this->action['cors'] ?? null); + } + + /** + * Get CORS options from controller attributes. + * + * @return array|null + */ + protected function controllerCorsOptions() + { + if (! $this->isControllerAction()) { + return null; + } + + try { + $reflectionClass = new ReflectionClass($this->getControllerClass()); + $reflectionMethod = $reflectionClass->getMethod($this->getControllerMethod()); + } catch (ReflectionException) { + return null; + } + + $methodAttrs = $reflectionMethod->getAttributes(Attributes\Cors::class); + + if (! empty($methodAttrs)) { + return $methodAttrs[0]->newInstance()->toArray(); + } + + $classAttrs = $reflectionClass->getAttributes(Attributes\Cors::class); + + if (! empty($classAttrs)) { + return $classAttrs[0]->newInstance()->toArray(); + } + + return null; + } + /** * Get the middleware for the route's controller. * diff --git a/src/Illuminate/Routing/RouteGroup.php b/src/Illuminate/Routing/RouteGroup.php index cca24b29234d..662a5ec100f6 100644 --- a/src/Illuminate/Routing/RouteGroup.php +++ b/src/Illuminate/Routing/RouteGroup.php @@ -30,8 +30,12 @@ public static function merge($new, $old, $prependExistingPrefix = true) 'where' => static::formatWhere($new, $old), ]); + if (! isset($new['cors']) && isset($old['cors'])) { + $new['cors'] = $old['cors']; + } + return array_merge_recursive(Arr::except( - $old, ['namespace', 'prefix', 'where', 'as'] + $old, ['namespace', 'prefix', 'where', 'as', 'cors'] ), $new); } diff --git a/src/Illuminate/Routing/RouteRegistrar.php b/src/Illuminate/Routing/RouteRegistrar.php index 6da02f501fef..471357d5802c 100644 --- a/src/Illuminate/Routing/RouteRegistrar.php +++ b/src/Illuminate/Routing/RouteRegistrar.php @@ -31,6 +31,7 @@ * @method \Illuminate\Routing\RouteRegistrar where(array $where) * @method \Illuminate\Routing\RouteRegistrar withoutMiddleware(array|string $middleware) * @method \Illuminate\Routing\RouteRegistrar withoutScopedBindings() + * @method \Illuminate\Routing\RouteRegistrar cors(array $options) */ class RouteRegistrar { @@ -71,6 +72,7 @@ class RouteRegistrar 'as', 'can', 'controller', + 'cors', 'domain', 'middleware', 'missing', diff --git a/tests/Foundation/Configuration/MiddlewareTest.php b/tests/Foundation/Configuration/MiddlewareTest.php index ecf0c0e85b00..f73b448dbc5b 100644 --- a/tests/Foundation/Configuration/MiddlewareTest.php +++ b/tests/Foundation/Configuration/MiddlewareTest.php @@ -15,6 +15,7 @@ use Illuminate\Foundation\Http\Middleware\PreventRequestForgery; use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance; use Illuminate\Foundation\Http\Middleware\TrimStrings; +use Illuminate\Http\Middleware\HandleRouteCors; use Illuminate\Http\Middleware\TrustHosts; use Illuminate\Http\Middleware\TrustProxies; use Illuminate\Http\Request; @@ -346,4 +347,14 @@ public function testRedirectGuestsToNullRegistersNullCallback() $this->assertNotNull($callback); $this->assertNull($callback(null)); } + + public function testRouteCorsMiddlewareIsPrependedToWebAndApiGroups() + { + $configuration = new Middleware(); + + $groups = $configuration->getMiddlewareGroups(); + + $this->assertSame(HandleRouteCors::class, $groups['web'][0]); + $this->assertSame(HandleRouteCors::class, $groups['api'][0]); + } } diff --git a/tests/Integration/Http/Middleware/HandleCorsTest.php b/tests/Integration/Http/Middleware/HandleCorsTest.php index 38ed647a621a..d1b286a285c6 100644 --- a/tests/Integration/Http/Middleware/HandleCorsTest.php +++ b/tests/Integration/Http/Middleware/HandleCorsTest.php @@ -2,17 +2,28 @@ namespace Illuminate\Tests\Integration\Http\Middleware; +use Illuminate\Auth\Middleware\Authenticate; +use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Contracts\Http\Kernel; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Http\Middleware\HandleCors; use Illuminate\Http\Request; +use Illuminate\Routing\Middleware\ThrottleRequests; use Illuminate\Routing\Router; +use Illuminate\Support\Facades\RateLimiter; use Orchestra\Testbench\TestCase; class HandleCorsTest extends TestCase { use ValidatesRequests; + protected function setUp(): void + { + parent::setUp(); + + SpyMiddleware::$invocations = 0; + } + protected function defineEnvironment($app) { $app['config']['cors'] = [ @@ -25,6 +36,11 @@ protected function defineEnvironment($app) 'max_age' => 0, ]; + $app['config']->set('auth.defaults.guard', 'web'); + $app['config']->set('auth.guards.web', ['driver' => 'handle-cors-null']); + + $app['auth']->viaRequest('handle-cors-null', fn () => null); + $kernel = $app->make(Kernel::class); $kernel->prependMiddleware(HandleCors::class); } @@ -245,6 +261,64 @@ public function testValidationException() $this->assertEquals(302, $crawler->getStatusCode()); } + public function testPreflightPassesThroughAuthMiddleware() + { + $crawler = $this->call('OPTIONS', 'api/protected', [], [], [], [ + 'HTTP_ORIGIN' => 'http://localhost', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ]); + + $this->assertEquals(204, $crawler->getStatusCode()); + $this->assertSame('http://localhost', $crawler->headers->get('Access-Control-Allow-Origin')); + } + + public function testPreflightDoesNotInvokeDownstreamMiddleware() + { + $this->app['router']->post('api/spy', ['uses' => fn () => 'OK']) + ->middleware(SpyMiddleware::class); + + $crawler = $this->call('OPTIONS', 'api/spy', [], [], [], [ + 'HTTP_ORIGIN' => 'http://localhost', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ]); + + $this->assertEquals(204, $crawler->getStatusCode()); + $this->assertSame('http://localhost', $crawler->headers->get('Access-Control-Allow-Origin')); + $this->assertSame(0, SpyMiddleware::$invocations, 'Downstream route middleware must not run on preflight requests.'); + } + + public function testPreflightPassesThroughThrottleMiddleware() + { + RateLimiter::for('cors-preflight', fn () => Limit::perMinute(1)); + + $this->app['router']->post('api/throttled', ['uses' => fn () => 'OK']) + ->middleware(ThrottleRequests::class.':cors-preflight'); + + $first = $this->call('OPTIONS', 'api/throttled', [], [], [], [ + 'HTTP_ORIGIN' => 'http://localhost', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ]); + + $this->assertEquals(204, $first->getStatusCode()); + $this->assertSame('http://localhost', $first->headers->get('Access-Control-Allow-Origin')); + + $second = $this->call('OPTIONS', 'api/throttled', [], [], [], [ + 'HTTP_ORIGIN' => 'http://localhost', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ]); + + $this->assertEquals(204, $second->getStatusCode()); + $this->assertSame('http://localhost', $second->headers->get('Access-Control-Allow-Origin')); + } + + public function testNonCorsOptionsReturnsAllowHeader() + { + $crawler = $this->call('OPTIONS', 'api/ping'); + + $this->assertEquals(200, $crawler->getStatusCode()); + $this->assertSame('POST,PUT', $crawler->headers->get('Allow')); + } + protected function addWebRoutes(Router $router) { $router->post('web/ping', [ @@ -283,5 +357,22 @@ protected function addApiRoutes(Router $router) return 'ok'; }, ]); + + $router->post('api/protected', [ + 'middleware' => Authenticate::class.':web', + 'uses' => fn () => 'PROTECTED', + ]); + } +} + +class SpyMiddleware +{ + public static int $invocations = 0; + + public function handle($request, \Closure $next) + { + self::$invocations++; + + return $next($request); } } diff --git a/tests/Integration/Http/Middleware/RouteCorsTest.php b/tests/Integration/Http/Middleware/RouteCorsTest.php new file mode 100644 index 000000000000..6048fc062046 --- /dev/null +++ b/tests/Integration/Http/Middleware/RouteCorsTest.php @@ -0,0 +1,330 @@ + ['api/*'], + 'supports_credentials' => false, + 'allowed_origins' => ['http://global.example.com'], + 'allowed_headers' => ['X-Global-Header'], + 'allowed_methods' => ['GET', 'POST'], + 'exposed_headers' => [], + 'max_age' => 0, + ]; + + $app['config']['app.key'] = 'base64:'.base64_encode(str_repeat('a', 32)); + + $app['config']->set('auth.defaults.guard', 'web'); + $app['config']->set('auth.guards.web', ['driver' => 'route-cors-null']); + + $app['auth']->viaRequest('route-cors-null', fn () => null); + + $kernel = $app->make(Kernel::class); + $kernel->prependMiddleware(HandleCors::class); + $kernel->prependMiddlewareToGroup('web', HandleRouteCors::class); + $kernel->prependMiddlewareToGroup('api', HandleRouteCors::class); + } + + protected function defineRoutes($router) + { + $router->middleware('api')->group(function (Router $router) { + $router->get('api/route-cors', ['uses' => fn () => 'OK']) + ->cors(['origins' => ['https://app.example.com'], 'methods' => ['GET']]); + + $router->post('api/route-cors', ['uses' => fn () => 'OK']) + ->cors(['origins' => ['https://app.example.com'], 'methods' => ['GET', 'POST']]); + + $router->get('api/global-cors', ['uses' => fn () => 'GLOBAL']); + + $router->get('api/sibling', ['uses' => fn () => 'SIBLING']) + ->cors(['origins' => ['https://sibling.example.com']]); + + $router->prefix('api/grouped')->cors([ + 'origins' => ['https://group.example.com'], + 'methods' => ['GET', 'POST'], + ])->group(function (Router $router) { + $router->get('child', ['uses' => fn () => 'CHILD']); + + $router->get('override', ['uses' => fn () => 'OVERRIDE']) + ->cors(['origins' => ['https://override.example.com']]); + }); + + $router->get('api/controller-cors', [ControllerWithClassCors::class, 'index']); + + $router->get('api/method-cors', [ControllerWithMethodCors::class, 'specific']); + + $router->get('api/attr-over-route', [ControllerWithClassCors::class, 'index']) + ->cors(['origins' => ['https://route-loses.example.com']]); + + $router->post('api/protected-route-cors', ['uses' => fn () => 'PROTECTED']) + ->middleware(Authenticate::class.':web') + ->cors(['origins' => ['https://app.example.com']]); + + $router->prefix('api/grouped-auth')->cors([ + 'origins' => ['https://group.example.com'], + 'methods' => ['GET'], + ])->group(function (Router $router) { + $router->get('secret', ['uses' => fn () => 'SECRET']) + ->middleware(Authenticate::class.':web'); + }); + }); + + $router->middleware('web')->group(function (Router $router) { + $router->get('web/no-cors', ['uses' => fn () => 'WEB']); + }); + } + + public function testRouteLevelCorsOnActualRequest() + { + $this->call('GET', 'api/route-cors', server: [ + 'HTTP_ORIGIN' => 'https://app.example.com', + ])->assertOk() + ->assertHeader('Access-Control-Allow-Origin', 'https://app.example.com'); + } + + public function testRouteLevelCorsPreflightRequest() + { + $this->call('OPTIONS', 'api/route-cors', server: [ + 'HTTP_ORIGIN' => 'https://app.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + ])->assertNoContent() + ->assertHeader('Access-Control-Allow-Origin', 'https://app.example.com'); + } + + public function testRouteLevelCorsPreflightWithPostMethod() + { + $this->call('OPTIONS', 'api/route-cors', server: [ + 'HTTP_ORIGIN' => 'https://app.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ])->assertNoContent() + ->assertHeader('Access-Control-Allow-Origin', 'https://app.example.com'); + } + + public function testFallbackToGlobalConfigWhenNoRouteCors() + { + $this->call('GET', 'api/global-cors', server: [ + 'HTTP_ORIGIN' => 'http://global.example.com', + ])->assertOk() + ->assertHeader('Access-Control-Allow-Origin', 'http://global.example.com'); + } + + public function testFallbackToGlobalConfigOnPreflight() + { + $this->call('OPTIONS', 'api/global-cors', server: [ + 'HTTP_ORIGIN' => 'http://global.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + ])->assertNoContent() + ->assertHeader('Access-Control-Allow-Origin', 'http://global.example.com'); + } + + public function testRouteCorsDoesNotBleedIntoSiblingRoute() + { + $response = $this->call('GET', 'api/sibling', server: [ + 'HTTP_ORIGIN' => 'https://app.example.com', + ]); + + $this->assertNotSame('https://app.example.com', $response->headers->get('Access-Control-Allow-Origin')); + } + + public function testRouteCorsReplacesGlobalDefaults() + { + $response = $this->call('GET', 'api/route-cors', server: [ + 'HTTP_ORIGIN' => 'http://global.example.com', + ]); + + $this->assertNotSame('http://global.example.com', $response->headers->get('Access-Control-Allow-Origin')); + } + + public function testRouteCorsPreflightDoesNotFallBackToGlobalDefaults() + { + $response = $this->call('OPTIONS', 'api/route-cors', server: [ + 'HTTP_ORIGIN' => 'http://global.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + ]); + + $this->assertNotSame('http://global.example.com', $response->headers->get('Access-Control-Allow-Origin')); + } + + public function testNoCorsHeadersOnWebRoute() + { + $this->call('GET', 'web/no-cors', server: [ + 'HTTP_ORIGIN' => 'http://anywhere.com', + ])->assertOk() + ->assertHeaderMissing('Access-Control-Allow-Origin'); + } + + public function testGroupCorsAppliesToChildRoutes() + { + $this->call('GET', 'api/grouped/child', server: [ + 'HTTP_ORIGIN' => 'https://group.example.com', + ])->assertOk() + ->assertHeader('Access-Control-Allow-Origin', 'https://group.example.com'); + } + + public function testChildRouteCanOverrideGroupCors() + { + $this->call('GET', 'api/grouped/override', server: [ + 'HTTP_ORIGIN' => 'https://override.example.com', + ])->assertOk() + ->assertHeader('Access-Control-Allow-Origin', 'https://override.example.com'); + } + + public function testGroupCorsOriginsNotAllowedOnOverrideRoute() + { + $response = $this->call('GET', 'api/grouped/override', server: [ + 'HTTP_ORIGIN' => 'https://group.example.com', + ]); + + $this->assertNotSame('https://group.example.com', $response->headers->get('Access-Control-Allow-Origin')); + } + + public function testControllerClassAttributeCors() + { + $this->call('GET', 'api/controller-cors', server: [ + 'HTTP_ORIGIN' => 'https://class-attr.example.com', + ])->assertOk() + ->assertHeader('Access-Control-Allow-Origin', 'https://class-attr.example.com'); + } + + public function testControllerMethodAttributeOverridesClassAttribute() + { + $this->call('GET', 'api/method-cors', server: [ + 'HTTP_ORIGIN' => 'https://method-attr.example.com', + ])->assertOk() + ->assertHeader('Access-Control-Allow-Origin', 'https://method-attr.example.com'); + } + + public function testControllerAttributeOverridesRouteAction() + { + $this->call('GET', 'api/attr-over-route', server: [ + 'HTTP_ORIGIN' => 'https://class-attr.example.com', + ])->assertOk() + ->assertHeader('Access-Control-Allow-Origin', 'https://class-attr.example.com'); + } + + public function testRouteActionIgnoredWhenControllerAttributeExists() + { + $response = $this->call('GET', 'api/attr-over-route', server: [ + 'HTTP_ORIGIN' => 'https://route-loses.example.com', + ]); + + $this->assertNotSame('https://route-loses.example.com', $response->headers->get('Access-Control-Allow-Origin')); + } + + public function testPreflightOnGroupCorsRoute() + { + $this->call('OPTIONS', 'api/grouped/child', server: [ + 'HTTP_ORIGIN' => 'https://group.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + ])->assertNoContent() + ->assertHeader('Access-Control-Allow-Origin', 'https://group.example.com'); + } + + public function testPreflightOnControllerAttributeRoute() + { + $this->call('OPTIONS', 'api/controller-cors', server: [ + 'HTTP_ORIGIN' => 'https://class-attr.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + ])->assertNoContent() + ->assertHeader('Access-Control-Allow-Origin', 'https://class-attr.example.com'); + } + + public function testRouteCorsWithCredentials() + { + $this->app['router']->middleware('api')->get('api/creds', ['uses' => fn () => 'OK']) + ->cors(['origins' => ['https://creds.example.com'], 'credentials' => true]); + + $this->call('GET', 'api/creds', server: [ + 'HTTP_ORIGIN' => 'https://creds.example.com', + ])->assertHeader('Access-Control-Allow-Credentials', 'true'); + } + + public function testPreflightAgainstProtectedRouteReturnsRouteCorsHeaders() + { + $response = $this->call('OPTIONS', 'api/protected-route-cors', server: [ + 'HTTP_ORIGIN' => 'https://app.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ]); + + $response->assertNoContent() + ->assertHeader('Access-Control-Allow-Origin', 'https://app.example.com'); + + $this->assertSame(['https://app.example.com'], $response->headers->all('Access-Control-Allow-Origin')); + } + + public function testPreflightAgainstGroupProtectedRouteReturnsGroupCorsHeaders() + { + $this->call('OPTIONS', 'api/grouped-auth/secret', server: [ + 'HTTP_ORIGIN' => 'https://group.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + ])->assertNoContent() + ->assertHeader('Access-Control-Allow-Origin', 'https://group.example.com'); + } + + public function testNonCorsOptionsStillReturnsAllowHeader() + { + $response = $this->call('OPTIONS', 'api/route-cors'); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('GET,HEAD,POST', $response->headers->get('Allow')); + } + + public function testPreflightAgainstThrottledRouteReturnsRouteCorsHeaders() + { + RateLimiter::for('route-cors-preflight', fn () => Limit::perMinute(1)); + + $this->app['router']->middleware('api')->post('api/throttled-route-cors', ['uses' => fn () => 'OK']) + ->middleware(ThrottleRequests::class.':route-cors-preflight') + ->cors(['origins' => ['https://app.example.com']]); + + $first = $this->call('OPTIONS', 'api/throttled-route-cors', server: [ + 'HTTP_ORIGIN' => 'https://app.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ]); + + $first->assertNoContent() + ->assertHeader('Access-Control-Allow-Origin', 'https://app.example.com'); + + $second = $this->call('OPTIONS', 'api/throttled-route-cors', server: [ + 'HTTP_ORIGIN' => 'https://app.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ]); + + $second->assertNoContent() + ->assertHeader('Access-Control-Allow-Origin', 'https://app.example.com'); + } +} + +#[Cors(origins: ['https://class-attr.example.com'])] +class ControllerWithClassCors +{ + public function index() + { + return 'CLASS_CORS'; + } +} + +#[Cors(origins: ['https://class-attr.example.com'])] +class ControllerWithMethodCors +{ + #[Cors(origins: ['https://method-attr.example.com'])] + public function specific() + { + return 'METHOD_CORS'; + } +} diff --git a/tests/Integration/Routing/CompiledRouteCollectionTest.php b/tests/Integration/Routing/CompiledRouteCollectionTest.php index 355df95d81ca..abb496b9c2e9 100644 --- a/tests/Integration/Routing/CompiledRouteCollectionTest.php +++ b/tests/Integration/Routing/CompiledRouteCollectionTest.php @@ -571,6 +571,25 @@ public function testRouteWithSamePathAndSameMethodButDiffDomainNameWithOptionsMe ], $this->collection()->getRoutesByMethod()); } + public function testCorsMetadataSurvivesCompiledRouteCollection() + { + $corsOptions = ['origins' => ['https://app.example.com'], 'methods' => ['GET']]; + + $this->routeCollection->add( + $this->newRoute('GET', 'api/cors-test', [ + 'uses' => 'CorsController@index', + 'cors' => $corsOptions, + ]) + ); + + $compiled = $this->collection(); + + $request = Request::create('http://localhost/api/cors-test', 'GET'); + $route = $compiled->match($request); + + $this->assertSame($corsOptions, $route->getAction()['cors']); + } + /** * Create a new Route object. *