Root cause
Injection & unsafe execution introduced
feat(logs): Add dropdown to download displayed or all logs
How AI contributed: Direct introductionPublished Jul 6, 2026 in
coollabsio/coolifyAll 4 findings in this repository
Coolify's GetLogs Livewire component gained a downloadAllLogs() action that interpolated the public property $container into docker logs -t and docker service logs -t commands and ran them over SSH through Process::run. Because that property can be set by any authenticated team member, a crafted value escaped the shell quoting and executed arbitrary commands as root on the managed host. The fix locks the property, validates it with ValidationPatterns::isValidContainerName, and checks Server ownership before the command is built.
downloadAllLogs() assembled its shell command by interpolating the public Livewire property $container directly into docker logs -t and docker service logs -t strings, then executed the result through Process::run over SSH. Nothing validated that the value was a container name, and no ownership check stood between the Livewire request and the command, so shell metacharacters in the property became part of the remote command line.
Root cause
feat(logs): Add dropdown to download displayed or all logs
Fix
fix: harden GetLogs Livewire component properties (#9229)
Fix by Andras Bacsai · no AI marker found
Case facts
@@ -188,6 +188,50 @@ public function copyLogs(): string return sanitizeLogsForExport($this->outputs); } + public function downloadAllLogs(): string+ {+ if (! $this->server->isFunctional() || ! $this->container) {+ return '';+ }++ if ($this->showTimeStamps) {+ if ($this->server->isSwarm()) {+ $command = "docker service logs -t {$this->container}";+ } else {+ $command = "docker logs -t {$this->container}";+ }+ } else {+ if ($this->server->isSwarm()) {+ $command = "docker service logs {$this->container}";+ } else {+ $command = "docker logs {$this->container}";+ }+ }++ if ($this->server->isNonRoot()) {+ $command = parseCommandsByLineForSudo(collect($command), $this->server);+ $command = $command[0];+ }++ $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);++ $allLogs = '';+ Process::run($sshCommand, function (string $type, string $output) use (&$allLogs) {+ $allLogs .= removeAnsiColors($output);+ });++ if ($this->showTimeStamps) {+ $allLogs = str($allLogs)->split('/\n/')->sort(function ($a, $b) {+ $a = explode(' ', $a);+ $b = explode(' ', $b);++ return $a[0] <=> $b[0];+ })->join("\n");+ }++ return sanitizeLogsForExport($allLogs);+ }+ public function render() { return view('livewire.project.shared.get-logs');@@ -316,14 +316,49 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text- d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" /> </svg> </button>- <button x-on:click="downloadLogs()" title="Download Logs"- class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">- <svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"- stroke-width="1.5" stroke="currentColor">- <path stroke-linecap="round" stroke-linejoin="round"- d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />- </svg>- </button>+ <div x-data="{ downloadMenuOpen: false }" class="relative">+ <button x-on:click="downloadMenuOpen = !downloadMenuOpen" title="Download Logs"+ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">+ <svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"+ stroke-width="1.5" stroke="currentColor">+ <path stroke-linecap="round" stroke-linejoin="round"+ d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />+ </svg>+ </button>+ <div x-show="downloadMenuOpen" x-on:click.away="downloadMenuOpen = false"+ x-transition:enter="transition ease-out duration-100"+ x-transition:enter-start="transform opacity-0 scale-95"+ x-transition:enter-end="transform opacity-100 scale-100"+ x-transition:leave="transition ease-in duration-75"+ x-transition:leave-start="transform opacity-100 scale-100"+ x-transition:leave-end="transform opacity-0 scale-95"+ class="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-md bg-white dark:bg-coolgray-200 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">+ <div class="py-1">+ <button x-on:click="downloadLogs(); downloadMenuOpen = false"+ class="block w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-coolgray-300">+ Download displayed logs+ </button>+ <button x-on:click="+ $wire.downloadAllLogs().then(logs => {+ if (!logs) return;+ const blob = new Blob([logs], { type: 'text/plain' });+ const url = URL.createObjectURL(blob);+ const a = document.createElement('a');+ a.href = url;+ const timestamp = new Date().toISOString().slice(0,19).replace(/[T:]/g, '-');+ a.download = containerName + '-all-logs-' + timestamp + '.txt';+ a.click();+ URL.revokeObjectURL(url);+ Livewire.dispatch('success', ['All logs downloaded.']);+ });+ downloadMenuOpen = false;+ "+ class="block w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-coolgray-300">+ Download all logs+ </button>+ </div>+ </div>+ </div> <button wire:click="toggleTimestamps" title="Toggle Timestamps" class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 {{ $showTimeStamps ? '!text-warning' : '' }}"> <svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"@@ -45,12 +45,13 @@ public function run(): void } try {- User::create([+ $user = (new User)->forceFill([ 'id' => 0, 'name' => env('ROOT_USERNAME', 'Root User'), 'email' => env('ROOT_USER_EMAIL'), 'password' => Hash::make(env('ROOT_USER_PASSWORD')), ]);+ $user->save(); echo "\n SUCCESS Root user created successfully.\n\n"; } catch (\Exception $e) { echo "\n ERROR Failed to create root user: {$e->getMessage()}\n\n";@@ -0,0 +1,39 @@+<?php++use Illuminate\Database\Migrations\Migration;+use Illuminate\Support\Facades\Crypt;+use Illuminate\Support\Facades\DB;++class EncryptExistingClickhouseAdminPasswords extends Migration+{+ public function up(): void+ {+ try {+ DB::table('standalone_clickhouses')->chunkById(100, function ($clickhouses) {+ foreach ($clickhouses as $clickhouse) {+ $password = $clickhouse->clickhouse_admin_password;++ if (empty($password)) {+ continue;+ }++ // Skip if already encrypted (idempotent)+ try {+ Crypt::decryptString($password);++ continue;+ } catch (Exception) {+ // Not encrypted yet — encrypt it+ }++ DB::table('standalone_clickhouses')+ ->where('id', $clickhouse->id)+ ->update(['clickhouse_admin_password' => Crypt::encryptString($password)]);+ }+ });+ } catch (Exception $e) {+ echo 'Encrypting ClickHouse admin passwords failed.';+ echo $e->getMessage();+ }+ }+}@@ -4,7 +4,9 @@ use App\Jobs\UpdateStripeCustomerEmailJob; use App\Notifications\Channels\SendsEmail;+use App\Notifications\TransactionalEmails\EmailChangeVerification; use App\Notifications\TransactionalEmails\ResetPassword as TransactionalEmailsResetPassword;+use App\Services\ChangelogService; use App\Traits\DeletesUserSessions; use DateTimeInterface; use Illuminate\Database\Eloquent\Factories\HasFactory;@@ -41,7 +43,13 @@ class User extends Authenticatable implements SendsEmail { use DeletesUserSessions, HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; - protected $guarded = [];+ protected $fillable = [+ 'name',+ 'email',+ 'password',+ 'force_password_reset',+ 'marketing_emails',+ ]; protected $hidden = [ 'password',@@ -87,7 +95,7 @@ protected static function boot() $team['id'] = 0; $team['name'] = 'Root Team'; }- $new_team = Team::create($team);+ $new_team = Team::forceCreate($team); $user->teams()->attach($new_team, ['role' => 'owner']); }); @@ -190,7 +198,7 @@ public function recreate_personal_team() $team['id'] = 0; $team['name'] = 'Root Team'; }- $new_team = Team::create($team);+ $new_team = Team::forceCreate($team); $this->teams()->attach($new_team, ['role' => 'owner']); return $new_team;@@ -228,7 +236,7 @@ public function changelogReads() public function getUnreadChangelogCount(): int {- return app(\App\Services\ChangelogService::class)->getUnreadCountForUser($this);+ return app(ChangelogService::class)->getUnreadCountForUser($this); } public function getRecipients(): array@@ -239,7 +247,7 @@ public function getRecipients(): array public function sendVerificationEmail() { $mail = new MailMessage;- $url = Url::temporarySignedRoute(+ $url = URL::temporarySignedRoute( 'verify.verify', Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), [@@ -401,14 +409,14 @@ public function requestEmailChange(string $newEmail): void $expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10); $expiresAt = Carbon::now()->addMinutes($expiryMinutes); - $this->update([+ $this->forceFill([ 'pending_email' => $newEmail, 'email_change_code' => $code, 'email_change_code_expires_at' => $expiresAt,- ]);+ ])->save(); // Send verification email to new address- $this->notify(new \App\Notifications\TransactionalEmails\EmailChangeVerification($this, $code, $newEmail, $expiresAt));+ $this->notify(new EmailChangeVerification($this, $code, $newEmail, $expiresAt)); } public function isEmailChangeCodeValid(string $code): boolAI-assisted change d190db3f2feac771d77a46d03816cde2637ae21ce3ee1c7e232a0556052a6b17 · Fix b2e8ea2eb854b61cd0efa28be9115aeab878986a1972e994d8e6caaac4a5399b