toBeTrue(); })->with([ 'single component' => 'nginx', 'namespace image' => 'library/nginx', 'ghcr image' => 'ghcr.io/coollabsio/coolify', 'repository component with repeated hyphens' => 'ghcr.io/acme/my--service', 'registry with port' => 'registry.example.com:5000/team/app', 'IPv6 registry' => '[2a01:4f8::1]/team/app', 'IPv6 registry with port' => '[2a01:4f8::1]:5000/app', 'IPv4-mapped IPv6 registry' => '[::ffff:192.0.2.1]:5000/app', 'digest marker used by existing dockerimage records' => 'nginx@sha256', ]); it('rejects docker registry image names with shell metacharacters', function (string $imageName) { expect(ValidationPatterns::isValidDockerImageName($imageName))->toBeFalse(); })->with([ 'command substitution' => 'coolify/poc$(touch /tmp/pwned)', 'semicolon' => 'coolify/poc;id', 'backticks' => 'coolify/poc`id`', 'pipe' => 'coolify/poc|id', 'logical and' => 'coolify/poc&&id', 'newline' => "coolify/poc\nid", 'space' => 'coolify/poc image', 'tag in image-name-only field' => 'coolify/poc:latest', 'IPv6 registry with command substitution' => '[2a01:4f8::1$(id)]:5000/app', 'IPv6 registry with letters outside hex' => '[2a01:4f8::zz]/app', 'unclosed IPv6 registry' => '[2a01:4f8::1:5000/app', 'bare IPv6 registry' => '2a01:4f8::1/app', ]); it('accepts valid docker registry image tags', function (string $tag) { expect(ValidationPatterns::isValidDockerImageTag($tag))->toBeTrue(); })->with([ 'latest' => 'latest', 'version' => 'v1.2.3', '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) { expect(ValidationPatterns::isValidDockerImageTag($tag))->toBeFalse(); })->with([ 'command substitution' => 'latest$(touch /tmp/pwned)', 'semicolon' => 'latest;id', 'backticks' => 'latest`id`', '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) { $failures = []; (new DockerImageFormat)->validate('image', $imageReference, function (string $message) use (&$failures): void { $failures[] = $message; }); expect($failures)->toBeEmpty(); })->with([ 'image with tag' => 'nginx:latest', 'registry image with tag' => 'ghcr.io/user/app:v1.2.3', 'image with sha256 digest' => 'nginx@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 'registry image with sha256 digest' => 'ghcr.io/user/app@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 'registry port image with tag' => 'localhost:5000/app:latest', '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 = []; (new DockerImageFormat)->validate('image', $imageReference, function (string $message) use (&$failures): void { $failures[] = $message; }); expect($failures)->not->toBeEmpty(); })->with([ 'colon sha256 marker' => 'nginx:sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 'command substitution' => 'nginx:latest$(touch /tmp/pwned)', 'newline' => "nginx:latest\nid", ]); it('stops deployments when a stored docker registry image value is unsafe', function () { $job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor(); $application = new Application([ 'docker_registry_image_name' => 'coolify/poc$(touch /tmp/pwned)', 'docker_registry_image_tag' => 'latest', ]); $deploymentQueue = new ApplicationDeploymentQueue([ 'docker_registry_image_tag' => null, ]); $jobReflection = new ReflectionClass($job); foreach ([ 'application' => $application, 'application_deployment_queue' => $deploymentQueue, 'dockerImagePreviewTag' => null, ] as $property => $value) { $reflectionProperty = $jobReflection->getProperty($property); $reflectionProperty->setValue($job, $value); } $method = $jobReflection->getMethod('validateDockerRegistryImageConfiguration'); 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();