diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php index bfc65d8d2..20c997656 100644 --- a/app/Actions/Proxy/StartProxy.php +++ b/app/Actions/Proxy/StartProxy.php @@ -75,6 +75,10 @@ public function handle(Server $server, bool $async = true, bool $force = false, ' done', " echo 'Successfully stopped and removed existing coolify-proxy.'", 'fi', + ]); + // Ensure required networks exist BEFORE docker compose up (networks are declared as external) + $commands = $commands->merge(ensureProxyNetworksExist($server)); + $commands = $commands->merge([ "echo 'Starting coolify-proxy.'", 'docker compose up -d --wait --remove-orphans', "echo 'Successfully started coolify-proxy.'", diff --git a/app/Console/Commands/CleanupNames.php b/app/Console/Commands/CleanupNames.php index e4bbe4a12..2451dc3ed 100644 --- a/app/Console/Commands/CleanupNames.php +++ b/app/Console/Commands/CleanupNames.php @@ -202,7 +202,8 @@ protected function createBackup(): void exec($command, $output, $returnCode); } catch (\Exception $e) { - // Silently continue + // Log failure but continue - backup is optional safeguard + Log::warning('Name cleanup backup failed', ['error' => $e->getMessage()]); } } diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index 0f1146808..0a98f1dc8 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -50,6 +50,7 @@ private function syncReleasesToGitHubRepo(): bool // Clone the repository $this->info('Cloning coolify-cdn repository...'); + $output = []; exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to clone repository: '.implode("\n", $output)); @@ -59,6 +60,7 @@ private function syncReleasesToGitHubRepo(): bool // Create feature branch $this->info('Creating feature branch...'); + $output = []; exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to create branch: '.implode("\n", $output)); @@ -96,6 +98,7 @@ private function syncReleasesToGitHubRepo(): bool // Stage and commit $this->info('Committing changes...'); + $output = []; exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to stage changes: '.implode("\n", $output)); @@ -133,6 +136,7 @@ private function syncReleasesToGitHubRepo(): bool // Push to remote $this->info('Pushing branch to remote...'); + $output = []; exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to push branch: '.implode("\n", $output)); @@ -146,6 +150,7 @@ private function syncReleasesToGitHubRepo(): bool $prTitle = 'Update releases.json - '.date('Y-m-d H:i:s'); $prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API'; $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; + $output = []; exec($prCommand, $output, $returnCode); // Clean up @@ -211,6 +216,7 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b // 3. Clone the repository $this->info('Cloning coolify-cdn repository...'); + $output = []; exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to clone repository: '.implode("\n", $output)); @@ -220,6 +226,7 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b // 4. Create feature branch $this->info('Creating feature branch...'); + $output = []; exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to create branch: '.implode("\n", $output)); @@ -274,6 +281,7 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b // 7. Stage both files $this->info('Staging changes...'); + $output = []; exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to stage changes: '.implode("\n", $output)); @@ -303,6 +311,7 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b // 9. Commit changes $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; $commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); + $output = []; exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to commit changes: '.implode("\n", $output)); @@ -313,6 +322,7 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b // 10. Push to remote $this->info('Pushing branch to remote...'); + $output = []; exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to push branch: '.implode("\n", $output)); @@ -326,6 +336,7 @@ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, b $prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); $prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion"; $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; + $output = []; exec($prCommand, $output, $returnCode); // 12. Clean up diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 2c4d0d361..469d8b550 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -351,7 +351,7 @@ public function create_service(Request $request) 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), ]; - if ($oneClickServiceName === 'cloudflared') { + if ($oneClickServiceName === 'pgadmin') { data_set($servicePayload, 'connect_to_docker_network', true); } $service = Service::create($servicePayload); diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 45ac6eb7d..6917de6d5 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -489,17 +489,22 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi $collectionsToExclude = collect(); } $commands[] = 'mkdir -p '.$this->backup_dir; + + // Validate and escape database name to prevent command injection + validateShellSafePath($databaseName, 'database name'); + $escapedDatabaseName = escapeshellarg($databaseName); + if ($collectionsToExclude->count() === 0) { if (str($this->database->image)->startsWith('mongo:4')) { $commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location"; } else { - $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --archive > $this->backup_location"; + $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location"; } } else { if (str($this->database->image)->startsWith('mongo:4')) { $commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location"; } else { - $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location"; + $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location"; } } } @@ -525,7 +530,10 @@ private function backup_standalone_postgresql(string $database): void if ($this->backup->dump_all) { $backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location"; } else { - $backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $database > $this->backup_location"; + // Validate and escape database name to prevent command injection + validateShellSafePath($database, 'database name'); + $escapedDatabase = escapeshellarg($database); + $backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $escapedDatabase > $this->backup_location"; } $commands[] = $backupCommand; @@ -547,7 +555,10 @@ private function backup_standalone_mysql(string $database): void if ($this->backup->dump_all) { $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location"; } else { - $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $database > $this->backup_location"; + // Validate and escape database name to prevent command injection + validateShellSafePath($database, 'database name'); + $escapedDatabase = escapeshellarg($database); + $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location"; } $this->backup_output = instant_remote_process($commands, $this->server); $this->backup_output = trim($this->backup_output); @@ -567,7 +578,10 @@ private function backup_standalone_mariadb(string $database): void if ($this->backup->dump_all) { $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location"; } else { - $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $database > $this->backup_location"; + // Validate and escape database name to prevent command injection + validateShellSafePath($database, 'database name'); + $escapedDatabase = escapeshellarg($database); + $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location"; } $this->backup_output = instant_remote_process($commands, $this->server); $this->backup_output = trim($this->backup_output); diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php index ff5c2e4f5..b5e1929de 100644 --- a/app/Jobs/ValidateAndInstallServerJob.php +++ b/app/Jobs/ValidateAndInstallServerJob.php @@ -168,6 +168,9 @@ public function handle(): void if (! $this->server->isBuildServer()) { $proxyShouldRun = CheckProxy::run($this->server, true); if ($proxyShouldRun) { + // Ensure networks exist BEFORE dispatching async proxy startup + // This prevents race condition where proxy tries to start before networks are created + instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false); StartProxy::dispatch($this->server); } } diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index da543a049..18ad93016 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -107,6 +107,25 @@ public function syncData(bool $toModel = false) $this->backup->save_s3 = $this->saveS3; $this->backup->disable_local_backup = $this->disableLocalBackup; $this->backup->s3_storage_id = $this->s3StorageId; + + // Validate databases_to_backup to prevent command injection + if (filled($this->databasesToBackup)) { + $databases = str($this->databasesToBackup)->explode(','); + foreach ($databases as $index => $db) { + $dbName = trim($db); + try { + validateShellSafePath($dbName, 'database name'); + } catch (\Exception $e) { + // Provide specific error message indicating which database failed validation + $position = $index + 1; + throw new \Exception( + "Database #{$position} ('{$dbName}') validation failed: ". + $e->getMessage() + ); + } + } + } + $this->backup->databases_to_backup = $this->databasesToBackup; $this->backup->dump_all = $this->dumpAll; $this->backup->timeout = $this->timeout; diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 3240aadd2..7ef2cdc4f 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -328,12 +328,15 @@ public function save_init_script($script) $configuration_dir = database_configuration_dir().'/'.$container_name; if ($oldScript && $oldScript['filename'] !== $script['filename']) { - $old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}"; - $delete_command = "rm -f $old_file_path"; try { + // Validate and escape filename to prevent command injection + validateShellSafePath($oldScript['filename'], 'init script filename'); + $old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}"; + $escapedOldPath = escapeshellarg($old_file_path); + $delete_command = "rm -f {$escapedOldPath}"; instant_remote_process([$delete_command], $this->server); } catch (Exception $e) { - $this->dispatch('error', 'Failed to remove old init script from server: '.$e->getMessage()); + $this->dispatch('error', $e->getMessage()); return; } @@ -370,13 +373,17 @@ public function delete_init_script($script) if ($found) { $container_name = $this->database->uuid; $configuration_dir = database_configuration_dir().'/'.$container_name; - $file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}"; - $command = "rm -f $file_path"; try { + // Validate and escape filename to prevent command injection + validateShellSafePath($script['filename'], 'init script filename'); + $file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}"; + $escapedPath = escapeshellarg($file_path); + + $command = "rm -f {$escapedPath}"; instant_remote_process([$command], $this->server); } catch (Exception $e) { - $this->dispatch('error', 'Failed to remove init script from server: '.$e->getMessage()); + $this->dispatch('error', $e->getMessage()); return; } @@ -405,6 +412,16 @@ public function save_new_init_script() 'new_filename' => 'required|string', 'new_content' => 'required|string', ]); + + try { + // Validate filename to prevent command injection + validateShellSafePath($this->new_filename, 'init script filename'); + } catch (Exception $e) { + $this->dispatch('error', $e->getMessage()); + + return; + } + $found = collect($this->initScripts)->firstWhere('filename', $this->new_filename); if ($found) { $this->dispatch('error', 'Filename already exists.'); diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php index 211d4723d..1550fd632 100644 --- a/app/Livewire/Project/Resource/Create.php +++ b/app/Livewire/Project/Resource/Create.php @@ -81,7 +81,7 @@ public function mount() 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), ]; - if ($oneClickServiceName === 'cloudflared' || $oneClickServiceName === 'pgadmin' || $oneClickServiceName === 'postgresus') { + if ($oneClickServiceName === 'pgadmin' || $oneClickServiceName === 'postgresus') { data_set($service_payload, 'connect_to_docker_network', true); } $service = Service::create($service_payload); diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index db171db24..644b100b8 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -179,6 +179,10 @@ public function submitFileStorageDirectory() $this->file_storage_directory_destination = trim($this->file_storage_directory_destination); $this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value(); + // Validate paths to prevent command injection + validateShellSafePath($this->file_storage_directory_source, 'storage source path'); + validateShellSafePath($this->file_storage_directory_destination, 'storage destination path'); + \App\Models\LocalFileVolume::create([ 'fs_path' => $this->file_storage_directory_source, 'mount_path' => $this->file_storage_directory_destination, diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 3b8d244cc..2030f631e 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -2,11 +2,14 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\Environment; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; +use App\Models\Project; use App\Models\SharedEnvironmentVariable; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\EnvironmentVariableProtection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Computed; use Livewire\Component; class Show extends Component @@ -184,6 +187,7 @@ public function submit() $this->serialize(); $this->syncData(true); + $this->syncData(false); $this->dispatch('success', 'Environment variable updated.'); $this->dispatch('envsUpdated'); $this->dispatch('configurationChanged'); @@ -192,6 +196,72 @@ public function submit() } } + #[Computed] + public function availableSharedVariables(): array + { + $team = currentTeam(); + $result = [ + 'team' => [], + 'project' => [], + 'environment' => [], + ]; + + // Early return if no team + if (! $team) { + return $result; + } + + // Check if user can view team variables + try { + $this->authorize('view', $team); + $result['team'] = $team->environment_variables() + ->pluck('key') + ->toArray(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view team variables + } + + // Get project variables if we have a project_uuid in route + $projectUuid = data_get($this->parameters, 'project_uuid'); + if ($projectUuid) { + $project = Project::where('team_id', $team->id) + ->where('uuid', $projectUuid) + ->first(); + + if ($project) { + try { + $this->authorize('view', $project); + $result['project'] = $project->environment_variables() + ->pluck('key') + ->toArray(); + + // Get environment variables if we have an environment_uuid in route + $environmentUuid = data_get($this->parameters, 'environment_uuid'); + if ($environmentUuid) { + $environment = $project->environments() + ->where('uuid', $environmentUuid) + ->first(); + + if ($environment) { + try { + $this->authorize('view', $environment); + $result['environment'] = $environment->environment_variables() + ->pluck('key') + ->toArray(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view environment variables + } + } + } + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view project variables + } + } + } + + return $result; + } + public function delete() { try { diff --git a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php index f377bbeb9..c67591cf5 100644 --- a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php +++ b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php @@ -25,13 +25,25 @@ public function delete(string $fileName) $this->authorize('update', $this->server); $proxy_path = $this->server->proxyPath(); $proxy_type = $this->server->proxyType(); + + // Decode filename: pipes are used to encode dots for Livewire property binding + // (e.g., 'my|service.yaml' -> 'my.service.yaml') + // This must happen BEFORE validation because validateShellSafePath() correctly + // rejects pipe characters as dangerous shell metacharacters $file = str_replace('|', '.', $fileName); + + // Validate filename to prevent command injection + validateShellSafePath($file, 'proxy configuration filename'); + if ($proxy_type === 'CADDY' && $file === 'Caddyfile') { $this->dispatch('error', 'Cannot delete Caddyfile.'); return; } - instant_remote_process(["rm -f {$proxy_path}/dynamic/{$file}"], $this->server); + + $fullPath = "{$proxy_path}/dynamic/{$file}"; + $escapedPath = escapeshellarg($fullPath); + instant_remote_process(["rm -f {$escapedPath}"], $this->server); if ($proxy_type === 'CADDY') { $this->server->reloadCaddy(); } diff --git a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php index eb2db1cbb..baf7b6b50 100644 --- a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php +++ b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php @@ -41,6 +41,10 @@ public function addDynamicConfiguration() 'fileName' => 'required', 'value' => 'required', ]); + + // Validate filename to prevent command injection + validateShellSafePath($this->fileName, 'proxy configuration filename'); + if (data_get($this->parameters, 'server_uuid')) { $this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first(); } @@ -65,8 +69,10 @@ public function addDynamicConfiguration() } $proxy_path = $this->server->proxyPath(); $file = "{$proxy_path}/dynamic/{$this->fileName}"; + $escapedFile = escapeshellarg($file); + if ($this->newFile) { - $exists = instant_remote_process(["test -f $file && echo 1 || echo 0"], $this->server); + $exists = instant_remote_process(["test -f {$escapedFile} && echo 1 || echo 0"], $this->server); if ($exists == 1) { $this->dispatch('error', 'File already exists'); @@ -80,7 +86,7 @@ public function addDynamicConfiguration() } $base64_value = base64_encode($this->value); instant_remote_process([ - "echo '{$base64_value}' | base64 -d | tee {$file} > /dev/null", + "echo '{$base64_value}' | base64 -d | tee {$escapedFile} > /dev/null", ], $this->server); if ($proxy_type === 'CADDY') { $this->server->reloadCaddy(); diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index c2dcd877b..1a5bd381b 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -206,6 +206,9 @@ public function validateDockerVersion() if (! $proxyShouldRun) { return; } + // Ensure networks exist BEFORE dispatching async proxy startup + // This prevents race condition where proxy tries to start before networks are created + instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false); StartProxy::dispatch($this->server); } else { $requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.'); diff --git a/app/Models/Application.php b/app/Models/Application.php index 821c69bca..6e920f8e6 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1035,7 +1035,7 @@ public function isLogDrainEnabled() public function isConfigurationChanged(bool $save = false) { - $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings->use_build_secrets); + $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings->use_build_secrets.$this->settings->inject_build_args_to_dockerfile.$this->settings->include_source_commit_in_build); if ($this->pull_request_id === 0 || $this->pull_request_id === null) { $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); } else { diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 80399a16b..843f01e59 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -190,11 +190,11 @@ private function get_real_environment_variables(?string $environment_variable = return $environment_variable; } foreach ($sharedEnvsFound as $sharedEnv) { - $type = str($sharedEnv)->match('/(.*?)\./'); + $type = str($sharedEnv)->trim()->match('/(.*?)\./'); if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) { continue; } - $variable = str($sharedEnv)->match('/\.(.*)/'); + $variable = str($sharedEnv)->trim()->match('/\.(.*)/'); if ($type->value() === 'environment') { $id = $resource->environment->id; } elseif ($type->value() === 'project') { @@ -231,7 +231,7 @@ private function set_environment_variables(?string $environment_variable = null) $environment_variable = trim($environment_variable); $type = str($environment_variable)->after('{{')->before('.')->value; if (str($environment_variable)->startsWith('{{'.$type) && str($environment_variable)->endsWith('}}')) { - return encrypt((string) str($environment_variable)->replace(' ', '')); + return encrypt($environment_variable); } return encrypt($environment_variable); diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 376ea9c5e..96170dbd6 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -61,9 +61,14 @@ public function loadStorageOnServer() $path = $path->after('.'); $path = $workdir.$path; } - $isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server); + + // Validate and escape path to prevent command injection + validateShellSafePath($path, 'storage path'); + $escapedPath = escapeshellarg($path); + + $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server); if ($isFile === 'OK') { - $content = instant_remote_process(["cat $path"], $server, false); + $content = instant_remote_process(["cat {$escapedPath}"], $server, false); // Check if content contains binary data by looking for null bytes or non-printable characters if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) { $content = '[binary file]'; @@ -91,14 +96,19 @@ public function deleteStorageOnServer() $path = $path->after('.'); $path = $workdir.$path; } - $isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server); - $isDir = instant_remote_process(["test -d $path && echo OK || echo NOK"], $server); + + // Validate and escape path to prevent command injection + validateShellSafePath($path, 'storage path'); + $escapedPath = escapeshellarg($path); + + $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server); + $isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server); if ($path && $path != '/' && $path != '.' && $path != '..') { if ($isFile === 'OK') { - $commands->push("rm -rf $path > /dev/null 2>&1 || true"); + $commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true"); } elseif ($isDir === 'OK') { - $commands->push("rm -rf $path > /dev/null 2>&1 || true"); - $commands->push("rmdir $path > /dev/null 2>&1 || true"); + $commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true"); + $commands->push("rmdir {$escapedPath} > /dev/null 2>&1 || true"); } } if ($commands->count() > 0) { @@ -135,10 +145,15 @@ public function saveStorageOnServer() $path = $path->after('.'); $path = $workdir.$path; } - $isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server); - $isDir = instant_remote_process(["test -d $path && echo OK || echo NOK"], $server); + + // Validate and escape path to prevent command injection + validateShellSafePath($path, 'storage path'); + $escapedPath = escapeshellarg($path); + + $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server); + $isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server); if ($isFile === 'OK' && $this->is_directory) { - $content = instant_remote_process(["cat $path"], $server, false); + $content = instant_remote_process(["cat {$escapedPath}"], $server, false); $this->is_directory = false; $this->content = $content; $this->save(); @@ -151,8 +166,8 @@ public function saveStorageOnServer() throw new \Exception('The following file is a directory on the server, but you are trying to mark it as a file.

