mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
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.
This commit is contained in:
1 parent
14ecadc0de
commit
2749f45ba6
6 files changed
+187
-14
No files matched your search
@@ -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 @@ class GetProxyConfiguration
|
||||
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();
|
||||
|
||||
@@ -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 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||
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 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -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 @@ class GetLogs extends Component
|
||||
$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 @@ class GetLogs extends Component
|
||||
$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 @@ class GetLogs extends Component
|
||||
})->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 @@ class GetLogs extends Component
|
||||
$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 @@ class GetLogs extends Component
|
||||
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');
|
||||
|
||||
@@ -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 @@ class DynamicConfigurations extends Component
|
||||
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');
|
||||
|
||||
@@ -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 @@ class Application extends BaseModel
|
||||
$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 @@ class Application extends BaseModel
|
||||
'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 @@ class Application extends BaseModel
|
||||
'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 @@ class Application extends BaseModel
|
||||
}
|
||||
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
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?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;
|
||||
|
||||
function remoteOutputSource(string $path): string
|
||||
{
|
||||
return file_get_contents(__DIR__.'/../../'.$path);
|
||||
}
|
||||
|
||||
it('bounds dynamic proxy configuration files and their combined Livewire payload', function () {
|
||||
$source = remoteOutputSource('app/Livewire/Server/Proxy/DynamicConfigurations.php');
|
||||
|
||||
expect($source)
|
||||
->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);
|
||||
});
|
||||
Reference in new issue
Block a user