user = User::factory()->create(); $this->team = Team::factory()->create(); $this->user->teams()->attach($this->team, ['role' => 'owner']); $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]); $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); $this->application = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $this->actingAs($this->user); session(['currentTeam' => $this->team]); }); describe('GetLogs locked properties', function () { test('container property has Locked attribute', function () { $property = new ReflectionProperty(GetLogs::class, 'container'); $attributes = $property->getAttributes(Locked::class); expect($attributes)->not->toBeEmpty(); }); test('server property has Locked attribute', function () { $property = new ReflectionProperty(GetLogs::class, 'server'); $attributes = $property->getAttributes(Locked::class); expect($attributes)->not->toBeEmpty(); }); test('resource property has Locked attribute', function () { $property = new ReflectionProperty(GetLogs::class, 'resource'); $attributes = $property->getAttributes(Locked::class); expect($attributes)->not->toBeEmpty(); }); test('servicesubtype property has Locked attribute', function () { $property = new ReflectionProperty(GetLogs::class, 'servicesubtype'); $attributes = $property->getAttributes(Locked::class); expect($attributes)->not->toBeEmpty(); }); }); describe('GetLogs Livewire action validation', function () { test('getLogs requests all logs when the line count is minus one', function () { $this->server->settings->fill([ 'is_reachable' => true, 'is_usable' => true, 'force_disabled' => false, ])->save(); $server = Server::with('settings')->findOrFail($this->server->id); fakeRemoteProcesses(['*' => Process::result(output: 'all logs')]); Livewire::test(GetLogs::class, [ 'server' => $server, 'resource' => $this->application, 'container' => 'test-container', ]) ->assertSee('All') ->assertSeeHtml('title="Show all logs"') ->call('showAllLogs') ->assertSet('numberOfLines', -1) ->assertReturned('all logs'); Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker logs -n all -t test-container')); }); 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); fakeRemoteProcesses(['*' => Process::result(output: $output)]); $component = new GetLogs; $component->server = $server; $component->resource = $this->application; $component->container = 'test-container'; $component->showTimeStamps = false; expect($component->getLogs()) ->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([ 'is_reachable' => true, 'is_usable' => true, 'force_disabled' => false, ])->save(); // Reload server with fresh settings to ensure casted values $server = Server::with('settings')->findOrFail($this->server->id); Livewire::test(GetLogs::class, [ 'server' => $server, 'resource' => $this->application, 'container' => 'container;malicious-command', ]) ->call('getLogs') ->assertReturned('Invalid container name.'); }); test('getLogs rejects unauthorized server access', function () { $otherTeam = Team::factory()->create(); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); Livewire::test(GetLogs::class, [ 'server' => $otherServer, 'resource' => $this->application, 'container' => 'test-container', ]) ->call('getLogs') ->assertReturned('Unauthorized.'); }); test('downloadAllLogs returns empty for invalid container name', function () { $this->server->settings->fill([ 'is_reachable' => true, 'is_usable' => true, 'force_disabled' => false, ])->save(); $server = Server::with('settings')->findOrFail($this->server->id); Livewire::test(GetLogs::class, [ 'server' => $server, 'resource' => $this->application, 'container' => 'container$(whoami)', ]) ->call('downloadAllLogs') ->assertReturned(''); }); test('downloadAllLogs returns empty for unauthorized server', function () { $otherTeam = Team::factory()->create(); $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); Livewire::test(GetLogs::class, [ 'server' => $otherServer, 'resource' => $this->application, 'container' => 'test-container', ]) ->call('downloadAllLogs') ->assertReturned(''); }); }); describe('GetLogs browser-rendered output', function () { beforeEach(function () { $this->server->settings->fill([ 'is_reachable' => true, 'is_usable' => true, 'force_disabled' => false, ])->save(); $this->functionalServer = Server::with('settings')->findOrFail($this->server->id); }); test('getLogs returns timestamped output without storing it in component state', function () { fakeRemoteProcesses(['*' => Process::result(output: "2026-09-28T10:00:02Z second\n2026-09-28T10:00:01Z first")]); $component = Livewire::test(GetLogs::class, [ 'server' => $this->functionalServer, 'resource' => $this->application, 'container' => 'test-container', ]) ->set('showTimeStamps', false) ->call('getLogs') ->assertReturned("2026-09-28T10:00:01Z first\n2026-09-28T10:00:02Z second"); expect($component->instance())->not->toHaveProperty('outputs'); Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker logs -n 100 -t test-container')); }); test('getLogs only requests new lines when streaming from a timestamp', function () { fakeRemoteProcesses(['*' => Process::result(output: '2026-09-28T10:00:03Z new')]); Livewire::test(GetLogs::class, [ 'server' => $this->functionalServer, 'resource' => $this->application, 'container' => 'test-container', ]) ->call('getLogs', '2026-09-28T10:00:02.123456789Z') ->assertReturned('2026-09-28T10:00:03Z new'); Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker logs --since 2026-09-28T10:00:02.123456789Z -t test-container')); }); test('getLogs prefixes the streaming command with sudo for non-root servers', function () { $this->functionalServer->update(['user' => 'ubuntu']); fakeRemoteProcesses(['*' => Process::result(output: '')]); Livewire::test(GetLogs::class, [ 'server' => $this->functionalServer->fresh(['settings']), 'resource' => $this->application, 'container' => 'test-container', ])->call('getLogs', '2026-09-28T10:00:02Z'); Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), '(sudo docker logs --since 2026-09-28T10:00:02Z -t test-container) 2>&1 | head -c')); }); test('getLogs ignores a since value that is not a docker timestamp', function (string $since) { fakeRemoteProcesses(['*' => Process::result(output: 'logs')]); Livewire::test(GetLogs::class, [ 'server' => $this->functionalServer, 'resource' => $this->application, 'container' => 'test-container', ])->call('getLogs', $since); Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker logs -n 100 -t test-container') && ! str_contains(remoteProcessScript($process), '--since')); })->with([ 'command substitution' => ['2026-09-28T10:00:02Z$(id)'], 'shell separator' => ['2026-09-28T10:00:02Z; id'], 'relative duration' => ['10m'], ]); test('copyLogs keeps line order and omits timestamps when timestamps are hidden', function () { fakeRemoteProcesses(['*' => Process::result(output: "zeta\nalpha")]); Livewire::test(GetLogs::class, [ 'server' => $this->functionalServer, 'resource' => $this->application, 'container' => 'test-container', ]) ->set('showTimeStamps', false) ->call('copyLogs') ->assertReturned("zeta\nalpha"); Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker logs -n 100 test-container')); }); test('copyLogs returns empty for unauthorized server', function () { $otherServer = Server::factory()->create(['team_id' => Team::factory()->create()->id]); Livewire::test(GetLogs::class, [ 'server' => $otherServer, 'resource' => $this->application, 'container' => 'test-container', ]) ->call('copyLogs') ->assertReturned(''); }); test('streaming does not render log lines or poll through Livewire', function () { fakeRemoteProcesses(['*' => Process::result(output: '2026-09-28T10:00:01Z secret-looking line')]); Livewire::test(GetLogs::class, [ 'server' => $this->functionalServer, 'resource' => $this->application, 'container' => 'coolify-sentinel', 'collapsible' => false, ]) ->call('toggleStreamLogs') ->assertSet('streamLogs', true) ->call('getLogs') ->assertDontSeeHtml('wire:poll') ->assertDontSee('secret-looking line'); }); }); describe('GetLogs container name injection payloads are blocked by validation', function () { test('newline injection payload is rejected', function () { // The exact PoC payload from the advisory $payload = "postgresql 2>/dev/null\necho '===RCE-START==='\nid\nwhoami\nhostname\ncat /etc/hostname\necho '===RCE-END==='\n#"; expect(ValidationPatterns::isValidContainerName($payload))->toBeFalse(); }); test('semicolon injection payload is rejected', function () { expect(ValidationPatterns::isValidContainerName('postgresql;id'))->toBeFalse(); }); test('backtick injection payload is rejected', function () { expect(ValidationPatterns::isValidContainerName('postgresql`id`'))->toBeFalse(); }); test('command substitution injection payload is rejected', function () { expect(ValidationPatterns::isValidContainerName('postgresql$(whoami)'))->toBeFalse(); }); test('pipe injection payload is rejected', function () { expect(ValidationPatterns::isValidContainerName('postgresql|cat /etc/passwd'))->toBeFalse(); }); test('valid container names are accepted', function () { expect(ValidationPatterns::isValidContainerName('postgresql'))->toBeTrue(); expect(ValidationPatterns::isValidContainerName('my-app-container'))->toBeTrue(); expect(ValidationPatterns::isValidContainerName('service_db.v2'))->toBeTrue(); expect(ValidationPatterns::isValidContainerName('coolify-proxy'))->toBeTrue(); }); });