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\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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user