fix(services): avoid loading bind-mount file contents (#10960)

This commit is contained in:
Andras Bacsai 2026-08-18 14:05:15 +02:00 committed by GitHub
commit 00b7861a8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 482 additions and 34 deletions

View file

@ -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__' || strlen($result ?? '') > self::MAX_CONFIGURATION_SIZE_BYTES) {
throw new \RuntimeException('Proxy configuration exceeds the 5 MiB size limit.');
}
if (! empty(trim($result ?? ''))) {
$server->proxy->last_saved_proxy_configuration = $result;
$server->save();

View file

@ -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,10 +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)."'";
$exec = "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,
@ -204,6 +208,14 @@ 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 {$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";
}
/**
* Calculate the number of seconds to wait before retrying the job.
*/

View file

@ -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) {
$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;
}
$remainingBytes = self::MAX_DISPLAY_SIZE_BYTES - $accumulatedBytes;
$outputBytes = strlen($output);
if ($outputBytes > $remainingBytes) {
$logChunks[] = removeAnsiColors(substr($output, 0, max(0, $remainingBytes)));
$truncated = true;
return;
}
$logChunks[] = removeAnsiColors($output);
$accumulatedBytes += $outputBytes;
});
$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
@ -252,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;
});
@ -287,6 +308,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');

View file

@ -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');

View file

@ -122,6 +122,8 @@ class Application extends BaseModel
{
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes;
public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024;
private static $parserVersion = '5';
protected $fillable = [
@ -2109,6 +2111,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.');
@ -2139,7 +2144,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([
@ -2151,11 +2156,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__' || strlen($composeFileContent) > self::MAX_DOCKER_COMPOSE_SIZE_BYTES) {
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;
@ -2171,6 +2179,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

View file

@ -138,9 +138,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;
@ -161,6 +161,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) {
@ -253,7 +274,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();

View file

@ -75,12 +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;
}
return in_array($user->email, [
$root_user->email,
]);
return str(config()->string('horizon.allowed_emails'))
->lower()
->explode(',')
->map(fn (string $email) => trim($email))
->contains($user->email);
});
}
}

View file

@ -167,13 +167,11 @@ 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->is_directory = false;
$fileVolume->save();
if ($fileVolume->is_based_on_git) {
$fileVolume->loadStorageOnServer();
}
} elseif ($isDir === 'OK') {
// If its a directory & exists
$fileVolume->content = null;

View file

@ -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

View file

@ -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')->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);
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([
@ -75,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,
@ -105,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,

View file

@ -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

View file

@ -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);
@ -64,3 +72,85 @@
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 () {
$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,
]);
$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');
$fileStorages = Mockery::mock(MorphMany::class);
$fileStorages->shouldReceive('get')->once()->andReturn(collect([$volume]));
$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 = remoteOutputSource('app/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 = remoteOutputSource('app/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 () {
$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('');
});

View file

@ -0,0 +1,183 @@
<?php
use App\Actions\Proxy\GetProxyConfiguration;
use App\Jobs\ScheduledTaskJob;
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;
uses(TestCase::class, RefreshDatabase::class);
function remoteOutputTestServer(): Server
{
$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 () {
$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");
Process::fake(function ($process) use ($files) {
if (str_contains($process->command, 'ls -1')) {
return Process::result(output: $files);
}
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');
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('rejects oversized docker compose files loaded from git before parsing', function () {
$server = remoteOutputTestServer();
$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();
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');
expect($source)
->toContain('MAX_OUTPUT_SIZE_BYTES')
->toContain('head -c {$readLimit}')
->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 5242881')
->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('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);
$method = $reflection->getMethod('boundedTaskCommand');
$command = $method->invoke($reflection->newInstanceWithoutConstructor(), 'sudo docker exec example true');
$server = new Server(['user' => '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('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');
Process::fake(['*' => Process::result(output: '__COOLIFY_PROXY_CONFIG_TOO_LARGE__')]);
$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)));
});