Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Illuminate/Foundation/Configuration/Middleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
106 changes: 101 additions & 5 deletions src/Illuminate/Http/Middleware/HandleCors.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
Expand All @@ -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.
*
Expand Down
51 changes: 51 additions & 0 deletions src/Illuminate/Http/Middleware/HandleRouteCors.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace Illuminate\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Routing\Route;

class HandleRouteCors extends HandleCors
{
/**
* Handle the incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return \Illuminate\Http\Response
*/
public function handle($request, Closure $next)
{
if ($this->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();
}
}
14 changes: 7 additions & 7 deletions src/Illuminate/Routing/AbstractRouteCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, \Illuminate\Routing\Route>
*/
protected function checkForAlternateVerbs($request)
{
Expand All @@ -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)
));
}

Expand Down Expand Up @@ -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<string, \Illuminate\Routing\Route> $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)]);
Expand Down
51 changes: 51 additions & 0 deletions src/Illuminate/Routing/Attributes/Cors.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace Illuminate\Routing\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
class Cors
{
/**
* @param array<string>|null $origins
* @param array<string>|null $methods
* @param array<string>|null $headers
* @param array<string>|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<string>,
* methods?: array<string>,
* headers?: array<string>,
* exposed_headers?: array<string>,
* 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));
}
}
59 changes: 59 additions & 0 deletions src/Illuminate/Routing/Route.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
6 changes: 5 additions & 1 deletion src/Illuminate/Routing/RouteGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
2 changes: 2 additions & 0 deletions src/Illuminate/Routing/RouteRegistrar.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -71,6 +72,7 @@ class RouteRegistrar
'as',
'can',
'controller',
'cors',
'domain',
'middleware',
'missing',
Expand Down
Loading
Loading