Please delete the directory on the server or mark it as directory.'); } instant_remote_process([ - "rm -fr $path", - "touch $path", + "rm -fr {$escapedPath}", + "touch {$escapedPath}", ], $server, false); FileStorageChanged::dispatch(data_get($server, 'team_id')); } @@ -161,19 +176,19 @@ public function saveStorageOnServer() $chown = data_get($this, 'chown'); if ($content) { $content = base64_encode($content); - $commands->push("echo '$content' | base64 -d | tee $path > /dev/null"); + $commands->push("echo '$content' | base64 -d | tee {$escapedPath} > /dev/null"); } else { - $commands->push("touch $path"); + $commands->push("touch {$escapedPath}"); } - $commands->push("chmod +x $path"); + $commands->push("chmod +x {$escapedPath}"); if ($chown) { - $commands->push("chown $chown $path"); + $commands->push("chown $chown {$escapedPath}"); } if ($chmod) { - $commands->push("chmod $chmod $path"); + $commands->push("chmod $chmod {$escapedPath}"); } } elseif ($isDir === 'NOK' && $this->is_directory) { - $commands->push("mkdir -p $path > /dev/null 2>&1 || true"); + $commands->push("mkdir -p {$escapedPath} > /dev/null 2>&1 || true"); } return instant_remote_process($commands, $server); diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php index 7cf8ee8fa..4a98e4a51 100644 --- a/app/View/Components/Forms/EnvVarInput.php +++ b/app/View/Components/Forms/EnvVarInput.php @@ -26,6 +26,7 @@ public function __construct( public bool $disabled = false, public bool $readonly = false, public ?string $helper = null, + public bool $allowToPeak = true, public string $defaultClass = 'input', public string $autocomplete = 'off', public ?int $minlength = null, @@ -72,6 +73,10 @@ public function render(): View|Closure|string $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id; } + if ($this->type === 'password') { + $this->defaultClass = $this->defaultClass.' pr-[2.8rem]'; + } + $this->scopeUrls = [ 'team' => route('shared-variables.team.index'), 'project' => route('shared-variables.project.index'), diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index dfcc3e190..e7d875777 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1644,9 +1644,16 @@ function serviceParser(Service $resource): Collection if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) { $path = $value->value(); if ($path !== '/') { - $fqdn = "$fqdn$path"; - $url = "$url$path"; - $fqdnValueForEnv = "$fqdnValueForEnv$path"; + // Only add path if it's not already present (prevents duplication on subsequent parse() calls) + if (! str($fqdn)->endsWith($path)) { + $fqdn = "$fqdn$path"; + } + if (! str($url)->endsWith($path)) { + $url = "$url$path"; + } + if (! str($fqdnValueForEnv)->endsWith($path)) { + $fqdnValueForEnv = "$fqdnValueForEnv$path"; + } } } diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index 08fad4958..6672f8b6f 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -108,6 +108,37 @@ function connectProxyToNetworks(Server $server) return $commands->flatten(); } + +/** + * Ensures all required networks exist before docker compose up. + * This must be called BEFORE docker compose up since the compose file declares networks as external. + * + * @param Server $server The server to ensure networks on + * @return \Illuminate\Support\Collection Commands to create networks if they don't exist + */ +function ensureProxyNetworksExist(Server $server) +{ + ['allNetworks' => $networks] = collectDockerNetworksByServer($server); + + if ($server->isSwarm()) { + $commands = $networks->map(function ($network) { + return [ + "echo 'Ensuring network $network exists...'", + "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network", + ]; + }); + } else { + $commands = $networks->map(function ($network) { + return [ + "echo 'Ensuring network $network exists...'", + "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network", + ]; + }); + } + + return $commands->flatten(); +} + function extractCustomProxyCommands(Server $server, string $existing_config): array { $custom_commands = []; diff --git a/bootstrap/helpers/sudo.php b/bootstrap/helpers/sudo.php index 7a7fc3680..b8ef84687 100644 --- a/bootstrap/helpers/sudo.php +++ b/bootstrap/helpers/sudo.php @@ -23,38 +23,56 @@ function shouldChangeOwnership(string $path): bool function parseCommandsByLineForSudo(Collection $commands, Server $server): array { $commands = $commands->map(function ($line) { - if ( - ! str(trim($line))->startsWith([ - 'cd', - 'command', - 'echo', - 'true', - 'if', - 'fi', - 'for', - 'do', - 'done', - 'while', - 'until', - 'case', - 'esac', - 'select', - 'then', - 'else', - 'elif', - 'break', - 'continue', - '#', - ]) - ) { - return "sudo $line"; + $trimmedLine = trim($line); + + // All bash keywords that should not receive sudo prefix + // Using word boundary matching to avoid prefix collisions (e.g., 'do' vs 'docker', 'if' vs 'ifconfig', 'fi' vs 'find') + $bashKeywords = [ + 'cd', + 'command', + 'declare', + 'echo', + 'export', + 'local', + 'readonly', + 'return', + 'true', + 'if', + 'fi', + 'for', + 'done', + 'while', + 'until', + 'case', + 'esac', + 'select', + 'then', + 'else', + 'elif', + 'break', + 'continue', + 'do', + ]; + + // Special case: comments (no collision risk with '#') + if (str_starts_with($trimmedLine, '#')) { + return $line; } - if (str(trim($line))->startsWith('if')) { - return str_replace('if', 'if sudo', $line); + // Check all keywords with word boundary matching + // Match keyword followed by space, semicolon, or end of line + foreach ($bashKeywords as $keyword) { + if (preg_match('/^'.preg_quote($keyword, '/').'(\s|;|$)/', $trimmedLine)) { + // Special handling for 'if' - insert sudo after 'if ' + if ($keyword === 'if') { + return preg_replace('/^(\s*)if\s+/', '$1if sudo ', $line); + } + + return $line; + } } - return $line; + return "sudo $line"; }); $commands = $commands->map(function ($line) use ($server) { diff --git a/config/constants.php b/config/constants.php index b2c43f2b9..893fb11fd 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.451', + 'version' => '4.0.0-beta.452', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index fadd5580d..577fdfe18 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.451" + "version": "4.0.0-beta.452" }, "nightly": { - "version": "4.0.0-beta.452" + "version": "4.0.0-beta.453" }, "helper": { "version": "1.0.12" diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php index 53a6b21ec..2466a57f9 100644 --- a/resources/views/components/forms/env-var-input.blade.php +++ b/resources/views/components/forms/env-var-input.blade.php @@ -10,7 +10,8 @@ @endif -
+ @click.outside="showDropdown = false"> + + @if ($type === 'password' && $allowToPeak) +
+ + + + + +
+ @endif wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif wire:loading.attr="disabled" - type="{{ $type }}" + @if ($type === 'password') + :type="type" + @else + type="{{ $type }}" + @endif @disabled($disabled) @if ($htmlId !== 'null') id="{{ $htmlId }}" @endif name="{{ $name }}" diff --git a/resources/views/livewire/project/database/heading.blade.php b/resources/views/livewire/project/database/heading.blade.php index b09adcc4e..0e67a3606 100644 --- a/resources/views/livewire/project/database/heading.blade.php +++ b/resources/views/livewire/project/database/heading.blade.php @@ -3,7 +3,9 @@ Database Startup - +
+ +