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
161 changes: 93 additions & 68 deletions src/http/src/Cors.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,19 @@

namespace Hypervel\Http;

use Hyperf\Context\Context;
use Hypervel\Context\ApplicationContext;
use Hypervel\Http\Contracts\RequestContract;
use Hypervel\Http\Contracts\ResponseContract;
use Psr\Http\Message\ResponseInterface;

/**
* CORS service with coroutine-safe options storage.
*
* Options are stored in coroutine-local Context as an immutable CorsOptions
* object, making this service safe for concurrent use in Swoole's coroutine
* environment. Each request gets its own isolated CORS configuration.
*
* @phpstan-type CorsInputOptions array{
* 'allowedOrigins'?: string[],
* 'allowedOriginsPatterns'?: string[],
Expand All @@ -36,87 +43,89 @@
*/
class Cors
{
/** @var string[] */
private array $allowedOrigins = [];

/** @var string[] */
private array $allowedOriginsPatterns = [];

/** @var string[] */
private array $allowedMethods = [];

/** @var string[] */
private array $allowedHeaders = [];

/** @var string[] */
private array $exposedHeaders = [];

private bool $supportsCredentials = false;

private ?int $maxAge = 0;

private bool $allowAllOrigins = false;

private bool $allowAllMethods = false;

private bool $allowAllHeaders = false;
/**
* Context key for storing CORS options.
*/
private const CONTEXT_KEY = '__cors.options';

/**
* @param CorsInputOptions $options
*/
public function __construct(array $options = [])
{
if ($options) {
if ($options !== []) {
$this->setOptions($options);
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constructor initializing options by calling setOptions directly introduces an anti-pattern in the coroutine-safe design. When Cors is instantiated as a singleton service (likely during app bootstrap), passing options to the constructor will store them in the bootstrap coroutine's context, which is never used since HandleCors middleware calls setOptions per-request. Consider either: (1) removing the ability to pass options to the constructor and documenting that setOptions must be called explicitly, or (2) deprecating constructor parameters in favor of always calling setOptions in the middleware. The current design is confusing because it suggests options can be set during construction, but those options may not be available in request coroutines.

Suggested change
$this->setOptions($options);
// Constructor options are deprecated and ignored to preserve coroutine-safe design.
// Use setOptions() per request (e.g. in middleware) instead.
@trigger_error(
'Passing options to ' . __CLASS__ . '::__construct() is deprecated and ignored. '
. 'Call setOptions() per request instead.',
E_USER_DEPRECATED
);

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@albertcht The observation is partially correct - constructor options do write to bootstrap context - but it's harmless. I don't think it's an anti-pattern. The middleware calls setOptions() per-request which writes to the correct request context, so the bootstrap write is just ignored.

Constructor options are useful for testing (used throughout this PR's tests) and standalone usage. A deprecation warning would just spam test output for no real benefit.

}
}

/**
* Set CORS options for the current request.
*
* Options are normalized and stored in coroutine-local Context as an
* immutable CorsOptions object, ensuring isolation between concurrent
* requests. Type validation is handled by CorsOptions' typed properties.
*
* @param CorsInputOptions $options
*/
public function setOptions(array $options): void
{
$this->allowedOrigins = $options['allowedOrigins'] ?? $options['allowed_origins'] ?? $this->allowedOrigins;
$this->allowedOriginsPatterns
= $options['allowedOriginsPatterns'] ?? $options['allowed_origins_patterns'] ?? $this->allowedOriginsPatterns;
$this->allowedMethods = $options['allowedMethods'] ?? $options['allowed_methods'] ?? $this->allowedMethods;
$this->allowedHeaders = $options['allowedHeaders'] ?? $options['allowed_headers'] ?? $this->allowedHeaders;
$this->supportsCredentials
= $options['supportsCredentials'] ?? $options['supports_credentials'] ?? $this->supportsCredentials;

$maxAge = $this->maxAge;
$current = $this->getOptions();

$allowedOrigins = $options['allowedOrigins'] ?? $options['allowed_origins'] ?? $current->allowedOrigins;
$allowedOriginsPatterns = $options['allowedOriginsPatterns'] ?? $options['allowed_origins_patterns'] ?? $current->allowedOriginsPatterns;
$allowedMethods = $options['allowedMethods'] ?? $options['allowed_methods'] ?? $current->allowedMethods;
$allowedHeaders = $options['allowedHeaders'] ?? $options['allowed_headers'] ?? $current->allowedHeaders;
$supportsCredentials = $options['supportsCredentials'] ?? $options['supports_credentials'] ?? $current->supportsCredentials;

$maxAge = $current->maxAge;
if (array_key_exists('maxAge', $options)) {
$maxAge = $options['maxAge'];
} elseif (array_key_exists('max_age', $options)) {
$maxAge = $options['max_age'];
}
$this->maxAge = $maxAge === null ? null : (int) $maxAge;

$exposedHeaders = $options['exposedHeaders'] ?? $options['exposed_headers'] ?? $this->exposedHeaders;
$this->exposedHeaders = $exposedHeaders === false ? [] : $exposedHeaders;
$maxAge = $maxAge === null ? null : (int) $maxAge;

$this->normalizeOptions();
}
$exposedHeaders = $options['exposedHeaders'] ?? $options['exposed_headers'] ?? $current->exposedHeaders;
$exposedHeaders = $exposedHeaders === false ? [] : $exposedHeaders;

private function normalizeOptions(): void
{
// Normalize case
$this->allowedHeaders = array_map('strtolower', $this->allowedHeaders);
$this->allowedMethods = array_map('strtoupper', $this->allowedMethods);
$allowedHeaders = array_map('strtolower', $allowedHeaders);
$allowedMethods = array_map('strtoupper', $allowedMethods);

// Normalize ['*'] to true
$this->allowAllOrigins = in_array('*', $this->allowedOrigins);
$this->allowAllHeaders = in_array('*', $this->allowedHeaders);
$this->allowAllMethods = in_array('*', $this->allowedMethods);
// Normalize ['*'] to flags
$allowAllOrigins = in_array('*', $allowedOrigins);
$allowAllHeaders = in_array('*', $allowedHeaders);
$allowAllMethods = in_array('*', $allowedMethods);

// Transform wildcard pattern
if (! $this->allowAllOrigins) {
foreach ($this->allowedOrigins as $origin) {
// Transform wildcard patterns in origins
if (! $allowAllOrigins) {
foreach ($allowedOrigins as $origin) {
if (strpos($origin, '*') !== false) {
$this->allowedOriginsPatterns[] = $this->convertWildcardToPattern($origin);
$allowedOriginsPatterns[] = $this->convertWildcardToPattern($origin);
}
}
}

Context::set(self::CONTEXT_KEY, new CorsOptions(
allowedOrigins: $allowedOrigins,
allowedOriginsPatterns: $allowedOriginsPatterns,
supportsCredentials: $supportsCredentials,
allowedHeaders: $allowedHeaders,
allowedMethods: $allowedMethods,
exposedHeaders: $exposedHeaders,
maxAge: $maxAge,
allowAllOrigins: $allowAllOrigins,
allowAllHeaders: $allowAllHeaders,
allowAllMethods: $allowAllMethods,
));
}

/**
* Get the current CORS options from Context.
*/
private function getOptions(): CorsOptions
{
return Context::get(self::CONTEXT_KEY) ?? new CorsOptions();
}

/**
Expand Down Expand Up @@ -172,17 +181,19 @@ public function addPreflightRequestHeaders(ResponseInterface $response, RequestC

public function isOriginAllowed(RequestContract $request): bool
{
if ($this->allowAllOrigins === true) {
$options = $this->getOptions();

if ($options->allowAllOrigins) {
return true;
}

$origin = $request->header('Origin') ?: '';

if (in_array($origin, $this->allowedOrigins)) {
if (in_array($origin, $options->allowedOrigins)) {
return true;
}

foreach ($this->allowedOriginsPatterns as $pattern) {
foreach ($options->allowedOriginsPatterns as $pattern) {
if (preg_match($pattern, $origin)) {
return true;
}
Expand All @@ -205,12 +216,14 @@ public function addActualRequestHeaders(ResponseInterface $response, RequestCont

private function configureAllowedOrigin(ResponseInterface $response, RequestContract $request): ResponseInterface
{
if ($this->allowAllOrigins === true && ! $this->supportsCredentials) {
$options = $this->getOptions();

if ($options->allowAllOrigins && ! $options->supportsCredentials) {
// Safe+cacheable, allow everything
$response = $response->withHeader('Access-Control-Allow-Origin', '*');
} elseif ($this->isSingleOriginAllowed()) {
// Single origins can be safely set
$response = $response->withHeader('Access-Control-Allow-Origin', array_values($this->allowedOrigins)[0]);
$response = $response->withHeader('Access-Control-Allow-Origin', array_values($options->allowedOrigins)[0]);
} else {
// For dynamic headers, set the requested Origin header when set and allowed
if ($this->isCorsRequest($request) && $this->isOriginAllowed($request)) {
Expand All @@ -225,40 +238,48 @@ private function configureAllowedOrigin(ResponseInterface $response, RequestCont

private function isSingleOriginAllowed(): bool
{
if ($this->allowAllOrigins === true || count($this->allowedOriginsPatterns) > 0) {
$options = $this->getOptions();

if ($options->allowAllOrigins || count($options->allowedOriginsPatterns) > 0) {
return false;
}

return count($this->allowedOrigins) === 1;
return count($options->allowedOrigins) === 1;
}

private function configureAllowedMethods(ResponseInterface $response, RequestContract $request): ResponseInterface
{
if ($this->allowAllMethods === true) {
$options = $this->getOptions();

if ($options->allowAllMethods) {
$allowMethods = strtoupper($request->header('Access-Control-Request-Method'));
$response = $this->varyHeader($response, 'Access-Control-Request-Method');
} else {
$allowMethods = implode(', ', $this->allowedMethods);
$allowMethods = implode(', ', $options->allowedMethods);
}

return $response->withHeader('Access-Control-Allow-Methods', $allowMethods);
}

private function configureAllowedHeaders(ResponseInterface $response, RequestContract $request): ResponseInterface
{
if ($this->allowAllHeaders === true) {
$options = $this->getOptions();

if ($options->allowAllHeaders) {
$allowHeaders = $request->header('Access-Control-Request-Headers');
$this->varyHeader($response, 'Access-Control-Request-Headers');
} else {
$allowHeaders = implode(', ', $this->allowedHeaders);
$allowHeaders = implode(', ', $options->allowedHeaders);
}

return $response->withHeader('Access-Control-Allow-Headers', $allowHeaders);
}

private function configureAllowCredentials(ResponseInterface $response, RequestContract $request): ResponseInterface
{
if ($this->supportsCredentials) {
$options = $this->getOptions();

if ($options->supportsCredentials) {
$response = $response->withHeader('Access-Control-Allow-Credentials', 'true');
}

Expand All @@ -267,17 +288,21 @@ private function configureAllowCredentials(ResponseInterface $response, RequestC

private function configureExposedHeaders(ResponseInterface $response, RequestContract $request): ResponseInterface
{
if ($this->exposedHeaders) {
$response = $response->withHeader('Access-Control-Expose-Headers', implode(', ', $this->exposedHeaders));
$options = $this->getOptions();

if ($options->exposedHeaders !== []) {
$response = $response->withHeader('Access-Control-Expose-Headers', implode(', ', $options->exposedHeaders));
}

return $response;
}

private function configureMaxAge(ResponseInterface $response, RequestContract $request): ResponseInterface
{
if ($this->maxAge !== null) {
$response = $response->withHeader('Access-Control-Max-Age', (string) $this->maxAge);
$options = $this->getOptions();

if ($options->maxAge !== null) {
$response = $response->withHeader('Access-Control-Max-Age', (string) $options->maxAge);
}

return $response;
Expand Down
41 changes: 41 additions & 0 deletions src/http/src/CorsOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace Hypervel\Http;

/**
* Immutable value object for normalized CORS options.
*
* Used for coroutine-safe storage in Context. Type validation is handled
* automatically by PHP's type system via typed properties - passing invalid
* types to the constructor will throw a TypeError.
*/
final readonly class CorsOptions
{
/**
* @param string[] $allowedOrigins Specific origins allowed to make requests
* @param string[] $allowedOriginsPatterns Regex patterns for matching allowed origins
* @param bool $supportsCredentials Whether to allow credentials (cookies, auth headers)
* @param string[] $allowedHeaders Headers the client is allowed to send
* @param string[] $allowedMethods HTTP methods the client is allowed to use
* @param string[] $exposedHeaders Headers the client is allowed to read from response
* @param null|int $maxAge How long preflight results can be cached (null = don't send header)
* @param bool $allowAllOrigins Whether '*' was specified for origins
* @param bool $allowAllHeaders Whether '*' was specified for headers
* @param bool $allowAllMethods Whether '*' was specified for methods
*/
public function __construct(
public array $allowedOrigins = [],
public array $allowedOriginsPatterns = [],
public bool $supportsCredentials = false,
public array $allowedHeaders = [],
public array $allowedMethods = [],
public array $exposedHeaders = [],
public ?int $maxAge = 0,
public bool $allowAllOrigins = false,
public bool $allowAllHeaders = false,
public bool $allowAllMethods = false,
) {
Comment on lines +28 to +39
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The array properties lack runtime type enforcement for their elements. While the docblocks specify string[] for most arrays, PHP's type system cannot enforce array element types in constructor parameters. Consider adding validation in the constructor to ensure all elements are strings for string[] properties, or at minimum add a note in the docblock that the caller is responsible for ensuring correct array element types.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@albertcht This is technically true but not worth addressing in my opinion. The docblock accurately describes PHP's behavior, and setOptions() already calls array_map('strtolower', ...) on headers/methods which would fail on non-strings. Since these values come from config files (developer-controlled, not user input), any type issues would surface immediately in dev. Adding runtime validation for every array element is overkill here.

}
}
18 changes: 15 additions & 3 deletions src/http/src/Middleware/HandleCors.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@ public function __construct(
protected RequestContract $request,
protected Cors $cors,
) {
$this->cors->setOptions(
$this->config = $container->get(ConfigInterface::class)->get('cors', [])
);
$this->config = $container->get(ConfigInterface::class)->get('cors', []);
}

public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
Expand All @@ -37,6 +35,9 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
return $handler->handle($request);
}

// Set CORS options per-request for coroutine isolation
$this->cors->setOptions($this->getCorsConfig());

if ($this->cors->isPreflightRequest($this->request)) {
$response = $this->cors->handlePreflightRequest($this->request);
return $this->cors->varyHeader($response, 'Access-Control-Request-Method');
Expand Down Expand Up @@ -101,4 +102,15 @@ protected function getPathsByHost(string $host): array
return is_string($path);
});
}

/**
* Get the CORS configuration.
*
* Override this method to provide custom CORS configuration,
* such as tenant-specific allowed origins.
*/
protected function getCorsConfig(): array
{
return $this->config;
}
}
Loading