Files
coolify/tests/Feature/Jobs/RemoteProcessCommandStorageTest.php
T
Andras BacsaiandClaude Opus 5.5 f540c49f68 fix(security): encrypt stored remote commands and remove them after the final attempt
remote_process() stored the full shell command as plain text in
activity_log and kept it for 60 days. Database starts store the compose file
with the database passwords, imports store S3 keys, and the Cloudflare
tunnel setup stores its token.

- Store the command encrypted. The queue worker decrypts it right before the
  SSH call and still reads plain commands of tasks queued before the upgrade.
- Remove the command when the task is finished: after a successful run, in
  failed() when no attempt is left, and for a stopped import. A failed attempt
  that the queue retries keeps it.
- Do not write a command preview into the error log.
- Migration: remove stored commands from activities that no longer run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:41:56 +02:00

88 lines
3.5 KiB
PHP

<?php
use App\Actions\CoolifyTask\RunRemoteProcess;
use App\Enums\ProcessStatus;
use App\Jobs\CoolifyTask;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Support\RemoteProcessCommand;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('cache.default', 'array');
config()->set('constants.ssh.mux_enabled', false);
InstanceSettings::forceCreate(['id' => 0]);
$team = Team::factory()->create();
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$this->server = Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
'user' => 'root',
]);
$this->secretCommand = "echo 'POSTGRES_PASSWORD=super-secret-value' > /tmp/compose.env";
});
it('stores the command of a remote process encrypted', function () {
Queue::fake();
$activity = remote_process([$this->secretCommand], $this->server);
$stored = Activity::query()->findOrFail($activity->id);
expect(json_encode($stored->properties))->not->toContain('super-secret-value')
->and(RemoteProcessCommand::read($stored))->toBe($this->secretCommand);
Queue::assertPushed(CoolifyTask::class);
});
it('still reads a plain command that was queued before the upgrade', function () {
$activity = activity()
->withProperties(['server_uuid' => $this->server->uuid, 'command' => 'echo legacy', 'type' => 'inline', 'status' => ProcessStatus::QUEUED->value])
->event('inline')
->log('[]');
expect(RemoteProcessCommand::read($activity))->toBe('echo legacy');
});
it('removes the command after a successful final run', function () {
Queue::fake();
Process::fake(fn () => Process::result(output: 'ok'));
$activity = remote_process([$this->secretCommand], $this->server);
(new CoolifyTask(Activity::query()->findOrFail($activity->id), ignore_errors: false, call_event_on_finish: null, call_event_data: null))->handle();
$stored = Activity::query()->findOrFail($activity->id);
expect($stored->getExtraProperty('command'))->toBeNull()
->and($stored->getExtraProperty('command_encrypted'))->toBeNull()
->and($stored->getExtraProperty('status'))->toBe(ProcessStatus::FINISHED->value);
});
it('keeps the command after one attempt so the queue can retry the task', function () {
Queue::fake();
Process::fake(fn () => Process::result(errorOutput: 'Connection reset', exitCode: 255));
$activity = remote_process([$this->secretCommand], $this->server);
$run = new RunRemoteProcess(activity: Activity::query()->findOrFail($activity->id));
expect(fn () => $run())->toThrow(RuntimeException::class);
expect(RemoteProcessCommand::read(Activity::query()->findOrFail($activity->id)))->toBe($this->secretCommand);
});
it('removes the command when the task fails permanently', function () {
Queue::fake();
$activity = remote_process([$this->secretCommand], $this->server);
(new CoolifyTask(Activity::query()->findOrFail($activity->id), ignore_errors: false, call_event_on_finish: null, call_event_data: null))
->failed(new RuntimeException('SSH connection failed'));
$stored = Activity::query()->findOrFail($activity->id);
expect($stored->getExtraProperty('command'))->toBeNull()
->and($stored->getExtraProperty('status'))->toBe(ProcessStatus::ERROR->value);
});