From 92d7a44cee7b664d83a3b93f0ea58bf875af9197 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 5 Jul 2025 10:34:50 +0200 Subject: [PATCH] Add new CORS configuration options --- config/autoload/cors.global.php | 11 ---- module/Core/config/dependencies.config.php | 1 + module/Core/src/Config/EnvVars.php | 7 +++ .../Core/src/Config/Options/CorsOptions.php | 58 +++++++++++++++++++ module/Rest/config/dependencies.config.php | 2 +- .../src/Middleware/CrossDomainMiddleware.php | 9 +-- .../Middleware/CrossDomainMiddlewareTest.php | 3 +- 7 files changed, 74 insertions(+), 17 deletions(-) delete mode 100644 config/autoload/cors.global.php create mode 100644 module/Core/src/Config/Options/CorsOptions.php diff --git a/config/autoload/cors.global.php b/config/autoload/cors.global.php deleted file mode 100644 index 58ad9428..00000000 --- a/config/autoload/cors.global.php +++ /dev/null @@ -1,11 +0,0 @@ - [ - 'max_age' => 3600, - ], - -]; diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 75c70594..0ad943e9 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -37,6 +37,7 @@ return [ Config\Options\RabbitMqOptions::class => [Config\Options\RabbitMqOptions::class, 'fromEnv'], Config\Options\RobotsOptions::class => [Config\Options\RobotsOptions::class, 'fromEnv'], Config\Options\RealTimeUpdatesOptions::class => [Config\Options\RealTimeUpdatesOptions::class, 'fromEnv'], + Config\Options\CorsOptions::class => [Config\Options\CorsOptions::class, 'fromEnv'], RedirectRule\ShortUrlRedirectRuleService::class => ConfigAbstractFactory::class, RedirectRule\ShortUrlRedirectionResolver::class => ConfigAbstractFactory::class, diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index 7fa1a95b..4f57a721 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -86,6 +86,9 @@ enum EnvVars: string case INITIAL_API_KEY = 'INITIAL_API_KEY'; case SKIP_INITIAL_GEOLITE_DOWNLOAD = 'SKIP_INITIAL_GEOLITE_DOWNLOAD'; case REAL_TIME_UPDATES_TOPICS = 'REAL_TIME_UPDATES_TOPICS'; + case CORS_ALLOW_ORIGIN = 'CORS_ALLOW_ORIGIN'; + case CORS_ALLOW_CREDENTIALS = 'CORS_ALLOW_CREDENTIALS'; + case CORS_MAX_AGE = 'CORS_MAX_AGE'; /** @deprecated Use REDIRECT_EXTRA_PATH */ case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH'; @@ -187,6 +190,10 @@ enum EnvVars: string self::DISABLE_REFERRER_TRACKING, self::DISABLE_UA_TRACKING => false, + self::CORS_ALLOW_ORIGIN => '*', + self::CORS_ALLOW_CREDENTIALS => false, + self::CORS_MAX_AGE => 3600, + default => null, }; } diff --git a/module/Core/src/Config/Options/CorsOptions.php b/module/Core/src/Config/Options/CorsOptions.php new file mode 100644 index 00000000..041503b6 --- /dev/null +++ b/module/Core/src/Config/Options/CorsOptions.php @@ -0,0 +1,58 @@ +'; + + /** @var string[]|'*'|'' */ + public string|array $allowOrigins; + + public function __construct( + string $allowOrigins = '*', + public bool $allowCredentials = false, + public int $maxAge = 3600, + ) { + $this->allowOrigins = $allowOrigins !== '*' && $allowOrigins !== self::ORIGIN_PATTERN + ? splitByComma($allowOrigins) + : $allowOrigins; + } + + public static function fromEnv(): self + { + return new self( + allowOrigins: EnvVars::CORS_ALLOW_ORIGIN->loadFromEnv(), + allowCredentials: EnvVars::CORS_ALLOW_CREDENTIALS->loadFromEnv(), + maxAge: EnvVars::CORS_MAX_AGE->loadFromEnv(), + ); + } + + public function responseWithAllowOrigin(RequestInterface $request, ResponseInterface $response): ResponseInterface + { + if ($this->allowOrigins === '*') { + return $response->withHeader('Access-Control-Allow-Origin', '*'); + } + + $requestOrigin = $request->getHeader('Origin'); + if ( + // The special value means we should allow requests from the origin set in the request's Origin + // header + $this->allowOrigins === self::ORIGIN_PATTERN + // If an array of allowed hosts was provided, set Access-Control-Allow-Origin header only if request's + // Origin header matches one of them + || contains($requestOrigin, $this->allowOrigins) + ) { + return $response->withHeader('Access-Control-Allow-Origin', $requestOrigin); + } + + return $response; + } +} diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index df482a46..058a860d 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -115,7 +115,7 @@ return [ RedirectRule\ShortUrlRedirectRuleService::class, ], - Middleware\CrossDomainMiddleware::class => ['config.cors'], + Middleware\CrossDomainMiddleware::class => [Config\Options\CorsOptions::class], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => [ Config\Options\UrlShortenerOptions::class, ], diff --git a/module/Rest/src/Middleware/CrossDomainMiddleware.php b/module/Rest/src/Middleware/CrossDomainMiddleware.php index d6a51a0c..4e3409d2 100644 --- a/module/Rest/src/Middleware/CrossDomainMiddleware.php +++ b/module/Rest/src/Middleware/CrossDomainMiddleware.php @@ -10,12 +10,13 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use Shlinkio\Shlink\Core\Config\Options\CorsOptions; use function implode; -class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterface +readonly class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterface { - public function __construct(private array $config) + public function __construct(private CorsOptions $options) { } @@ -27,7 +28,7 @@ class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterfa } // Add Allow-Origin header - $response = $response->withHeader('Access-Control-Allow-Origin', '*'); + $response = $this->options->responseWithAllowOrigin($request, $response); if ($request->getMethod() !== self::METHOD_OPTIONS) { return $response; } @@ -40,7 +41,7 @@ class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterfa $corsHeaders = [ 'Access-Control-Allow-Methods' => $this->resolveCorsAllowedMethods($response), 'Access-Control-Allow-Headers' => $request->getHeaderLine('Access-Control-Request-Headers'), - 'Access-Control-Max-Age' => $this->config['max_age'], + 'Access-Control-Max-Age' => $this->options->maxAge, ]; // Options requests should always be empty and have a 204 status code diff --git a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php index b74a435f..615b0132 100644 --- a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Server\RequestHandlerInterface; +use Shlinkio\Shlink\Core\Config\Options\CorsOptions; use Shlinkio\Shlink\Rest\Middleware\CrossDomainMiddleware; class CrossDomainMiddlewareTest extends TestCase @@ -20,7 +21,7 @@ class CrossDomainMiddlewareTest extends TestCase protected function setUp(): void { - $this->middleware = new CrossDomainMiddleware(['max_age' => 1000]); + $this->middleware = new CrossDomainMiddleware(new CorsOptions(maxAge: 1000)); $this->handler = $this->createMock(RequestHandlerInterface::class); }