From 2ff0233f478affa378c53887a7c64b57c81eef8a Mon Sep 17 00:00:00 2001 From: Selim Salihovic Date: Tue, 31 Mar 2026 12:05:37 +0200 Subject: [PATCH 1/8] feat: add configurable Horizon dashboard admin access Add HORIZON_ALLOWED_EMAILS env var to grant additional users access to the Horizon dashboard. Root user (User ID 0) always retains access. Co-Authored-By: Claude Sonnet 4.5 --- .env.development.example | 5 +++++ app/Providers/HorizonServiceProvider.php | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.env.development.example b/.env.development.example index 594b89201..8537a9b99 100644 --- a/.env.development.example +++ b/.env.development.example @@ -24,6 +24,11 @@ RAY_ENABLED=false # Enable Laravel Telescope for debugging TELESCOPE_ENABLED=false +# Laravel Horizon Admin Access +# Comma-separated list of email addresses allowed to access /horizon dashboard +# The root user (User ID 0) always has access +# HORIZON_ALLOWED_EMAILS=admin@example.com,devops@example.com + # Enable Laravel Nightwatch monitoring NIGHTWATCH_ENABLED=false NIGHTWATCH_TOKEN= diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php index 0caa3a3a9..037636496 100644 --- a/app/Providers/HorizonServiceProvider.php +++ b/app/Providers/HorizonServiceProvider.php @@ -55,9 +55,18 @@ protected function gate(): void Gate::define('viewHorizon', function ($user) { $root_user = User::find(0); - return in_array($user->email, [ - $root_user->email, - ]); + // Get additional allowed emails from environment variable + $allowedEmails = array_filter( + array_map('trim', explode(',', env('HORIZON_ALLOWED_EMAILS', ''))) + ); + + // Merge root user email with additional allowed emails + $authorizedEmails = array_merge( + [$root_user->email], + $allowedEmails + ); + + return in_array($user->email, $authorizedEmails); }); } } From 14ecadc0debb2a86975ea4d48dc6abc8ddc3ceb0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:24:12 +0200 Subject: [PATCH 2/8] fix(services): avoid reading bind-mount files when syncing volumes Stop catting remote file contents into memory for non-git bind mounts in getFilesystemVolumesFromServer; git-based volumes still load via loadStorageOnServer(). --- bootstrap/helpers/services.php | 19 +++++++++---------- tests/Unit/LocalFileVolumeContentSizeTest.php | 13 +++++++++++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 20b184a01..e46769a15 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -139,7 +139,7 @@ function replaceVariables(string $variable): Stringable function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Application $oneService, bool $isInit = false) { try { - if ($oneService->getMorphClass() === \App\Models\Application::class) { + if ($oneService->getMorphClass() === Application::class) { $workdir = $oneService->workdir(); $server = $oneService->destination->server; } else { @@ -167,13 +167,12 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli $isDir = instant_remote_process(["test -d $fileLocation && echo OK || echo NOK"], $server); if ($isFile === 'OK') { - // If its a file & exists - $filesystemContent = instant_remote_process(["cat $fileLocation"], $server); if ($fileVolume->is_based_on_git) { - $fileVolume->content = $filesystemContent; + $fileVolume->loadStorageOnServer(); + } else { + $fileVolume->is_directory = false; + $fileVolume->save(); } - $fileVolume->is_directory = false; - $fileVolume->save(); } elseif ($isDir === 'OK') { // If its a directory & exists $fileVolume->content = null; @@ -204,7 +203,7 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli instant_remote_process(["mkdir -p $fileLocation"], $server); } } - } catch (\Throwable $e) { + } catch (Throwable $e) { return handleError($e); } } @@ -214,7 +213,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource) $name = data_get($resource, 'name'); $dockerComposeRaw = data_get($resource, 'service.docker_compose_raw'); if (! $dockerComposeRaw) { - throw new \Exception('No compose file found or not a valid YAML file.'); + throw new Exception('No compose file found or not a valid YAML file.'); } $dockerCompose = Yaml::parse($dockerComposeRaw); @@ -396,7 +395,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource) } } } - } catch (\Throwable $e) { + } catch (Throwable $e) { return handleError($e); } } @@ -495,7 +494,7 @@ function applyServiceApplicationPrerequisites(Service $service): void } } } - } catch (\Throwable $e) { + } catch (Throwable $e) { // Log error but don't throw - prerequisites are nice-to-have, not critical Log::error('Failed to apply service application prerequisites', [ 'service_id' => $service->id, diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php index 1fd315884..0ad69e28a 100644 --- a/tests/Unit/LocalFileVolumeContentSizeTest.php +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -64,3 +64,16 @@ expect($array)->toHaveKey('is_too_large'); expect($array['is_too_large'])->toBeTrue(); }); + +it('does not read regular bind-mounted file contents while loading service settings', function () { + $helpers = file_get_contents(base_path('bootstrap/helpers/services.php')); + $filesystemSync = str($helpers) + ->after('function getFilesystemVolumesFromServer') + ->before('function updateCompose'); + + expect($filesystemSync->value()) + ->not->toContain('instant_remote_process(["cat $fileLocation"]'); + expect($filesystemSync->value()) + ->toContain('if ($fileVolume->is_based_on_git)') + ->toContain('$fileVolume->loadStorageOnServer();'); +}); From 2749f45ba6e464681929e9485730395452a246f3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:21:00 +0200 Subject: [PATCH 3/8] fix: cap remote logs, tasks, and config output sizes Bound proxy config, docker compose, scheduled task, log viewer, and dynamic proxy config reads so large remote output cannot overwhelm PHP. --- app/Actions/Proxy/GetProxyConfiguration.php | 10 ++- app/Jobs/ScheduledTaskJob.php | 12 ++- app/Livewire/Project/Shared/GetLogs.php | 39 ++++++-- .../Server/Proxy/DynamicConfigurations.php | 35 +++++++- app/Models/Application.php | 15 +++- tests/Unit/RemoteOutputSizeLimitsTest.php | 90 +++++++++++++++++++ 6 files changed, 187 insertions(+), 14 deletions(-) create mode 100644 tests/Unit/RemoteOutputSizeLimitsTest.php diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index 159f12252..7df16c52b 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -13,6 +13,8 @@ class GetProxyConfiguration { use AsAction; + public const MAX_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024; + public function handle(Server $server, bool $forceRegenerate = false): string { $proxyType = $server->proxyType(); @@ -98,11 +100,17 @@ private function configMatchesProxyType(string $proxyType, string $configuration private function backfillFromDisk(Server $server): ?string { $proxy_path = $server->proxyPath(); + $configurationPath = escapeshellarg("$proxy_path/docker-compose.yml"); + $readLimit = self::MAX_CONFIGURATION_SIZE_BYTES + 1; $result = instant_remote_process([ "mkdir -p $proxy_path", - "cat $proxy_path/docker-compose.yml 2>/dev/null", + "if [ ! -f {$configurationPath} ]; then exit 0; elif [ \"$(wc -c < {$configurationPath})\" -gt ".self::MAX_CONFIGURATION_SIZE_BYTES." ]; then echo '__COOLIFY_PROXY_CONFIG_TOO_LARGE__'; else head -c {$readLimit} {$configurationPath}; fi", ], $server, false); + if ($result === '__COOLIFY_PROXY_CONFIG_TOO_LARGE__') { + throw new \RuntimeException('Proxy configuration exceeds the 5 MiB size limit.'); + } + if (! empty(trim($result ?? ''))) { $server->proxy->last_saved_proxy_configuration = $result; $server->save(); diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index dc11ec89e..02f3daa3b 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -25,6 +25,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public const MAX_OUTPUT_SIZE_BYTES = 5 * 1024 * 1024; + /** * The number of times the job may be attempted. */ @@ -148,7 +150,8 @@ public function handle(): void foreach ($this->containers as $containerName) { if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; - $exec = "docker exec {$containerName} {$cmd}"; + $execCommand = "docker exec {$containerName} {$cmd}"; + $exec = $this->boundedTaskCommand($execCommand); // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently // See: https://github.com/coollabsio/coolify/issues/6736 $this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true); @@ -204,6 +207,13 @@ public function handle(): void } } + private function boundedTaskCommand(string $command): string + { + $maxOutputBytes = self::MAX_OUTPUT_SIZE_BYTES; + + return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$maxOutputBytes} > \"\$output_file\"; if IFS= read -r -n 1 extra_byte; then printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code"; + } + /** * Calculate the number of seconds to wait before retrying the job. */ diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index d0121bdc5..fa575073f 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -25,6 +25,8 @@ class GetLogs extends Component { public const MAX_LOG_LINES = 50000; + public const MAX_DISPLAY_SIZE_BYTES = 5 * 1024 * 1024; + public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB public string $outputs = ''; @@ -154,14 +156,12 @@ public function getLogs($refresh = false) $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } else { $command = "docker logs -n {$this->numberOfLines} -t {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } } else { if ($this->server->isSwarm()) { @@ -170,22 +170,39 @@ public function getLogs($refresh = false) $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } else { $command = "docker logs -n {$this->numberOfLines} {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } } + $command = $this->boundedLogCommand($command, self::MAX_DISPLAY_SIZE_BYTES); + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); + // Collect new logs into temporary variable first to prevent flickering // (avoids clearing output before new data is ready) // Use array accumulation + implode for O(n) instead of O(n²) string concatenation $logChunks = []; - Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks) { - $logChunks[] = removeAnsiColors($output); + $accumulatedBytes = 0; + $truncated = false; + Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) { + if ($truncated) { + return; + } + + $output = removeAnsiColors($output); + $remainingBytes = self::MAX_DISPLAY_SIZE_BYTES - $accumulatedBytes; + if (strlen($output) > $remainingBytes) { + $logChunks[] = substr($output, 0, max(0, $remainingBytes)); + $truncated = true; + + return; + } + + $logChunks[] = $output; + $accumulatedBytes += strlen($output); }); $newOutputs = implode('', $logChunks); @@ -198,6 +215,10 @@ public function getLogs($refresh = false) })->join("\n"); } + if ($truncated) { + $newOutputs .= "\n\n[... Output truncated at 5MB limit ...]"; + } + // Only update outputs after new data is ready (atomic update prevents flicker) $this->outputs = $newOutputs; } @@ -239,6 +260,7 @@ public function downloadAllLogs(): string $command = $command[0]; } + $command = $this->boundedLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES); $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); // Use array accumulation + implode for O(n) instead of O(n²) string concatenation @@ -287,6 +309,11 @@ public function downloadAllLogs(): string return sanitizeLogsForExport($allLogs); } + private function boundedLogCommand(string $command, int $maxBytes): string + { + return "({$command}) 2>&1 | head -c ".($maxBytes + 1); + } + public function render() { return view('livewire.project.shared.get-logs'); diff --git a/app/Livewire/Server/Proxy/DynamicConfigurations.php b/app/Livewire/Server/Proxy/DynamicConfigurations.php index f824645aa..6351dace8 100644 --- a/app/Livewire/Server/Proxy/DynamicConfigurations.php +++ b/app/Livewire/Server/Proxy/DynamicConfigurations.php @@ -11,6 +11,12 @@ class DynamicConfigurations extends Component { use AuthorizesRequests; + public const MAX_CONFIGURATION_FILE_SIZE_BYTES = 1024 * 1024; + + public const MAX_TOTAL_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024; + + public const MAX_CONFIGURATION_FILES = 100; + public ?Server $server = null; public $parameters = []; @@ -44,15 +50,36 @@ public function loadDynamicConfigurations() return handleError($e, $this); } $proxy_path = $this->server->proxyPath(); - $files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic"], $this->server); + $fileLimit = self::MAX_CONFIGURATION_FILES + 1; + $files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic | head -n {$fileLimit}"], $this->server); $files = collect(explode("\n", $files))->filter(fn ($file) => ! empty($file)); $files = $files->map(fn ($file) => trim($file)); $files = $files->sort(); $contents = collect([]); - foreach ($files as $file) { + $skippedFiles = collect([]); + $totalBytes = 0; + if ($files->count() > self::MAX_CONFIGURATION_FILES) { + $skippedFiles->push('additional files'); + } + foreach ($files->take(self::MAX_CONFIGURATION_FILES) as $file) { $without_extension = str_replace('.', '|', $file); - $content = instant_remote_process(["cat {$proxy_path}/dynamic/{$file}"], $this->server); - $contents[$without_extension] = $content ?? ''; + $filePath = escapeshellarg("{$proxy_path}/dynamic/{$file}"); + $readLimit = self::MAX_CONFIGURATION_FILE_SIZE_BYTES + 1; + $content = instant_remote_process(["head -c {$readLimit} {$filePath}"], $this->server); + $content = $content ?? ''; + $contentBytes = strlen($content); + + if ($contentBytes > self::MAX_CONFIGURATION_FILE_SIZE_BYTES || $totalBytes + $contentBytes > self::MAX_TOTAL_CONFIGURATION_SIZE_BYTES) { + $skippedFiles->push($file); + + continue; + } + + $contents[$without_extension] = $content; + $totalBytes += $contentBytes; + } + if ($skippedFiles->isNotEmpty()) { + $this->dispatch('warning', 'Some dynamic configurations were not loaded because they exceed the safe display limits: '.$skippedFiles->implode(', ')); } $this->contents = $contents; $this->dispatch('$refresh'); diff --git a/app/Models/Application.php b/app/Models/Application.php index 732142b0d..6f1102717 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -119,6 +119,8 @@ class Application extends BaseModel { use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; + private static $parserVersion = '5'; protected $fillable = [ @@ -1936,6 +1938,9 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = $workdir = rtrim($this->base_directory, '/'); $composeFile = $this->docker_compose_location; $fileList = collect([".$workdir$composeFile"]); + $composeFilePath = escapeshellarg(".$workdir$composeFile"); + $composeReadLimit = self::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1; + $readComposeFile = "if [ \"$(wc -c < {$composeFilePath})\" -gt ".self::MAX_DOCKER_COMPOSE_SIZE_BYTES." ]; then echo '__COOLIFY_COMPOSE_TOO_LARGE__'; else head -c {$composeReadLimit} {$composeFilePath}; fi"; $gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid); if (! $gitRemoteStatus['is_accessible']) { throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.'); @@ -1966,7 +1971,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = 'git sparse-checkout init', "git sparse-checkout set {$fileList->implode(' ')}", 'git read-tree -mu HEAD', - "cat .$workdir$composeFile", + $readComposeFile, ]); } else { $commands = collect([ @@ -1978,11 +1983,14 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = 'git sparse-checkout init --cone', "git sparse-checkout set {$fileList->implode(' ')}", 'git read-tree -mu HEAD', - "cat .$workdir$composeFile", + $readComposeFile, ]); } try { $composeFileContent = instant_remote_process($commands, $this->destination->server); + if ($composeFileContent === '__COOLIFY_COMPOSE_TOO_LARGE__') { + throw new RuntimeException('Docker Compose file exceeds the 5 MiB size limit.'); + } } catch (\Exception $e) { // Restore original values on failure only $this->docker_compose_location = $initialDockerComposeLocation; @@ -1998,6 +2006,9 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = } throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.'); } + if (str($e->getMessage())->contains('exceeds the 5 MiB size limit')) { + throw $e; + } throw new RuntimeException('Failed to read the Docker Compose file from the repository.'); } finally { // Cleanup only - restoration happens in catch block diff --git a/tests/Unit/RemoteOutputSizeLimitsTest.php b/tests/Unit/RemoteOutputSizeLimitsTest.php new file mode 100644 index 000000000..e010970bb --- /dev/null +++ b/tests/Unit/RemoteOutputSizeLimitsTest.php @@ -0,0 +1,90 @@ +toContain('MAX_CONFIGURATION_FILE_SIZE_BYTES') + ->toContain('MAX_TOTAL_CONFIGURATION_SIZE_BYTES') + ->toContain('MAX_CONFIGURATION_FILES') + ->toContain('head -c') + ->toContain('$totalBytes'); + + expect(DynamicConfigurations::MAX_CONFIGURATION_FILE_SIZE_BYTES)->toBe(1024 * 1024) + ->and(DynamicConfigurations::MAX_TOTAL_CONFIGURATION_SIZE_BYTES)->toBe(5 * 1024 * 1024) + ->and(DynamicConfigurations::MAX_CONFIGURATION_FILES)->toBe(100); +}); + +it('bounds regular log viewer output before it reaches PHP', function () { + $source = remoteOutputSource('app/Livewire/Project/Shared/GetLogs.php'); + + expect($source) + ->toContain('MAX_DISPLAY_SIZE_BYTES') + ->toContain('boundedLogCommand(') + ->toContain('[... Output truncated at'); + + $method = new ReflectionMethod(GetLogs::class, 'boundedLogCommand'); + $command = $method->invoke(new GetLogs, 'docker logs example', 100); + + expect(GetLogs::MAX_DISPLAY_SIZE_BYTES)->toBe(5 * 1024 * 1024) + ->and($command)->toBe('(docker logs example) 2>&1 | head -c 101'); +}); + +it('bounds docker compose files loaded from git before parsing', function () { + $source = remoteOutputSource('app/Models/Application.php'); + + expect($source) + ->toContain('MAX_DOCKER_COMPOSE_SIZE_BYTES') + ->toContain('MAX_DOCKER_COMPOSE_SIZE_BYTES + 1') + ->toContain('head -c'); + + expect(Application::MAX_DOCKER_COMPOSE_SIZE_BYTES)->toBe(5 * 1024 * 1024); +}); + +it('bounds scheduled task output before storing or notifying', function () { + $source = remoteOutputSource('app/Jobs/ScheduledTaskJob.php'); + + expect($source) + ->toContain('MAX_OUTPUT_SIZE_BYTES') + ->toContain('head -c {$maxOutputBytes}') + ->toContain('[... Output truncated at'); + + $reflection = new ReflectionClass(ScheduledTaskJob::class); + $method = $reflection->getMethod('boundedTaskCommand'); + $command = $method->invoke($reflection->newInstanceWithoutConstructor(), 'printf hello'); + $failureCommand = $method->invoke($reflection->newInstanceWithoutConstructor(), "bash -c 'printf failure; exit 7'"); + exec('bash -n -c '.escapeshellarg($command), $output, $exitCode); + exec('bash -c '.escapeshellarg($command), $commandOutput, $commandExitCode); + exec('bash -c '.escapeshellarg($failureCommand).' 2>&1', $failureOutput, $failureExitCode); + + expect(ScheduledTaskJob::MAX_OUTPUT_SIZE_BYTES)->toBe(5 * 1024 * 1024) + ->and($command)->toContain('head -c 5242880') + ->and($exitCode)->toBe(0) + ->and($commandExitCode)->toBe(0) + ->and(implode("\n", $commandOutput))->toBe('hello') + ->and($failureExitCode)->toBe(7) + ->and(implode("\n", $failureOutput))->toBe('failure'); +}); + +it('bounds proxy configuration backfill before storing it', function () { + $source = remoteOutputSource('app/Actions/Proxy/GetProxyConfiguration.php'); + + expect($source) + ->toContain('MAX_CONFIGURATION_SIZE_BYTES') + ->toContain('MAX_CONFIGURATION_SIZE_BYTES + 1') + ->toContain('head -c') + ->toContain('Proxy configuration exceeds'); + + expect(GetProxyConfiguration::MAX_CONFIGURATION_SIZE_BYTES)->toBe(5 * 1024 * 1024); +}); From 60129c2c4701431a18c15fe4c7b40e4f8607c07c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:02:51 +0200 Subject: [PATCH 4/8] fix: measure log size before ANSI strip; harden tasks and volumes Truncate GetLogs against raw byte length, then strip ANSI colors. Run scheduled-task docker exec with explicit sudo docker and no_sudo to avoid double sudo rewriting on non-root servers. Mark git-based file volumes as files before refreshing content from the server. --- app/Jobs/ScheduledTaskJob.php | 5 ++- app/Livewire/Project/Shared/GetLogs.php | 15 ++++--- bootstrap/helpers/services.php | 5 +-- tests/Feature/GetLogsCommandInjectionTest.php | 41 ++++++++++++++++++- tests/Unit/LocalFileVolumeContentSizeTest.php | 11 +++++ tests/Unit/RemoteOutputSizeLimitsTest.php | 20 +++++++++ 6 files changed, 83 insertions(+), 14 deletions(-) diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index 02f3daa3b..d24f36350 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -150,11 +150,12 @@ public function handle(): void foreach ($this->containers as $containerName) { if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; - $execCommand = "docker exec {$containerName} {$cmd}"; + $dockerCommand = $this->server->isNonRoot() ? 'sudo docker' : 'docker'; + $execCommand = "{$dockerCommand} exec {$containerName} {$cmd}"; $exec = $this->boundedTaskCommand($execCommand); // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently // See: https://github.com/coollabsio/coolify/issues/6736 - $this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->task_output = instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->timeout, disableMultiplexing: true); $this->task_log->update([ 'status' => 'success', 'message' => $this->task_output, diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index fa575073f..67a040ef7 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -192,17 +192,17 @@ public function getLogs($refresh = false) return; } - $output = removeAnsiColors($output); $remainingBytes = self::MAX_DISPLAY_SIZE_BYTES - $accumulatedBytes; - if (strlen($output) > $remainingBytes) { - $logChunks[] = substr($output, 0, max(0, $remainingBytes)); + $outputBytes = strlen($output); + if ($outputBytes > $remainingBytes) { + $logChunks[] = removeAnsiColors(substr($output, 0, max(0, $remainingBytes))); $truncated = true; return; } - $logChunks[] = $output; - $accumulatedBytes += strlen($output); + $logChunks[] = removeAnsiColors($output); + $accumulatedBytes += $outputBytes; }); $newOutputs = implode('', $logChunks); @@ -274,20 +274,19 @@ public function downloadAllLogs(): string return; } - $output = removeAnsiColors($output); $outputBytes = strlen($output); if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) { $remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes; if ($remaining > 0) { - $logChunks[] = substr($output, 0, $remaining); + $logChunks[] = removeAnsiColors(substr($output, 0, $remaining)); } $truncated = true; return; } - $logChunks[] = $output; + $logChunks[] = removeAnsiColors($output); $accumulatedBytes += $outputBytes; }); diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index e46769a15..05430930b 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -167,11 +167,10 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli $isDir = instant_remote_process(["test -d $fileLocation && echo OK || echo NOK"], $server); if ($isFile === 'OK') { + $fileVolume->is_directory = false; + $fileVolume->save(); if ($fileVolume->is_based_on_git) { $fileVolume->loadStorageOnServer(); - } else { - $fileVolume->is_directory = false; - $fileVolume->save(); } } elseif ($isDir === 'OK') { // If its a directory & exists diff --git a/tests/Feature/GetLogsCommandInjectionTest.php b/tests/Feature/GetLogsCommandInjectionTest.php index db75f7b75..c920b94ed 100644 --- a/tests/Feature/GetLogsCommandInjectionTest.php +++ b/tests/Feature/GetLogsCommandInjectionTest.php @@ -3,6 +3,7 @@ use App\Livewire\Project\Shared\GetLogs; use App\Models\Application; use App\Models\Environment; +use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; @@ -10,6 +11,8 @@ use App\Models\User; use App\Support\ValidationPatterns; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Process\FakeProcessResult; +use Illuminate\Support\Facades\Process; use Livewire\Attributes\Locked; use Livewire\Livewire; @@ -20,7 +23,11 @@ $this->team = Team::factory()->create(); $this->user->teams()->attach($this->team, ['role' => 'owner']); - $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + ]); // Server::created auto-creates a StandaloneDocker, reuse it $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); $this->project = Project::factory()->create(['team_id' => $this->team->id]); @@ -67,6 +74,38 @@ }); describe('GetLogs Livewire action validation', function () { + test('getLogs marks ANSI-colored output truncated based on raw bytes', function () { + $this->server->settings->fill([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ])->save(); + $server = Server::with('settings')->find($this->server->id); + $output = "\e[31m".str_repeat('a', GetLogs::MAX_DISPLAY_SIZE_BYTES - 4); + + expect(strlen($output))->toBe(GetLogs::MAX_DISPLAY_SIZE_BYTES + 1); + + Process::shouldReceive('timeout')->once()->andReturnSelf(); + Process::shouldReceive('run')->andReturnUsing(function (string $command, ?callable $callback = null) use ($output): FakeProcessResult { + if ($callback) { + $callback('out', $output); + } + + return new FakeProcessResult(command: $command); + }); + + $component = new GetLogs; + $component->server = $server; + $component->resource = $this->application; + $component->container = 'test-container'; + $component->showTimeStamps = false; + $component->getLogs(true); + + expect($component->outputs) + ->toContain('[... Output truncated at 5MB limit ...]') + ->not->toContain("\e[31m"); + }); + test('getLogs rejects invalid container name', function () { // Make server functional by setting settings directly $this->server->settings->fill([ diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php index 0ad69e28a..195453978 100644 --- a/tests/Unit/LocalFileVolumeContentSizeTest.php +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -77,3 +77,14 @@ ->toContain('if ($fileVolume->is_based_on_git)') ->toContain('$fileVolume->loadStorageOnServer();'); }); + +it('marks git-based file volumes as files before refreshing their content', function () { + $helpers = file_get_contents(base_path('bootstrap/helpers/services.php')); + $fileBranch = str($helpers) + ->after("if (\$isFile === 'OK') {") + ->before("} elseif (\$isDir === 'OK') {"); + + expect($fileBranch->value())->toMatch( + '/\$fileVolume->is_directory = false;\s+\$fileVolume->save\(\);\s+if \(\$fileVolume->is_based_on_git\) \{/' + ); +}); diff --git a/tests/Unit/RemoteOutputSizeLimitsTest.php b/tests/Unit/RemoteOutputSizeLimitsTest.php index e010970bb..b8341dd73 100644 --- a/tests/Unit/RemoteOutputSizeLimitsTest.php +++ b/tests/Unit/RemoteOutputSizeLimitsTest.php @@ -5,6 +5,7 @@ use App\Livewire\Project\Shared\GetLogs; use App\Livewire\Server\Proxy\DynamicConfigurations; use App\Models\Application; +use App\Models\Server; function remoteOutputSource(string $path): string { @@ -77,6 +78,25 @@ function remoteOutputSource(string $path): string ->and(implode("\n", $failureOutput))->toBe('failure'); }); +it('does not pass the scheduled task output wrapper through the sudo rewriter', function () { + $source = remoteOutputSource('app/Jobs/ScheduledTaskJob.php'); + $reflection = new ReflectionClass(ScheduledTaskJob::class); + $method = $reflection->getMethod('boundedTaskCommand'); + $command = $method->invoke($reflection->newInstanceWithoutConstructor(), 'sudo docker exec example true'); + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu'); + $rewrittenCommand = parseCommandsByLineForSudo(collect([$command]), $server)[0]; + + exec('bash -n -c '.escapeshellarg($command), $output, $exitCode); + exec('bash -n -c '.escapeshellarg($rewrittenCommand).' 2>/dev/null', $rewrittenOutput, $rewrittenExitCode); + + expect($source) + ->toContain("\$dockerCommand = \$this->server->isNonRoot() ? 'sudo docker' : 'docker'") + ->toContain('instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true') + ->and($exitCode)->toBe(0) + ->and($rewrittenExitCode)->not->toBe(0); +}); + it('bounds proxy configuration backfill before storing it', function () { $source = remoteOutputSource('app/Actions/Proxy/GetProxyConfiguration.php'); From 49c8f6cb5d1850fd9c49b59f1f06f359ea0172de Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:31:26 +0200 Subject: [PATCH 5/8] fix: cap remote file and task reads after size-check races Read local file volumes with a bounded head instead of cat so a file that grows after the size check cannot be fully slurped into PHP memory. Treat oversized bounded reads as too large and skip binary detection on that placeholder. For scheduled tasks, read one extra byte, then truncate and append the 5MB notice instead of relying on a one-byte peek. --- app/Jobs/ScheduledTaskJob.php | 3 +- app/Models/LocalFileVolume.php | 27 ++++++++++++-- tests/Unit/LocalFileVolumeContentSizeTest.php | 35 +++++++++++++++++++ tests/Unit/RemoteOutputSizeLimitsTest.php | 18 ++++++++-- 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index d24f36350..f7bd5f933 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -211,8 +211,9 @@ public function handle(): void private function boundedTaskCommand(string $command): string { $maxOutputBytes = self::MAX_OUTPUT_SIZE_BYTES; + $readLimit = $maxOutputBytes + 1; - return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$maxOutputBytes} > \"\$output_file\"; if IFS= read -r -n 1 extra_byte; then printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code"; + return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$readLimit} > \"\$output_file\"; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$(wc -c < \"\$output_file\")\" -gt {$maxOutputBytes} ]; then truncate -s {$maxOutputBytes} \"\$output_file\"; printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code"; } /** diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 968e6c3d0..6b0124cb9 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -113,9 +113,9 @@ public function loadStorageOnServer() return; } - $content = instant_remote_process(["cat {$escapedPath}"], $server, false); + $content = $this->readRemoteFileContent($escapedPath, $server); // 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)) { + if ($content !== self::TOO_LARGE_PLACEHOLDER && (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content))) { $content = self::BINARY_PLACEHOLDER; } $this->content = $content; @@ -136,6 +136,27 @@ protected function remoteFileExceedsLimit(string $escapedPath, $server): bool return $size > self::MAX_CONTENT_SIZE; } + /** + * Cap the remote read itself so a file that grows after the size check + * cannot be fully slurped into PHP memory. + */ + protected function readRemoteFileContent(string $escapedPath, $server): string + { + $readLimit = self::MAX_CONTENT_SIZE + 1; + $content = instant_remote_process(["head -c {$readLimit} {$escapedPath}"], $server, false); + + return self::contentFromBoundedRead($content); + } + + public static function contentFromBoundedRead(?string $content): string + { + if (strlen((string) $content) > self::MAX_CONTENT_SIZE) { + return self::TOO_LARGE_PLACEHOLDER; + } + + return (string) $content; + } + public function deleteStorageOnServer() { if ($this->is_host_file) { @@ -228,7 +249,7 @@ public function saveStorageOnServer() if ($this->remoteFileExceedsLimit($escapedPath, $server)) { $this->content = self::TOO_LARGE_PLACEHOLDER; } else { - $this->content = instant_remote_process(["cat {$escapedPath}"], $server, false); + $this->content = $this->readRemoteFileContent($escapedPath, $server); } $this->is_directory = false; $this->save(); diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php index 195453978..1894bf439 100644 --- a/tests/Unit/LocalFileVolumeContentSizeTest.php +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -88,3 +88,38 @@ '/\$fileVolume->is_directory = false;\s+\$fileVolume->save\(\);\s+if \(\$fileVolume->is_based_on_git\) \{/' ); }); + +it('bounds the remote file read itself to prevent a size-check race', function () { + $source = file_get_contents(app_path('Models/LocalFileVolume.php')); + $loadStorage = str($source) + ->after('public function loadStorageOnServer()') + ->before('public function deleteStorageOnServer()'); + + expect($loadStorage->value()) + ->toContain('head -c') + ->not->toContain('instant_remote_process(["cat {$escapedPath}"]'); +}); + +it('bounds directory-to-file conflict reads the same way', function () { + $source = file_get_contents(app_path('Models/LocalFileVolume.php')); + $saveStorage = str($source) + ->after('public function saveStorageOnServer()') + ->before('protected function plainMountPath'); + + expect($saveStorage->value()) + ->not->toContain('instant_remote_process(["cat {$escapedPath}"]'); +}); + +it('treats a bounded remote read that exceeds the limit as too large', function () { + $oversized = str_repeat('a', LocalFileVolume::MAX_CONTENT_SIZE + 1); + + expect(LocalFileVolume::contentFromBoundedRead($oversized)) + ->toBe(LocalFileVolume::TOO_LARGE_PLACEHOLDER); +}); + +it('keeps a bounded remote read that fits the limit', function () { + expect(LocalFileVolume::contentFromBoundedRead('hello')) + ->toBe('hello') + ->and(LocalFileVolume::contentFromBoundedRead(null)) + ->toBe(''); +}); diff --git a/tests/Unit/RemoteOutputSizeLimitsTest.php b/tests/Unit/RemoteOutputSizeLimitsTest.php index b8341dd73..3660e8828 100644 --- a/tests/Unit/RemoteOutputSizeLimitsTest.php +++ b/tests/Unit/RemoteOutputSizeLimitsTest.php @@ -58,7 +58,7 @@ function remoteOutputSource(string $path): string expect($source) ->toContain('MAX_OUTPUT_SIZE_BYTES') - ->toContain('head -c {$maxOutputBytes}') + ->toContain('head -c {$readLimit}') ->toContain('[... Output truncated at'); $reflection = new ReflectionClass(ScheduledTaskJob::class); @@ -70,7 +70,7 @@ function remoteOutputSource(string $path): string exec('bash -c '.escapeshellarg($failureCommand).' 2>&1', $failureOutput, $failureExitCode); expect(ScheduledTaskJob::MAX_OUTPUT_SIZE_BYTES)->toBe(5 * 1024 * 1024) - ->and($command)->toContain('head -c 5242880') + ->and($command)->toContain('head -c 5242881') ->and($exitCode)->toBe(0) ->and($commandExitCode)->toBe(0) ->and(implode("\n", $commandOutput))->toBe('hello') @@ -78,6 +78,20 @@ function remoteOutputSource(string $path): string ->and(implode("\n", $failureOutput))->toBe('failure'); }); +it('marks scheduled task output that exceeds the limit as truncated', function () { + $reflection = new ReflectionClass(ScheduledTaskJob::class); + $method = $reflection->getMethod('boundedTaskCommand'); + $largeOutputCommand = 'head -c '.(ScheduledTaskJob::MAX_OUTPUT_SIZE_BYTES + 1).' /dev/zero | tr "\\0" "x"'; + $command = $method->invoke($reflection->newInstanceWithoutConstructor(), $largeOutputCommand); + + exec('bash -c '.escapeshellarg($command), $output, $exitCode); + $taskOutput = implode("\n", $output); + + expect($exitCode)->toBe(0) + ->and(strlen($taskOutput))->toBeGreaterThan(ScheduledTaskJob::MAX_OUTPUT_SIZE_BYTES) + ->and($taskOutput)->toEndWith('[... Output truncated at 5MB limit ...]'); +}); + it('does not pass the scheduled task output wrapper through the sudo rewriter', function () { $source = remoteOutputSource('app/Jobs/ScheduledTaskJob.php'); $reflection = new ReflectionClass(ScheduledTaskJob::class); From 342be9e99b9f9f2ce9d5b00ce3cb6608a375b115 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:52:51 +0200 Subject: [PATCH 6/8] chore: remove horizon var from .env.development --- .env.development.example | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.env.development.example b/.env.development.example index f97b9c65e..380f10a44 100644 --- a/.env.development.example +++ b/.env.development.example @@ -41,11 +41,6 @@ DB_PORT=5432 VITE_HOST=localhost VITE_PORT=5173 -# Laravel Horizon Admin Access -# Comma-separated list of email addresses allowed to access /horizon dashboard -# The root user (User ID 0) always has access -# HORIZON_ALLOWED_EMAILS=admin@example.com,devops@example.com - # Enable Laravel Nightwatch monitoring NIGHTWATCH_ENABLED=false NIGHTWATCH_TOKEN= From 5da6c5e11822270212b2b558fc6839452f803b86 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:07:49 +0200 Subject: [PATCH 7/8] feat(horizon): read allowed emails from config and allow root user by id - read allowed emails from config instead of env(), which returns null once configs are cached in production - allow the root user by id instead of matching their email - clean up gate code --- app/Providers/HorizonServiceProvider.php | 23 +++++++++-------------- config/horizon.php | 12 ++++++++++++ 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php index 1978ea0cb..9fe33b2cd 100644 --- a/app/Providers/HorizonServiceProvider.php +++ b/app/Providers/HorizonServiceProvider.php @@ -75,21 +75,16 @@ public function boot(): void protected function gate(): void { - Gate::define('viewHorizon', function ($user) { - $root_user = User::find(0); + Gate::define('viewHorizon', function (User $user) { + if ($user->id === 0) { + return true; + } - // Get additional allowed emails from environment variable - $allowedEmails = array_filter( - array_map('trim', explode(',', env('HORIZON_ALLOWED_EMAILS', ''))) - ); - - // Merge root user email with additional allowed emails - $authorizedEmails = array_merge( - [$root_user->email], - $allowedEmails - ); - - return in_array($user->email, $authorizedEmails); + return str(config()->string('horizon.allowed_emails')) + ->lower() + ->explode(',') + ->map(fn (string $email) => trim($email)) + ->contains($user->email); }); } } diff --git a/config/horizon.php b/config/horizon.php index d17803849..d86c52aff 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -30,6 +30,18 @@ 'path' => env('HORIZON_PATH', 'horizon'), + /* + |-------------------------------------------------------------------------- + | Horizon Allowed Emails + |-------------------------------------------------------------------------- + | + | A comma-separated list of email addresses that may access the Horizon + | dashboard in addition to the root user. + | + */ + + 'allowed_emails' => env('HORIZON_ALLOWED_EMAILS', ''), + /* |-------------------------------------------------------------------------- | Horizon Redis Connection From 288dc98ca7bd803fd927e6c677550f10e136842b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:03:33 +0200 Subject: [PATCH 8/8] fix: enforce remote output size limits --- app/Actions/Proxy/GetProxyConfiguration.php | 2 +- app/Models/Application.php | 2 +- tests/Feature/GetLogsCommandInjectionTest.php | 6 +- tests/Pest.php | 17 +++ tests/Unit/LocalFileVolumeContentSizeTest.php | 73 +++++++---- tests/Unit/RemoteOutputSizeLimitsTest.php | 119 +++++++++++++----- 6 files changed, 163 insertions(+), 56 deletions(-) diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index 7df16c52b..d09aae802 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -107,7 +107,7 @@ private function backfillFromDisk(Server $server): ?string "if [ ! -f {$configurationPath} ]; then exit 0; elif [ \"$(wc -c < {$configurationPath})\" -gt ".self::MAX_CONFIGURATION_SIZE_BYTES." ]; then echo '__COOLIFY_PROXY_CONFIG_TOO_LARGE__'; else head -c {$readLimit} {$configurationPath}; fi", ], $server, false); - if ($result === '__COOLIFY_PROXY_CONFIG_TOO_LARGE__') { + if ($result === '__COOLIFY_PROXY_CONFIG_TOO_LARGE__' || strlen($result ?? '') > self::MAX_CONFIGURATION_SIZE_BYTES) { throw new \RuntimeException('Proxy configuration exceeds the 5 MiB size limit.'); } diff --git a/app/Models/Application.php b/app/Models/Application.php index a74941f02..fef76cd39 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -2161,7 +2161,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = } try { $composeFileContent = instant_remote_process($commands, $this->destination->server); - if ($composeFileContent === '__COOLIFY_COMPOSE_TOO_LARGE__') { + if ($composeFileContent === '__COOLIFY_COMPOSE_TOO_LARGE__' || strlen($composeFileContent) > self::MAX_DOCKER_COMPOSE_SIZE_BYTES) { throw new RuntimeException('Docker Compose file exceeds the 5 MiB size limit.'); } } catch (\Exception $e) { diff --git a/tests/Feature/GetLogsCommandInjectionTest.php b/tests/Feature/GetLogsCommandInjectionTest.php index c920b94ed..2c86d7328 100644 --- a/tests/Feature/GetLogsCommandInjectionTest.php +++ b/tests/Feature/GetLogsCommandInjectionTest.php @@ -80,7 +80,7 @@ 'is_usable' => true, 'force_disabled' => false, ])->save(); - $server = Server::with('settings')->find($this->server->id); + $server = Server::with('settings')->findOrFail($this->server->id); $output = "\e[31m".str_repeat('a', GetLogs::MAX_DISPLAY_SIZE_BYTES - 4); expect(strlen($output))->toBe(GetLogs::MAX_DISPLAY_SIZE_BYTES + 1); @@ -114,7 +114,7 @@ 'force_disabled' => false, ])->save(); // Reload server with fresh settings to ensure casted values - $server = Server::with('settings')->find($this->server->id); + $server = Server::with('settings')->findOrFail($this->server->id); Livewire::test(GetLogs::class, [ 'server' => $server, @@ -144,7 +144,7 @@ 'is_usable' => true, 'force_disabled' => false, ])->save(); - $server = Server::with('settings')->find($this->server->id); + $server = Server::with('settings')->findOrFail($this->server->id); Livewire::test(GetLogs::class, [ 'server' => $server, diff --git a/tests/Pest.php b/tests/Pest.php index 7100bfaa7..25d944010 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -27,6 +27,23 @@ require_once __DIR__.'/Support/BrowserTestHelpers.php'; +function remoteOutputSource(string $path): string +{ + $fixturePath = dirname(__DIR__).'/'.$path; + + if (! is_readable($fixturePath)) { + throw new RuntimeException("Unable to read source fixture: {$fixturePath}"); + } + + $source = file_get_contents($fixturePath); + + if ($source === false) { + throw new RuntimeException("Unable to read source fixture: {$fixturePath}"); + } + + return $source; +} + /* |-------------------------------------------------------------------------- | Test Hooks diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php index 1894bf439..2a2223401 100644 --- a/tests/Unit/LocalFileVolumeContentSizeTest.php +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -9,10 +9,18 @@ * payload, crashing the browser. */ +use App\Models\Application; use App\Models\LocalFileVolume; +use App\Models\PrivateKey; +use App\Models\Server; +use App\Models\User; +use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Process; +use Illuminate\Support\Facades\Storage; use Tests\TestCase; -uses(TestCase::class); +uses(TestCase::class, RefreshDatabase::class); it('exposes a 5 MiB content size limit', function () { expect(LocalFileVolume::MAX_CONTENT_SIZE)->toBe(5_242_880); @@ -66,31 +74,50 @@ }); it('does not read regular bind-mounted file contents while loading service settings', function () { - $helpers = file_get_contents(base_path('bootstrap/helpers/services.php')); - $filesystemSync = str($helpers) - ->after('function getFilesystemVolumesFromServer') - ->before('function updateCompose'); + $user = User::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $user->teams()->first()->id]); + Storage::fake('ssh-keys'); + $server = Server::factory()->create([ + 'team_id' => $user->teams()->first()->id, + 'private_key_id' => $privateKey->id, + ]); - expect($filesystemSync->value()) - ->not->toContain('instant_remote_process(["cat $fileLocation"]'); - expect($filesystemSync->value()) - ->toContain('if ($fileVolume->is_based_on_git)') - ->toContain('$fileVolume->loadStorageOnServer();'); -}); + $volume = Mockery::mock(LocalFileVolume::class)->makePartial(); + $volume->fs_path = '/data/large.bin'; + $volume->is_based_on_git = false; + $volume->shouldReceive('save')->once(); + $volume->shouldNotReceive('loadStorageOnServer'); -it('marks git-based file volumes as files before refreshing their content', function () { - $helpers = file_get_contents(base_path('bootstrap/helpers/services.php')); - $fileBranch = str($helpers) - ->after("if (\$isFile === 'OK') {") - ->before("} elseif (\$isDir === 'OK') {"); + $fileStorages = Mockery::mock(MorphMany::class); + $fileStorages->shouldReceive('get')->once()->andReturn(collect([$volume])); - expect($fileBranch->value())->toMatch( - '/\$fileVolume->is_directory = false;\s+\$fileVolume->save\(\);\s+if \(\$fileVolume->is_based_on_git\) \{/' - ); + $application = Mockery::mock(Application::class)->makePartial(); + $application->shouldReceive('getMorphClass')->andReturn(Application::class); + $application->shouldReceive('workdir')->once()->andReturn('/data/application'); + $application->shouldReceive('fileStorages')->once()->andReturn($fileStorages); + $application->setRelation('destination', (object) ['server' => $server]); + + Process::fake(function ($process) { + if (str_contains($process->command, 'test -f /data/large.bin')) { + return Process::result(output: 'OK'); + } + + if (str_contains($process->command, 'test -d /data/large.bin')) { + return Process::result(output: 'NOK'); + } + + return Process::result(); + }); + + getFilesystemVolumesFromServer($application); + + expect($volume->is_directory)->toBeFalse(); + Process::assertRan(fn ($process) => str_contains($process->command, 'test -f /data/large.bin')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'cat /data/large.bin') || str_contains($process->command, 'head -c')); }); it('bounds the remote file read itself to prevent a size-check race', function () { - $source = file_get_contents(app_path('Models/LocalFileVolume.php')); + $source = remoteOutputSource('app/Models/LocalFileVolume.php'); $loadStorage = str($source) ->after('public function loadStorageOnServer()') ->before('public function deleteStorageOnServer()'); @@ -101,7 +128,7 @@ }); it('bounds directory-to-file conflict reads the same way', function () { - $source = file_get_contents(app_path('Models/LocalFileVolume.php')); + $source = remoteOutputSource('app/Models/LocalFileVolume.php'); $saveStorage = str($source) ->after('public function saveStorageOnServer()') ->before('protected function plainMountPath'); @@ -118,8 +145,12 @@ }); it('keeps a bounded remote read that fits the limit', function () { + $maximumSizedContent = str_repeat('a', LocalFileVolume::MAX_CONTENT_SIZE); + expect(LocalFileVolume::contentFromBoundedRead('hello')) ->toBe('hello') + ->and(LocalFileVolume::contentFromBoundedRead($maximumSizedContent)) + ->toBe($maximumSizedContent) ->and(LocalFileVolume::contentFromBoundedRead(null)) ->toBe(''); }); diff --git a/tests/Unit/RemoteOutputSizeLimitsTest.php b/tests/Unit/RemoteOutputSizeLimitsTest.php index 3660e8828..dda82a0fb 100644 --- a/tests/Unit/RemoteOutputSizeLimitsTest.php +++ b/tests/Unit/RemoteOutputSizeLimitsTest.php @@ -5,27 +5,65 @@ use App\Livewire\Project\Shared\GetLogs; use App\Livewire\Server\Proxy\DynamicConfigurations; use App\Models\Application; +use App\Models\PrivateKey; use App\Models\Server; +use App\Models\User; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Process; +use Illuminate\Support\Facades\Storage; +use Spatie\SchemalessAttributes\SchemalessAttributes; +use Tests\TestCase; -function remoteOutputSource(string $path): string +uses(TestCase::class, RefreshDatabase::class); + +function remoteOutputTestServer(): Server { - return file_get_contents(__DIR__.'/../../'.$path); + $user = User::factory()->create(); + $team = $user->teams()->first(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + Storage::fake('ssh-keys'); + + return Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + ]); } it('bounds dynamic proxy configuration files and their combined Livewire payload', function () { - $source = remoteOutputSource('app/Livewire/Server/Proxy/DynamicConfigurations.php'); + $server = Mockery::mock(remoteOutputTestServer())->makePartial(); + $server->shouldReceive('proxyPath')->andReturn('/data/proxy'); + $files = collect(range(1, 101))->map(fn (int $number) => sprintf('file%03d.yml', $number))->implode("\n"); - expect($source) - ->toContain('MAX_CONFIGURATION_FILE_SIZE_BYTES') - ->toContain('MAX_TOTAL_CONFIGURATION_SIZE_BYTES') - ->toContain('MAX_CONFIGURATION_FILES') - ->toContain('head -c') - ->toContain('$totalBytes'); + Process::fake(function ($process) use ($files) { + if (str_contains($process->command, 'ls -1')) { + return Process::result(output: $files); + } - expect(DynamicConfigurations::MAX_CONFIGURATION_FILE_SIZE_BYTES)->toBe(1024 * 1024) - ->and(DynamicConfigurations::MAX_TOTAL_CONFIGURATION_SIZE_BYTES)->toBe(5 * 1024 * 1024) - ->and(DynamicConfigurations::MAX_CONFIGURATION_FILES)->toBe(100); + if (str_contains($process->command, 'file001.yml')) { + return Process::result(output: str_repeat('x', DynamicConfigurations::MAX_CONFIGURATION_FILE_SIZE_BYTES + 1)); + } + + if (preg_match('/file00[2-6]\.yml/', $process->command)) { + return Process::result(output: str_repeat('x', DynamicConfigurations::MAX_CONFIGURATION_FILE_SIZE_BYTES)); + } + + return Process::result(output: 'x'); + }); + + $component = Mockery::mock(DynamicConfigurations::class)->makePartial(); + $component->server = $server; + $component->shouldReceive('authorize')->once(); + $component->shouldReceive('dispatch')->andReturnSelf(); + $component->loadDynamicConfigurations(); + + expect($component->contents)->toHaveCount(5) + ->toHaveKeys(['file002|yml', 'file003|yml', 'file004|yml', 'file005|yml', 'file006|yml']) + ->not->toHaveKeys(['file001|yml', 'file007|yml', 'file101|yml']); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'file101.yml')); }); +it('fails explicitly when a source fixture cannot be read', function () { + remoteOutputSource('missing-source-fixture.php'); +})->throws(RuntimeException::class, 'Unable to read source fixture:'); it('bounds regular log viewer output before it reaches PHP', function () { $source = remoteOutputSource('app/Livewire/Project/Shared/GetLogs.php'); @@ -42,17 +80,34 @@ function remoteOutputSource(string $path): string ->and($command)->toBe('(docker logs example) 2>&1 | head -c 101'); }); -it('bounds docker compose files loaded from git before parsing', function () { - $source = remoteOutputSource('app/Models/Application.php'); +it('rejects oversized docker compose files loaded from git before parsing', function () { + $server = remoteOutputTestServer(); - expect($source) - ->toContain('MAX_DOCKER_COMPOSE_SIZE_BYTES') - ->toContain('MAX_DOCKER_COMPOSE_SIZE_BYTES + 1') - ->toContain('head -c'); + $application = Mockery::mock(Application::class)->makePartial(); + $application->base_directory = '/'; + $application->docker_compose_location = '/docker-compose.yml'; + $application->setRelation('destination', (object) ['server' => $server]); + $application->shouldReceive('generateGitImportCommands')->andReturn(['commands' => collect(['git clone example checkout'])]); + $application->shouldReceive('getGitRemoteStatus')->andReturn(['is_accessible' => true]); + $application->shouldReceive('save')->once(); - expect(Application::MAX_DOCKER_COMPOSE_SIZE_BYTES)->toBe(5 * 1024 * 1024); + Process::fake(function ($process) { + if (str_contains($process->command, 'git --version')) { + return Process::result(output: 'git version 2.40.0'); + } + + if (str_contains($process->command, 'wc -c')) { + return Process::result(output: '__COOLIFY_COMPOSE_TOO_LARGE__'); + } + + return Process::result(); + }); + + expect(fn () => $application->loadComposeFile()) + ->toThrow(RuntimeException::class, 'Docker Compose file exceeds the 5 MiB size limit.'); + expect($application->docker_compose_raw)->toBeNull(); + Process::assertRan(fn ($process) => str_contains($process->command, 'head -c '.(Application::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1))); }); - it('bounds scheduled task output before storing or notifying', function () { $source = remoteOutputSource('app/Jobs/ScheduledTaskJob.php'); @@ -97,8 +152,7 @@ function remoteOutputSource(string $path): string $reflection = new ReflectionClass(ScheduledTaskJob::class); $method = $reflection->getMethod('boundedTaskCommand'); $command = $method->invoke($reflection->newInstanceWithoutConstructor(), 'sudo docker exec example true'); - $server = Mockery::mock(Server::class)->makePartial(); - $server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu'); + $server = new Server(['user' => 'ubuntu']); $rewrittenCommand = parseCommandsByLineForSudo(collect([$command]), $server)[0]; exec('bash -n -c '.escapeshellarg($command), $output, $exitCode); @@ -111,14 +165,19 @@ function remoteOutputSource(string $path): string ->and($rewrittenExitCode)->not->toBe(0); }); -it('bounds proxy configuration backfill before storing it', function () { - $source = remoteOutputSource('app/Actions/Proxy/GetProxyConfiguration.php'); +it('rejects oversized proxy configuration backfill before persistence', function () { + $proxy = Mockery::mock(SchemalessAttributes::class); + $proxy->shouldNotReceive('set'); + $server = Mockery::mock(remoteOutputTestServer())->makePartial(); + $server->shouldReceive('proxyPath')->andReturn('/data/proxy'); + $server->shouldReceive('getAttribute')->with('proxy')->andReturn($proxy); + $server->shouldNotReceive('save'); - expect($source) - ->toContain('MAX_CONFIGURATION_SIZE_BYTES') - ->toContain('MAX_CONFIGURATION_SIZE_BYTES + 1') - ->toContain('head -c') - ->toContain('Proxy configuration exceeds'); + Process::fake(['*' => Process::result(output: '__COOLIFY_PROXY_CONFIG_TOO_LARGE__')]); - expect(GetProxyConfiguration::MAX_CONFIGURATION_SIZE_BYTES)->toBe(5 * 1024 * 1024); + $method = new ReflectionMethod(GetProxyConfiguration::class, 'backfillFromDisk'); + + expect(fn () => $method->invoke(new GetProxyConfiguration, $server)) + ->toThrow(RuntimeException::class, 'Proxy configuration exceeds the 5 MiB size limit.'); + Process::assertRan(fn ($process) => str_contains($process->command, 'head -c '.(GetProxyConfiguration::MAX_CONFIGURATION_SIZE_BYTES + 1))); });