mirror of
https://github.com/coollabsio/coolify.git
synced 2026-06-14 03:19:51 +00:00
fix(applications): harden image validation
This commit is contained in:
@@ -17,6 +17,7 @@ use App\Models\LocalPersistentVolume;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Rules\DockerImageFormat;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Services\DockerImageParser;
|
||||
@@ -1790,8 +1791,8 @@ class ApplicationsController extends Controller
|
||||
]))->setStatusCode(201);
|
||||
} elseif ($type === 'dockerimage') {
|
||||
$validationRules = [
|
||||
'docker_registry_image_name' => 'string|required',
|
||||
'docker_registry_image_tag' => 'string',
|
||||
'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat],
|
||||
'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
|
||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
||||
];
|
||||
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
||||
|
||||
@@ -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->only_this_server = $this->application_deployment_queue->only_this_server;
|
||||
$this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag;
|
||||
$this->validateDockerRegistryImageConfiguration();
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
if ($this->application->dockerfile) {
|
||||
|
||||
@@ -157,8 +157,8 @@ class General extends Component
|
||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||
'customNetworkAliases' => 'nullable',
|
||||
'dockerfile' => 'nullable',
|
||||
'dockerRegistryImageName' => 'nullable',
|
||||
'dockerRegistryImageTag' => 'nullable',
|
||||
'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(),
|
||||
'dockerRegistryImageTag' => ValidationPatterns::dockerImageTagRules(),
|
||||
'dockerfileLocation' => ValidationPatterns::filePathRules(),
|
||||
'dockerComposeLocation' => ValidationPatterns::filePathRules(),
|
||||
'dockerCompose' => 'nullable',
|
||||
@@ -848,7 +848,7 @@ class General extends Component
|
||||
}
|
||||
if ($this->buildPack === 'dockerimage') {
|
||||
$this->validate([
|
||||
'dockerRegistryImageName' => 'required',
|
||||
'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(required: true),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\New;
|
||||
use App\Models\Application;
|
||||
use App\Models\Project;
|
||||
use App\Services\DockerImageParser;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Livewire\Component;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
@@ -81,8 +82,8 @@ class DockerImage extends Component
|
||||
public function submit()
|
||||
{
|
||||
$this->validate([
|
||||
'imageName' => ['required', 'string'],
|
||||
'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'],
|
||||
'imageName' => ValidationPatterns::dockerImageNameRules(required: true),
|
||||
'imageTag' => ValidationPatterns::dockerImageTagRules(),
|
||||
'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'],
|
||||
]);
|
||||
|
||||
|
||||
@@ -2,18 +2,26 @@
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Support\ValidationPatterns;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
class DockerImageFormat implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
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
|
||||
if (preg_match('/:sha256?:/i', $value)) {
|
||||
$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;
|
||||
}
|
||||
|
||||
// Valid formats:
|
||||
// 1. image:tag (e.g., nginx:latest)
|
||||
// 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)
|
||||
$imageName = $value;
|
||||
$tag = null;
|
||||
|
||||
$pattern = '/^
|
||||
(?:[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[0-9]+)?\/)? # Optional registry with optional port
|
||||
[a-z0-9]+(?:[._\/-][a-z0-9]+)* # Image name (required)
|
||||
(?::[a-z0-9][a-z0-9._-]*|@sha256:[a-f0-9]{64})? # Optional :tag or @sha256:hash
|
||||
$/ix';
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,23 @@ class ValidationPatterns
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@@ -163,6 +180,81 @@ class ValidationPatterns
|
||||
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).
|
||||
*
|
||||
|
||||
+16
-15
@@ -3,6 +3,7 @@
|
||||
use App\Enums\BuildPackTypes;
|
||||
use App\Enums\RedirectTypes;
|
||||
use App\Enums\StaticImageTypes;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -93,16 +94,16 @@ function sharedDataApplications()
|
||||
'domains' => 'string|nullable',
|
||||
'redirect' => Rule::enum(RedirectTypes::class),
|
||||
'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
|
||||
'docker_registry_image_name' => 'string|nullable',
|
||||
'docker_registry_image_tag' => 'string|nullable',
|
||||
'install_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
|
||||
'build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
|
||||
'start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
|
||||
'docker_registry_image_name' => ValidationPatterns::dockerImageNameRules(),
|
||||
'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
|
||||
'install_command' => ValidationPatterns::shellSafeCommandRules(),
|
||||
'build_command' => ValidationPatterns::shellSafeCommandRules(),
|
||||
'start_command' => ValidationPatterns::shellSafeCommandRules(),
|
||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/',
|
||||
'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable',
|
||||
'custom_network_aliases' => 'string|nullable',
|
||||
'base_directory' => \App\Support\ValidationPatterns::directoryPathRules(),
|
||||
'publish_directory' => \App\Support\ValidationPatterns::directoryPathRules(),
|
||||
'base_directory' => ValidationPatterns::directoryPathRules(),
|
||||
'publish_directory' => ValidationPatterns::directoryPathRules(),
|
||||
'health_check_enabled' => 'boolean',
|
||||
'health_check_type' => 'string|in:http,cmd',
|
||||
'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
|
||||
@@ -125,24 +126,24 @@ function sharedDataApplications()
|
||||
'limits_cpuset' => 'string|nullable',
|
||||
'limits_cpu_shares' => 'numeric',
|
||||
'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").
|
||||
// Access is gated by API token authentication. Commands run inside the app container, not the host.
|
||||
'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_container' => \App\Support\ValidationPatterns::containerNameRules(),
|
||||
'pre_deployment_command_container' => ValidationPatterns::containerNameRules(),
|
||||
'manual_webhook_secret_github' => 'string|nullable',
|
||||
'manual_webhook_secret_gitlab' => 'string|nullable',
|
||||
'manual_webhook_secret_bitbucket' => 'string|nullable',
|
||||
'manual_webhook_secret_gitea' => 'string|nullable',
|
||||
'dockerfile_location' => \App\Support\ValidationPatterns::filePathRules(),
|
||||
'dockerfile_target_build' => \App\Support\ValidationPatterns::dockerTargetRules(),
|
||||
'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(),
|
||||
'dockerfile_location' => ValidationPatterns::filePathRules(),
|
||||
'dockerfile_target_build' => ValidationPatterns::dockerTargetRules(),
|
||||
'docker_compose_location' => ValidationPatterns::filePathRules(),
|
||||
'docker_compose' => 'string|nullable',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_custom_start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
|
||||
'docker_compose_custom_build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
|
||||
'docker_compose_custom_start_command' => ValidationPatterns::shellSafeCommandRules(),
|
||||
'docker_compose_custom_build_command' => ValidationPatterns::shellSafeCommandRules(),
|
||||
'is_container_label_escape_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);
|
||||
});
|
||||
Reference in New Issue
Block a user