From 4f9ad86e52c219bb6f000452f5c8f2b4da4c97ad Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:05:18 +0100 Subject: [PATCH] fix(applications): allow environment variable references in image tags Closes #10642 --- .../Api/ApplicationsController.php | 2 +- app/Rules/DockerImageFormat.php | 15 ++++--- app/Services/DockerImageParser.php | 16 +++---- app/Support/ValidationPatterns.php | 24 +++++++--- ...neralDockerRegistryImageValidationTest.php | 14 ++++++ tests/Unit/DockerImageParserTest.php | 12 +++++ .../DockerImageReferenceValidationTest.php | 45 +++++++++++++++++++ 7 files changed, 109 insertions(+), 19 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 91161f4000..d5114dd7a3 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2395,7 +2395,7 @@ class ApplicationsController extends Controller ]))->setStatusCode(201); } elseif ($type === 'dockerimage') { $validationRules = [ - 'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat], + 'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat(allowVariableTag: true)], 'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(), 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable', ]; diff --git a/app/Rules/DockerImageFormat.php b/app/Rules/DockerImageFormat.php index 038cc27619..4c1acf2066 100644 --- a/app/Rules/DockerImageFormat.php +++ b/app/Rules/DockerImageFormat.php @@ -9,6 +9,11 @@ use Illuminate\Translation\PotentiallyTranslatedString; class DockerImageFormat implements ValidationRule { + /** + * @param bool $allowVariableTag Allow $VAR tags. Use it only for images that Docker Compose runs, because only Compose fills in the variables. + */ + public function __construct(private bool $allowVariableTag = false) {} + /** * Run the validation rule. * @@ -35,15 +40,15 @@ class DockerImageFormat implements ValidationRule if (preg_match('/\A(.+)@sha256:([a-f0-9]{64})\z/i', $value, $matches) === 1) { $imageName = $matches[1]; } else { - $lastColon = strrpos($value, ':'); $lastSlash = strrpos($value, '/'); - if ($lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash)) { - $imageName = substr($value, 0, $lastColon); - $tag = substr($value, $lastColon + 1); + $tagColon = strpos($value, ':', $lastSlash === false ? 0 : $lastSlash + 1); + if ($tagColon !== false) { + $imageName = substr($value, 0, $tagColon); + $tag = substr($value, $tagColon + 1); } } - if (! ValidationPatterns::isValidDockerImageName($imageName) || ! ValidationPatterns::isValidDockerImageTag($tag)) { + if (! ValidationPatterns::isValidDockerImageName($imageName) || ! ValidationPatterns::isValidDockerImageTag($tag, allowVariables: $this->allowVariableTag)) { $fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.'); } } diff --git a/app/Services/DockerImageParser.php b/app/Services/DockerImageParser.php index b483c979a3..b74a96f7f3 100644 --- a/app/Services/DockerImageParser.php +++ b/app/Services/DockerImageParser.php @@ -20,15 +20,15 @@ class DockerImageParser $this->tag = $matches[2]; $this->isImageHash = true; } else { - // Split by : to handle the tag, but be careful with registry ports - $lastColon = strrpos($imageString, ':'); - $hasSlash = str_contains($imageString, '/'); + // The tag starts at the first colon after the last slash. A colon before + // the last slash is a registry port, and a later colon can be part of a + // ${VAR:-default} tag. + $lastSlash = strrpos($imageString, '/'); + $tagColon = strpos($imageString, ':', $lastSlash === false ? 0 : $lastSlash + 1); - // If the last colon appears after the last slash, it's a tag - // Otherwise it might be a port in the registry URL - if ($lastColon !== false && (! $hasSlash || $lastColon > strrpos($imageString, '/'))) { - $mainPart = substr($imageString, 0, $lastColon); - $this->tag = substr($imageString, $lastColon + 1); + if ($tagColon !== false) { + $mainPart = substr($imageString, 0, $tagColon); + $this->tag = substr($imageString, $tagColon + 1); // Check if the tag is a SHA256 hash $this->isImageHash = $this->isSha256Hash($this->tag); diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 53bc945c18..77c89f2fbe 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -144,12 +144,23 @@ class ValidationPatterns public const DOCKER_IMAGE_NAME_PATTERN = '/\A(?=.{1,255}\z)(?:(?:(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\[[0-9a-f:.]+\])(?::[0-9]+)?\/)?[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*)(?:@sha256)?\z/'; /** - * Pattern for Docker image tags. + * Pattern for literal Docker image tags. * * Docker tags may contain letters, digits, underscores, dots, and hyphens, * must start with an alphanumeric/underscore, and are limited to 128 chars. */ - public const DOCKER_IMAGE_TAG_PATTERN = '/\A[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}\z/'; + public const DOCKER_IMAGE_LITERAL_TAG_PATTERN = '/\A[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}\z/'; + + /** + * Pattern for Docker image tags of applications. + * + * Same as DOCKER_IMAGE_LITERAL_TAG_PATTERN, but + * $VAR, ${VAR}, ${VAR:-default} and ${VAR-default} references are also allowed, + * because Docker Compose fills them in from the application's environment + * variables at deploy time. A default may only hold tag characters. Other + * expansions ($(...), ${VAR:?...}, nested references, $1, $$) are rejected. + */ + public const DOCKER_IMAGE_TAG_PATTERN = '/\A(?=.{1,128}\z)(?:[A-Za-z0-9_]|\$(?:[A-Za-z_][A-Za-z0-9_]*+|\{[A-Za-z_][A-Za-z0-9_]*+(?::?-[A-Za-z0-9_.-]*+)?\}))(?:[A-Za-z0-9_.-]|\$(?:[A-Za-z_][A-Za-z0-9_]*+|\{[A-Za-z_][A-Za-z0-9_]*+(?::?-[A-Za-z0-9_.-]*+)?\}))*\z/'; /** * Normalize environment variable keys before validation and storage. @@ -312,7 +323,7 @@ class ValidationPatterns { return [ "{$nameField}.regex" => 'The Docker registry image name must be a valid image repository without a tag and may not contain shell metacharacters.', - "{$tagField}.regex" => 'The Docker registry image tag must be a valid Docker tag and may not contain shell metacharacters.', + "{$tagField}.regex" => 'The Docker registry image tag must be a valid Docker tag or a $VARIABLE reference and may not contain shell metacharacters.', ]; } @@ -348,14 +359,17 @@ class ValidationPatterns /** * Check if a string is a valid Docker image tag. + * + * Set $allowVariables to false where Docker Compose does not fill in the tag, + * for example for images that Coolify runs with docker run. */ - public static function isValidDockerImageTag(?string $value): bool + public static function isValidDockerImageTag(?string $value, bool $allowVariables = true): bool { if (blank($value)) { return true; } - return preg_match(self::DOCKER_IMAGE_TAG_PATTERN, $value) === 1; + return preg_match($allowVariables ? self::DOCKER_IMAGE_TAG_PATTERN : self::DOCKER_IMAGE_LITERAL_TAG_PATTERN, $value) === 1; } /** diff --git a/tests/Feature/Livewire/ApplicationGeneralDockerRegistryImageValidationTest.php b/tests/Feature/Livewire/ApplicationGeneralDockerRegistryImageValidationTest.php index 3ed6b85448..a8a08285e2 100644 --- a/tests/Feature/Livewire/ApplicationGeneralDockerRegistryImageValidationTest.php +++ b/tests/Feature/Livewire/ApplicationGeneralDockerRegistryImageValidationTest.php @@ -53,3 +53,17 @@ it('uses safe docker registry image validation rules in the application general ->and($validator->errors()->has('dockerRegistryImageName'))->toBeTrue() ->and($validator->errors()->has('dockerRegistryImageTag'))->toBeTrue(); }); + +it('accepts an environment variable reference as the docker registry image tag in the application general form', function () { + $component = new General; + $method = new ReflectionMethod($component, 'rules'); + $rules = $method->invoke($component); + + $validator = validator([ + 'dockerRegistryImageTag' => '$API_VERSION', + ], [ + 'dockerRegistryImageTag' => $rules['dockerRegistryImageTag'], + ]); + + expect($validator->passes())->toBeTrue(); +}); diff --git a/tests/Unit/DockerImageParserTest.php b/tests/Unit/DockerImageParserTest.php index 43de9797f2..dea8ffddad 100644 --- a/tests/Unit/DockerImageParserTest.php +++ b/tests/Unit/DockerImageParserTest.php @@ -139,3 +139,15 @@ it('correctly parses image when user provides digest-decorated name with colon h ->and($parser->getTag())->toBe($hash) ->and($parser->isImageHash())->toBeTrue(); }); + +it('parses a tag with a default value as one tag', function (string $imageString, string $imageName) { + $parser = new DockerImageParser; + $parser->parse($imageString); + + expect($parser->getFullImageNameWithoutTag())->toBe($imageName) + ->and($parser->getTag())->toBe('${API_VERSION:-latest}') + ->and($parser->isImageHash())->toBeFalse(); +})->with([ + 'image' => ['nginx:${API_VERSION:-latest}', 'nginx'], + 'registry image with port' => ['localhost:5000/team/app:${API_VERSION:-latest}', 'localhost:5000/team/app'], +]); diff --git a/tests/Unit/DockerImageReferenceValidationTest.php b/tests/Unit/DockerImageReferenceValidationTest.php index 05f2bce7ff..b1e65c5eb5 100644 --- a/tests/Unit/DockerImageReferenceValidationTest.php +++ b/tests/Unit/DockerImageReferenceValidationTest.php @@ -46,6 +46,11 @@ it('accepts valid docker registry image tags', function (string $tag) { 'uppercase and underscore' => 'PR_123', 'sha256 hash' => '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 'legacy sha256 prefixed hash' => 'sha256-1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + 'environment variable reference' => '$API_VERSION', + 'braced environment variable reference' => '${API_VERSION}', + 'environment variable reference with literal parts' => 'v${API_VERSION}-alpine', + 'environment variable reference with default for empty or unset' => '${API_VERSION:-latest}', + 'environment variable reference with default for unset' => '${API_VERSION-1.27-alpine}', ]); it('rejects docker registry image tags with shell metacharacters', function (string $tag) { @@ -57,6 +62,14 @@ it('rejects docker registry image tags with shell metacharacters', function (str 'pipe' => 'latest|id', 'logical and' => 'latest&&id', 'newline' => "latest\nid", + 'bare dollar' => '$', + 'positional parameter' => '$1', + 'special parameter' => '$$', + 'unclosed braced reference' => '${API_VERSION', + 'parameter expansion with command substitution default' => '${API_VERSION:-$(id)}', + 'parameter expansion with nested reference default' => '${API_VERSION:-${OTHER}}', + 'parameter expansion with required error' => '${API_VERSION:?missing}', + 'parameter expansion with assignment' => '${API_VERSION:=latest}', ]); it('accepts supported full docker image reference formats', function (string $imageReference) { @@ -76,6 +89,21 @@ it('accepts supported full docker image reference formats', function (string $im 'IPv6 registry port image with tag' => '[2a01:4f8::1]:5000/app:latest', ]); +it('accepts an environment variable tag in a full docker image reference only when variable tags are allowed', function () { + $validate = function (DockerImageFormat $rule): array { + $failures = []; + + $rule->validate('image', 'ghcr.io/user/app:${API_VERSION:-latest}', function (string $message) use (&$failures): void { + $failures[] = $message; + }); + + return $failures; + }; + + expect($validate(new DockerImageFormat(allowVariableTag: true)))->toBeEmpty() + ->and($validate(new DockerImageFormat))->not->toBeEmpty(); +}); + it('rejects unsupported full docker image reference formats', function (string $imageReference) { $failures = []; @@ -115,3 +143,20 @@ it('stops deployments when a stored docker registry image value is unsafe', func expect(fn () => $method->invoke($job))->toThrow(DeploymentException::class); }); + +it('allows deployments when the stored docker registry image tag is an environment variable reference', function () { + $job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor(); + + $jobReflection = new ReflectionClass($job); + foreach ([ + 'application' => new Application([ + 'docker_registry_image_name' => 'ghcr.io/user/app', + 'docker_registry_image_tag' => '$API_VERSION', + ]), + 'dockerImagePreviewTag' => null, + ] as $property => $value) { + $jobReflection->getProperty($property)->setValue($job, $value); + } + + $jobReflection->getMethod('validateDockerRegistryImageConfiguration')->invoke($job); +})->throwsNoExceptions();