diff --git a/services/nginx/app/classes/coolify_manager.php b/services/nginx/app/classes/coolify_manager.php index 97609f5a..688b6565 100644 --- a/services/nginx/app/classes/coolify_manager.php +++ b/services/nginx/app/classes/coolify_manager.php @@ -5,6 +5,8 @@ namespace classes; use RuntimeException; use Throwable; +require_once __DIR__ . '/cors_policy.php'; + class coolify_manager { private const KINDS = ['database', 'redis', 'minio']; @@ -1070,7 +1072,9 @@ class coolify_manager $targetPublicUrl, $resourceUuid, self::resourceFirstExposedPort($resource, $target), - $resource['custom_labels'] ?? null + $resource['custom_labels'] ?? null, + $app, + self::gatewayRouteTargetCorsConfig($target) ); $update = $resourceType === 'service' ? $client->updateService($resourceUuid, $updatePayload) @@ -2420,7 +2424,9 @@ class coolify_manager string $publicUrl, string $resourceUuid = '', ?int $port = null, - mixed $existingLabels = null + mixed $existingLabels = null, + string $app = '', + string $corsConfig = '' ): array { $decodedLabels = self::decodeCoolifyLabels($existingLabels); @@ -2435,7 +2441,9 @@ class coolify_manager $publicUrl, $resourceUuid, $routePort, - self::gatewayRouteDefaultCertResolver($publicUrl) + self::gatewayRouteDefaultCertResolver($publicUrl), + $app, + $corsConfig ); if ($labels !== []) { $payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels( @@ -2460,6 +2468,49 @@ class coolify_manager ]; } + private static function gatewayRouteTargetCorsConfig(array $target): string + { + $context = self::jsonDecode($target['deploy_context_json'] ?? null); + $configured = null; + + foreach (['coolify_env', 'runtime_env', 'environment_variables'] as $key) { + $env = $context[$key] ?? null; + if (is_array($env) && array_key_exists('CORS', $env) && is_scalar($env['CORS'])) { + $configured = (string)$env['CORS']; + } + } + + foreach (['coolify_env_file', 'env'] as $key) { + $raw = $context[$key] ?? null; + if (!is_string($raw)) { + continue; + } + foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $line) { + $line = trim((string)$line); + if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) { + continue; + } + [$envKey, $value] = explode('=', $line, 2); + if (trim($envKey) === 'CORS') { + $configured = $value; + } + } + } + + if ($configured === null) { + $runtimeValue = getenv('CORS'); + if ($runtimeValue !== false) { + $configured = $runtimeValue; + } elseif (array_key_exists('CORS', $_ENV ?? [])) { + $configured = (string)$_ENV['CORS']; + } elseif (array_key_exists('CORS', $_SERVER ?? [])) { + $configured = (string)$_SERVER['CORS']; + } + } + + return cors_policy::withRequiredOrigins((string)($configured ?? '')); + } + private static function coolifyProxyUrl(string $publicUrl, ?int $port): string { if ($port === null || $port <= 0) { @@ -2483,7 +2534,9 @@ class coolify_manager string $publicUrl, string $resourceUuid, ?int $port = null, - ?string $certResolver = null + ?string $certResolver = null, + string $app = '', + string $corsConfig = '' ): array { $resourceUuid = self::gatewayRouteLabelId($resourceUuid); @@ -2508,6 +2561,7 @@ class coolify_manager $certResolver = trim((string)($certResolver ?? '')); $httpLabel = 'http-0-' . $resourceUuid; $httpsLabel = 'https-0-' . $resourceUuid; + $isApi = strtolower(trim($app)) === 'api'; $labels = [ 'traefik.enable=true', 'traefik.http.middlewares.gzip.compress=true', @@ -2521,12 +2575,18 @@ class coolify_manager $labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}"; $labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}"; } + $httpsMiddlewares = []; + if ($isApi) { + $corsMiddleware = "{$httpsLabel}-cors"; + $labels = array_merge($labels, cors_policy::traefikHeadersMiddlewareLabels($corsMiddleware, $corsConfig)); + $httpsMiddlewares[] = $corsMiddleware; + } if ($path !== '/') { $labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}"; - $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip"; - } else { - $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip"; + $httpsMiddlewares[] = "{$httpsLabel}-stripprefix"; } + $httpsMiddlewares[] = 'gzip'; + $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=" . implode(',', $httpsMiddlewares); $labels[] = "traefik.http.routers.{$httpsLabel}.tls=true"; if ($certResolver !== '') { $labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}"; diff --git a/services/nginx/app/classes/cors_policy.php b/services/nginx/app/classes/cors_policy.php index e9aebb2b..f346dcb1 100644 --- a/services/nginx/app/classes/cors_policy.php +++ b/services/nginx/app/classes/cors_policy.php @@ -88,6 +88,39 @@ class cors_policy return self::REQUIRED_ALLOWED_ORIGINS; } + /** + * @return array + */ + public static function traefikHeadersMiddlewareLabels(string $middlewareName, string $corsConfig = ''): array + { + $middlewareName = trim($middlewareName); + if ($middlewareName === '' || preg_match('/^[a-zA-Z0-9-]+$/', $middlewareName) !== 1) { + return []; + } + + $allowedHeaders = array_values(array_filter( + array_map('trim', explode(',', self::ALLOWED_HEADERS)), + static fn(string $header): bool => $header !== '' && $header !== '*' + )); + $allowedMethods = array_values(array_filter(array_map('trim', explode(',', self::ALLOWED_METHODS)))); + $exposedHeaders = array_values(array_filter(array_map('trim', explode(',', self::EXPOSED_HEADERS)))); + $prefix = "traefik.http.middlewares.{$middlewareName}.headers"; + $allowedOrigins = self::allowedOrigins($corsConfig); + $originLabel = $allowedOrigins === ['*'] + ? "{$prefix}.accesscontrolalloworiginlistregex=^(https?://[^/]+|capacitor://[^/]+)$" + : "{$prefix}.accesscontrolalloworiginlist=" . implode(',', $allowedOrigins); + + return [ + "{$prefix}.accesscontrolallowcredentials=true", + "{$prefix}.accesscontrolallowheaders=" . implode(',', $allowedHeaders), + "{$prefix}.accesscontrolallowmethods=" . implode(',', $allowedMethods), + $originLabel, + "{$prefix}.accesscontrolexposeheaders=" . implode(',', $exposedHeaders), + "{$prefix}.accesscontrolmaxage=" . self::MAX_AGE_SECONDS, + "{$prefix}.addvaryheader=true", + ]; + } + /** * @return array */ diff --git a/services/nginx/app/classes/release_manager.php b/services/nginx/app/classes/release_manager.php index 285c8e17..286de1dc 100644 --- a/services/nginx/app/classes/release_manager.php +++ b/services/nginx/app/classes/release_manager.php @@ -8408,7 +8408,9 @@ class release_manager $publicUrl, $resourceUuid, $routePort, - self::gatewayRouteDefaultCertResolver($publicUrl) + self::gatewayRouteDefaultCertResolver($publicUrl), + (string)($target['app'] ?? ''), + (string)($this->releaseCoolifyRuntimeEnv($target, $context)['CORS'] ?? '') ); if ($labels !== []) { $payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels( @@ -8461,7 +8463,9 @@ class release_manager string $publicUrl, string $resourceUuid, ?int $port = null, - ?string $certResolver = null + ?string $certResolver = null, + string $app = '', + string $corsConfig = '' ): array { $resourceUuid = self::coolifyRouteLabelId($resourceUuid); @@ -8487,6 +8491,7 @@ class release_manager $httpLabel = 'http-0-' . $resourceUuid; $httpsLabel = 'https-0-' . $resourceUuid; $priority = (string)(1000 + strlen($path)); + $isApi = strtolower(trim($app)) === 'api'; $labels = [ 'traefik.enable=true', 'traefik.http.middlewares.gzip.compress=true', @@ -8501,12 +8506,18 @@ class release_manager $labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}"; $labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}"; } + $httpsMiddlewares = []; + if ($isApi) { + $corsMiddleware = "{$httpsLabel}-cors"; + $labels = array_merge($labels, cors_policy::traefikHeadersMiddlewareLabels($corsMiddleware, $corsConfig)); + $httpsMiddlewares[] = $corsMiddleware; + } if ($path !== '/') { $labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}"; - $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip"; - } else { - $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip"; + $httpsMiddlewares[] = "{$httpsLabel}-stripprefix"; } + $httpsMiddlewares[] = 'gzip'; + $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=" . implode(',', $httpsMiddlewares); $labels[] = "traefik.http.routers.{$httpsLabel}.tls=true"; if ($certResolver !== '') { $labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}"; diff --git a/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php b/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php index 5390626e..66cdbbc7 100644 --- a/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php +++ b/services/nginx/app/tests/Unit/Coolify/CoolifyManagerTest.php @@ -5,6 +5,7 @@ app_require('classes/coolify_manager.php'); use classes\coolify_api_client; use classes\coolify_manager; +use classes\cors_policy; class CoolifyManagerHetznerTargetSetFake { @@ -418,9 +419,10 @@ it('adds explicit Coolify application route labels for gateway API domains', fun $payload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io', 'api-app-uuid', 8080, base64_encode(implode("\n", [ 'custom.keep=true', 'traefik.http.routers.https-0-api-app-uuid.entryPoints=old', + 'traefik.http.routers.https-0-api-app-uuid.middlewares=legacy', 'traefik.http.routers.https-0-api-app-uuid.tls.certresolver=dns-cloudflare', 'traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=9090', - ]))); + ])), 'api', 'https://partner.example.test/app'); $labels = explode("\n", base64_decode($payload['custom_labels'], true)); expect($payload['domains'])->toBe('https://api-v2.truckwash.io:8080') @@ -429,6 +431,12 @@ it('adds explicit Coolify application route labels for gateway API domains', fun ->and($labels)->toContain('custom.keep=true') ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)') ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.entryPoints=https') + ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.middlewares=https-0-api-app-uuid-cors,gzip') + ->and($labels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolallowcredentials=true') + ->and($labels)->toContain( + 'traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolalloworiginlist=' + . implode(',', cors_policy::allowedOrigins('https://partner.example.test/app')) + ) ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt') ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io') ->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080'); @@ -449,21 +457,49 @@ it('adds explicit Coolify application route labels for gateway API domains', fun 'app' => 'frontend', ]))->toBe('https://api-v2.truckwash.io/internal/frontend'); - $pathPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/api', 'api-app-uuid', 8080, ''); + $pathPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/api', 'api-app-uuid', 8080, '', 'api', '*'); $pathLabels = explode("\n", base64_decode($pathPayload['custom_labels'], true)); expect($pathPayload['domains'])->toBe('https://api-v2.truckwash.io:8080/internal/api') ->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)') ->and($pathLabels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-stripprefix.stripprefix.prefixes=/internal/api') - ->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.middlewares=https-0-api-app-uuid-stripprefix,gzip'); + ->and($pathLabels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolalloworiginlistregex=^(https?://[^/]+|capacitor://[^/]+)$') + ->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.middlewares=https-0-api-app-uuid-cors,https-0-api-app-uuid-stripprefix,gzip'); - $frontendPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/frontend', 'frontend-app-uuid', 80, ''); + $frontendPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/frontend', 'frontend-app-uuid', 80, '', 'frontend'); $frontendLabels = explode("\n", base64_decode($frontendPayload['custom_labels'], true)); expect($frontendPayload['domains'])->toBe('https://api-v2.truckwash.io:80/internal/frontend') ->and($frontendLabels)->toContain('traefik.http.routers.https-0-frontend-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/frontend`)') ->and($frontendLabels)->toContain('traefik.http.middlewares.https-0-frontend-app-uuid-stripprefix.stripprefix.prefixes=/internal/frontend') ->and($frontendLabels)->toContain('traefik.http.routers.https-0-frontend-app-uuid.middlewares=https-0-frontend-app-uuid-stripprefix,gzip'); + expect(implode("\n", $frontendLabels))->not->toContain('-cors'); +}); + +it('resolves gateway CORS from deployment context with the same precedence as runtime env', function (): void { + $method = new ReflectionMethod(coolify_manager::class, 'gatewayRouteTargetCorsConfig'); + $config = $method->invoke(null, [ + 'deploy_context_json' => json_encode([ + 'coolify_env' => [ + 'CORS' => 'https://array.example.test/app', + ], + 'coolify_env_file' => "CORS=https://file.example.test/path\nOTHER=value", + 'env' => "CORS=*\n", + ]), + ]); + + expect($config)->toBe('*'); + + $requiredConfig = $method->invoke(null, [ + 'deploy_context_json' => json_encode([ + 'runtime_env' => [ + 'CORS' => 'https://partner.example.test/app', + ], + ]), + ]); + expect(explode(',', $requiredConfig)) + ->toContain('https://partner.example.test') + ->toContain('https://truckwash.io'); }); it('isolates and restores Hetzner load balancer IP targets for gateway certificate bootstrap', function (): void { diff --git a/services/nginx/app/tests/Unit/Infrastructure/CorsPolicyTest.php b/services/nginx/app/tests/Unit/Infrastructure/CorsPolicyTest.php index f7f1bd04..37951320 100644 --- a/services/nginx/app/tests/Unit/Infrastructure/CorsPolicyTest.php +++ b/services/nginx/app/tests/Unit/Infrastructure/CorsPolicyTest.php @@ -27,6 +27,51 @@ it('merges required release and existing frontend origins into configured CORS', expect($origins)->not->toContain('https://api-v2.truckwash.io/master/api'); }); +it('builds credential-safe Traefik CORS middleware labels from the canonical policy', function (): void { + $labels = cors_policy::traefikHeadersMiddlewareLabels( + 'https-0-api-app-cors', + 'https://partner.example.test/app' + ); + $allowedHeadersLabel = current(array_filter( + $labels, + static fn(string $label): bool => str_contains($label, '.accesscontrolallowheaders=') + )); + + expect($labels) + ->toContain('traefik.http.middlewares.https-0-api-app-cors.headers.accesscontrolallowcredentials=true') + ->toContain('traefik.http.middlewares.https-0-api-app-cors.headers.accesscontrolallowmethods=GET,POST,PUT,PATCH,DELETE,OPTIONS') + ->toContain('traefik.http.middlewares.https-0-api-app-cors.headers.accesscontrolexposeheaders=Server-Timing') + ->toContain('traefik.http.middlewares.https-0-api-app-cors.headers.accesscontrolmaxage=86400') + ->toContain('traefik.http.middlewares.https-0-api-app-cors.headers.addvaryheader=true'); + expect($labels) + ->toContain( + 'traefik.http.middlewares.https-0-api-app-cors.headers.accesscontrolalloworiginlist=' + . implode(',', cors_policy::allowedOrigins('https://partner.example.test/app')) + ); + expect($allowedHeadersLabel) + ->toContain('Content-Type') + ->toContain('Authorization') + ->toContain('X-Customer-Number') + ->toContain('X-Release-Trace') + ->toContain('X-Release-Channel') + ->toContain('X-Frontend-Version') + ->toContain('Cache-Control') + ->toContain('Pragma') + ->not->toContain(',*'); + expect(cors_policy::traefikHeadersMiddlewareLabels('invalid middleware'))->toBe([]); +}); + +it('uses an origin-reflecting Traefik regex for credentialed wildcard CORS', function (): void { + $labels = cors_policy::traefikHeadersMiddlewareLabels('https-0-api-app-cors', '*'); + + expect($labels) + ->toContain( + 'traefik.http.middlewares.https-0-api-app-cors.headers.accesscontrolalloworiginlistregex=' + . '^(https?://[^/]+|capacitor://[^/]+)$' + ); + expect(implode("\n", $labels))->not->toContain('accesscontrolalloworiginlist=*'); +}); + it('requires a syntactically exact request origin', function (): void { expect(cors_policy::normalizeRequestOrigin('capacitor://localhost')) ->toBe('capacitor://localhost'); diff --git a/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php index 51efb9da..70b7d8f7 100644 --- a/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php +++ b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php @@ -5,6 +5,7 @@ app_require('classes/release_manager_schema_bootstrap.php'); app_require('classes/coolify_api_client.php'); use classes\coolify_api_client; +use classes\cors_policy; use classes\release_manager; class ReleaseManagerCoolifyEnvFake extends coolify_api_client @@ -584,6 +585,9 @@ it('builds explicit Coolify application route labels for release API targets', f 'branch' => 'master', ], [ 'coolify_ports_exposes' => '8080', + 'runtime_env' => [ + 'CORS' => 'https://partner.example.test/app', + ], ], 'https://api-v2.truckwash.io', 'api-app-uuid', base64_encode('custom.keep=true')); $labels = explode("\n", base64_decode($payload['custom_labels'], true)); @@ -593,9 +597,29 @@ it('builds explicit Coolify application route labels for release API targets', f ->and($labels)->toContain('custom.keep=true') ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)') ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.priority=1001') + ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.middlewares=https-0-api-app-uuid-cors,gzip') + ->and($labels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolallowcredentials=true') + ->and($labels)->toContain( + 'traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolalloworiginlist=' + . implode(',', cors_policy::allowedOrigins('https://partner.example.test/app')) + ) ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt') ->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io') ->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080'); + + $frontendPayload = $payloadMethod->invoke($manager, [ + 'channel_slug' => 'internal', + 'app' => 'frontend', + 'repository' => 'copenhagentruckwash/pleno-vue', + 'branch' => 'master', + ], [ + 'coolify_ports_exposes' => '80', + ], 'https://api-v2.truckwash.io/internal/frontend', 'frontend-app-uuid', ''); + $frontendLabels = explode("\n", base64_decode($frontendPayload['custom_labels'], true)); + + expect($frontendLabels) + ->toContain('traefik.http.routers.https-0-frontend-app-uuid.middlewares=https-0-frontend-app-uuid-stripprefix,gzip'); + expect(implode("\n", $frontendLabels))->not->toContain('-cors'); }); it('updates existing frontend Coolify applications away from legacy Nixpacks detection', function (): void { @@ -1560,12 +1584,13 @@ it('requires non-default release channel runtime URLs and preserves load balance 'https://api-v2.truckwash.io/internal/api', 'release-api-internal', 80, - 'letsencrypt' + 'letsencrypt', + 'api' ); expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)'); expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.priority=1013'); expect($labels)->toContain('traefik.http.middlewares.https-0-release-api-internal-stripprefix.stripprefix.prefixes=/internal/api'); - expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.middlewares=https-0-release-api-internal-stripprefix,gzip'); + expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.middlewares=https-0-release-api-internal-cors,https-0-release-api-internal-stripprefix,gzip'); $source = file(app_path('classes/release_manager.php')); $methodSource = implode('', array_slice(