fix(applications): harden image validation

This commit is contained in:
Andras Bacsai
2026-06-01 15:12:58 +02:00
parent 4d0be415c8
commit d72c1e2a47
10 changed files with 392 additions and 35 deletions
@@ -17,6 +17,7 @@ use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey; use App\Models\PrivateKey;
use App\Models\Project; use App\Models\Project;
use App\Models\Server; use App\Models\Server;
use App\Rules\DockerImageFormat;
use App\Rules\ValidGitBranch; use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl; use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser; use App\Services\DockerImageParser;
@@ -1790,8 +1791,8 @@ class ApplicationsController extends Controller
]))->setStatusCode(201); ]))->setStatusCode(201);
} elseif ($type === 'dockerimage') { } elseif ($type === 'dockerimage') {
$validationRules = [ $validationRules = [
'docker_registry_image_name' => 'string|required', 'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat],
'docker_registry_image_tag' => 'string', 'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
]; ];
$validationRules = array_merge(sharedDataApplications(), $validationRules); $validationRules = array_merge(sharedDataApplications(), $validationRules);
+16
View File
@@ -220,6 +220,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->restart_only = $this->restart_only && $this->application->build_pack !== 'dockerimage' && $this->application->build_pack !== 'dockerfile'; $this->restart_only = $this->restart_only && $this->application->build_pack !== 'dockerimage' && $this->application->build_pack !== 'dockerfile';
$this->only_this_server = $this->application_deployment_queue->only_this_server; $this->only_this_server = $this->application_deployment_queue->only_this_server;
$this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag; $this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag;
$this->validateDockerRegistryImageConfiguration();
$this->git_type = data_get($this->application_deployment_queue, 'git_type'); $this->git_type = data_get($this->application_deployment_queue, 'git_type');
@@ -1139,6 +1140,21 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $this->pull_request_id === 0; return $this->pull_request_id === 0;
} }
private function validateDockerRegistryImageConfiguration(): void
{
if (! ValidationPatterns::isValidDockerImageName($this->application->docker_registry_image_name)) {
throw new DeploymentException('Docker registry image name contains invalid characters.');
}
if (! ValidationPatterns::isValidDockerImageTag($this->application->docker_registry_image_tag)) {
throw new DeploymentException('Docker registry image tag contains invalid characters.');
}
if (! ValidationPatterns::isValidDockerImageTag($this->dockerImagePreviewTag)) {
throw new DeploymentException('Docker registry preview image tag contains invalid characters.');
}
}
private function generate_image_names() private function generate_image_names()
{ {
if ($this->application->dockerfile) { if ($this->application->dockerfile) {
+3 -3
View File
@@ -157,8 +157,8 @@ class General extends Component
'portsMappings' => ValidationPatterns::portMappingRules(), 'portsMappings' => ValidationPatterns::portMappingRules(),
'customNetworkAliases' => 'nullable', 'customNetworkAliases' => 'nullable',
'dockerfile' => 'nullable', 'dockerfile' => 'nullable',
'dockerRegistryImageName' => 'nullable', 'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(),
'dockerRegistryImageTag' => 'nullable', 'dockerRegistryImageTag' => ValidationPatterns::dockerImageTagRules(),
'dockerfileLocation' => ValidationPatterns::filePathRules(), 'dockerfileLocation' => ValidationPatterns::filePathRules(),
'dockerComposeLocation' => ValidationPatterns::filePathRules(), 'dockerComposeLocation' => ValidationPatterns::filePathRules(),
'dockerCompose' => 'nullable', 'dockerCompose' => 'nullable',
@@ -848,7 +848,7 @@ class General extends Component
} }
if ($this->buildPack === 'dockerimage') { if ($this->buildPack === 'dockerimage') {
$this->validate([ $this->validate([
'dockerRegistryImageName' => 'required', 'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(required: true),
]); ]);
} }
+3 -2
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\New;
use App\Models\Application; use App\Models\Application;
use App\Models\Project; use App\Models\Project;
use App\Services\DockerImageParser; use App\Services\DockerImageParser;
use App\Support\ValidationPatterns;
use Livewire\Component; use Livewire\Component;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
@@ -81,8 +82,8 @@ class DockerImage extends Component
public function submit() public function submit()
{ {
$this->validate([ $this->validate([
'imageName' => ['required', 'string'], 'imageName' => ValidationPatterns::dockerImageNameRules(required: true),
'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'], 'imageTag' => ValidationPatterns::dockerImageTagRules(),
'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'], 'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'],
]); ]);
+22 -13
View File
@@ -2,18 +2,26 @@
namespace App\Rules; namespace App\Rules;
use App\Support\ValidationPatterns;
use Closure; use Closure;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
class DockerImageFormat implements ValidationRule class DockerImageFormat implements ValidationRule
{ {
/** /**
* Run the validation rule. * Run the validation rule.
* *
* @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail * @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/ */
public function validate(string $attribute, mixed $value, Closure $fail): void public function validate(string $attribute, mixed $value, Closure $fail): void
{ {
if (! is_string($value)) {
$fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.');
return;
}
// Check if the value contains ":sha256:" or ":sha" which is incorrect format // Check if the value contains ":sha256:" or ":sha" which is incorrect format
if (preg_match('/:sha256?:/i', $value)) { if (preg_match('/:sha256?:/i', $value)) {
$fail('The :attribute must use @ before sha256 digest (e.g., image@sha256:hash, not image:sha256:hash).'); $fail('The :attribute must use @ before sha256 digest (e.g., image@sha256:hash, not image:sha256:hash).');
@@ -21,20 +29,21 @@ class DockerImageFormat implements ValidationRule
return; return;
} }
// Valid formats: $imageName = $value;
// 1. image:tag (e.g., nginx:latest) $tag = null;
// 2. registry/image:tag (e.g., ghcr.io/user/app:v1.2.3)
// 3. image@sha256:hash (e.g., nginx@sha256:abc123...)
// 4. registry/image@sha256:hash
// 5. registry:port/image:tag (e.g., localhost:5000/app:latest)
$pattern = '/^ if (preg_match('/\A(.+)@sha256:([a-f0-9]{64})\z/i', $value, $matches) === 1) {
(?:[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[0-9]+)?\/)? # Optional registry with optional port $imageName = $matches[1];
[a-z0-9]+(?:[._\/-][a-z0-9]+)* # Image name (required) } else {
(?::[a-z0-9][a-z0-9._-]*|@sha256:[a-f0-9]{64})? # Optional :tag or @sha256:hash $lastColon = strrpos($value, ':');
$/ix'; $lastSlash = strrpos($value, '/');
if ($lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash)) {
$imageName = substr($value, 0, $lastColon);
$tag = substr($value, $lastColon + 1);
}
}
if (! preg_match($pattern, $value)) { if (! ValidationPatterns::isValidDockerImageName($imageName) || ! ValidationPatterns::isValidDockerImageTag($tag)) {
$fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.'); $fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.');
} }
} }
+92
View File
@@ -102,6 +102,23 @@ class ValidationPatterns
*/ */
public const DB_PASSWORD_PATTERN = '/^[A-Za-z0-9!@#%^*()_+\-=\[\]{}:,.?\/~]+$/'; public const DB_PASSWORD_PATTERN = '/^[A-Za-z0-9!@#%^*()_+\-=\[\]{}:,.?\/~]+$/';
/**
* Pattern for Docker image repository names without a tag.
*
* Allows an optional registry host/port followed by lowercase repository
* path components. A trailing @sha256 marker is accepted for existing
* digest-based dockerimage records that store the digest hash separately.
*/
public const DOCKER_IMAGE_NAME_PATTERN = '/\A(?=.{1,255}\z)(?:(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]+)?\/)?[a-z0-9]+(?:(?:[._-]|__)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._-]|__)[a-z0-9]+)*)*)(?:@sha256)?\z/';
/**
* Pattern for 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/';
/** /**
* Normalize environment variable keys before validation and storage. * Normalize environment variable keys before validation and storage.
*/ */
@@ -163,6 +180,81 @@ class ValidationPatterns
return $key; return $key;
} }
/**
* Get validation rules for Docker image repository names without tags.
*/
public static function dockerImageNameRules(bool $required = false, int $maxLength = 255): array
{
$rules = [];
if ($required) {
$rules[] = 'required';
} else {
$rules[] = 'nullable';
}
$rules[] = 'string';
$rules[] = "max:$maxLength";
$rules[] = 'regex:'.self::DOCKER_IMAGE_NAME_PATTERN;
return $rules;
}
/**
* Get validation rules for Docker image tags.
*/
public static function dockerImageTagRules(bool $required = false, int $maxLength = 128): array
{
$rules = [];
if ($required) {
$rules[] = 'required';
} else {
$rules[] = 'nullable';
}
$rules[] = 'string';
$rules[] = "max:$maxLength";
$rules[] = 'regex:'.self::DOCKER_IMAGE_TAG_PATTERN;
return $rules;
}
/**
* Get validation messages for Docker image fields.
*/
public static function dockerImageMessages(string $nameField = 'docker_registry_image_name', string $tagField = 'docker_registry_image_tag'): array
{
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.',
];
}
/**
* Check if a string is a valid Docker image repository name without a tag.
*/
public static function isValidDockerImageName(?string $value): bool
{
if (blank($value)) {
return true;
}
return preg_match(self::DOCKER_IMAGE_NAME_PATTERN, $value) === 1;
}
/**
* Check if a string is a valid Docker image tag.
*/
public static function isValidDockerImageTag(?string $value): bool
{
if (blank($value)) {
return true;
}
return preg_match(self::DOCKER_IMAGE_TAG_PATTERN, $value) === 1;
}
/** /**
* Get validation rules for database identifier fields (username, database name). * Get validation rules for database identifier fields (username, database name).
* *
+16 -15
View File
@@ -3,6 +3,7 @@
use App\Enums\BuildPackTypes; use App\Enums\BuildPackTypes;
use App\Enums\RedirectTypes; use App\Enums\RedirectTypes;
use App\Enums\StaticImageTypes; use App\Enums\StaticImageTypes;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -93,16 +94,16 @@ function sharedDataApplications()
'domains' => 'string|nullable', 'domains' => 'string|nullable',
'redirect' => Rule::enum(RedirectTypes::class), 'redirect' => Rule::enum(RedirectTypes::class),
'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'docker_registry_image_name' => 'string|nullable', 'docker_registry_image_name' => ValidationPatterns::dockerImageNameRules(),
'docker_registry_image_tag' => 'string|nullable', 'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
'install_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), 'install_command' => ValidationPatterns::shellSafeCommandRules(),
'build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), 'build_command' => ValidationPatterns::shellSafeCommandRules(),
'start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), 'start_command' => ValidationPatterns::shellSafeCommandRules(),
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/', 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/',
'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable', 'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable',
'custom_network_aliases' => 'string|nullable', 'custom_network_aliases' => 'string|nullable',
'base_directory' => \App\Support\ValidationPatterns::directoryPathRules(), 'base_directory' => ValidationPatterns::directoryPathRules(),
'publish_directory' => \App\Support\ValidationPatterns::directoryPathRules(), 'publish_directory' => ValidationPatterns::directoryPathRules(),
'health_check_enabled' => 'boolean', 'health_check_enabled' => 'boolean',
'health_check_type' => 'string|in:http,cmd', 'health_check_type' => 'string|in:http,cmd',
'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], 'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
@@ -125,24 +126,24 @@ function sharedDataApplications()
'limits_cpuset' => 'string|nullable', 'limits_cpuset' => 'string|nullable',
'limits_cpu_shares' => 'numeric', 'limits_cpu_shares' => 'numeric',
'custom_labels' => 'string|nullable', 'custom_labels' => 'string|nullable',
'custom_docker_run_options' => \App\Support\ValidationPatterns::shellSafeCommandRules(2000), 'custom_docker_run_options' => ValidationPatterns::shellSafeCommandRules(2000),
// Security: deployment commands are intentionally arbitrary shell (e.g. "php artisan migrate"). // Security: deployment commands are intentionally arbitrary shell (e.g. "php artisan migrate").
// Access is gated by API token authentication. Commands run inside the app container, not the host. // Access is gated by API token authentication. Commands run inside the app container, not the host.
'post_deployment_command' => 'string|nullable', 'post_deployment_command' => 'string|nullable',
'post_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(), 'post_deployment_command_container' => ValidationPatterns::containerNameRules(),
'pre_deployment_command' => 'string|nullable', 'pre_deployment_command' => 'string|nullable',
'pre_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(), 'pre_deployment_command_container' => ValidationPatterns::containerNameRules(),
'manual_webhook_secret_github' => 'string|nullable', 'manual_webhook_secret_github' => 'string|nullable',
'manual_webhook_secret_gitlab' => 'string|nullable', 'manual_webhook_secret_gitlab' => 'string|nullable',
'manual_webhook_secret_bitbucket' => 'string|nullable', 'manual_webhook_secret_bitbucket' => 'string|nullable',
'manual_webhook_secret_gitea' => 'string|nullable', 'manual_webhook_secret_gitea' => 'string|nullable',
'dockerfile_location' => \App\Support\ValidationPatterns::filePathRules(), 'dockerfile_location' => ValidationPatterns::filePathRules(),
'dockerfile_target_build' => \App\Support\ValidationPatterns::dockerTargetRules(), 'dockerfile_target_build' => ValidationPatterns::dockerTargetRules(),
'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), 'docker_compose_location' => ValidationPatterns::filePathRules(),
'docker_compose' => 'string|nullable', 'docker_compose' => 'string|nullable',
'docker_compose_domains' => 'array|nullable', 'docker_compose_domains' => 'array|nullable',
'docker_compose_custom_start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), 'docker_compose_custom_start_command' => ValidationPatterns::shellSafeCommandRules(),
'docker_compose_custom_build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), 'docker_compose_custom_build_command' => ValidationPatterns::shellSafeCommandRules(),
'is_container_label_escape_enabled' => 'boolean', 'is_container_label_escape_enabled' => 'boolean',
'is_preserve_repository_enabled' => 'boolean', 'is_preserve_repository_enabled' => 'boolean',
]; ];
@@ -0,0 +1,108 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'docker-registry-validation-api-test-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::factory()->create([
'server_id' => $this->server->id,
'network' => 'coolify-'.Str::lower(Str::random(8)),
]);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function dockerRegistryApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
function makeDockerRegistryValidationApplication(array $overrides = []): Application
{
return Application::factory()->create(array_merge([
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
'build_pack' => 'nixpacks',
'docker_registry_image_name' => 'ghcr.io/coollabsio/example',
'docker_registry_image_tag' => 'latest',
], $overrides));
}
describe('PATCH /api/v1/applications/{uuid} docker registry image validation', function () {
test('rejects shell metacharacters in docker registry image name without persisting them', function () {
$application = makeDockerRegistryValidationApplication();
$response = $this->withHeaders(dockerRegistryApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}", [
'docker_registry_image_name' => 'coolify/poc$(touch /tmp/pwned)',
'docker_registry_image_tag' => 'latest',
]);
$response->assertUnprocessable()
->assertInvalid(['docker_registry_image_name']);
$application->refresh();
expect($application->docker_registry_image_name)->toBe('ghcr.io/coollabsio/example')
->and($application->docker_registry_image_tag)->toBe('latest');
});
test('rejects shell metacharacters in docker registry image tag without persisting them', function () {
$application = makeDockerRegistryValidationApplication();
$response = $this->withHeaders(dockerRegistryApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}", [
'docker_registry_image_name' => 'ghcr.io/coollabsio/example',
'docker_registry_image_tag' => 'latest$(touch /tmp/pwned)',
]);
$response->assertUnprocessable()
->assertInvalid(['docker_registry_image_tag']);
$application->refresh();
expect($application->docker_registry_image_name)->toBe('ghcr.io/coollabsio/example')
->and($application->docker_registry_image_tag)->toBe('latest');
});
test('accepts valid docker registry image values', function () {
$application = makeDockerRegistryValidationApplication();
$response = $this->withHeaders(dockerRegistryApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$application->uuid}", [
'docker_registry_image_name' => 'registry.example.com:5000/team/app',
'docker_registry_image_tag' => 'v1.2.3',
]);
$response->assertOk();
$application->refresh();
expect($application->docker_registry_image_name)->toBe('registry.example.com:5000/team/app')
->and($application->docker_registry_image_tag)->toBe('v1.2.3');
});
});
@@ -0,0 +1,21 @@
<?php
use App\Livewire\Project\Application\General;
it('uses safe docker registry image validation rules in the application general form', function () {
$component = new General;
$method = new ReflectionMethod($component, 'rules');
$rules = $method->invoke($component);
$validator = validator([
'dockerRegistryImageName' => 'coolify/poc$(touch /tmp/pwned)',
'dockerRegistryImageTag' => 'latest$(touch /tmp/pwned)',
], [
'dockerRegistryImageName' => $rules['dockerRegistryImageName'],
'dockerRegistryImageTag' => $rules['dockerRegistryImageTag'],
]);
expect($validator->fails())->toBeTrue()
->and($validator->errors()->has('dockerRegistryImageName'))->toBeTrue()
->and($validator->errors()->has('dockerRegistryImageTag'))->toBeTrue();
});
@@ -0,0 +1,108 @@
<?php
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Rules\DockerImageFormat;
use App\Support\ValidationPatterns;
it('accepts valid docker registry image names', function (string $imageName) {
expect(ValidationPatterns::isValidDockerImageName($imageName))->toBeTrue();
})->with([
'single component' => 'nginx',
'namespace image' => 'library/nginx',
'ghcr image' => 'ghcr.io/coollabsio/coolify',
'registry with port' => 'registry.example.com:5000/team/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',
]);
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',
]);
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",
]);
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',
]);
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);
});