mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-08 06:56:02 -04:00
Git-based Docker Compose applications skipped the Compose injection
validation that services use. It now runs when the file is loaded or
reloaded, when the raw Compose is saved (UI and API create), and at
deployment before any command uses the file; an unsafe file is not
saved, and a deployment stops with a clear log line.
- All 371 service templates and realistic Compose files still pass.
- Network names may now mix text with $VAR, ${VAR}, ${VAR:-default},
or ${VAR-default} (for example ${COMPOSE_PROJECT_NAME}_default), so
such existing applications keep deploying; command substitution and
unsafe defaults stay rejected.
- Quote the preserved-repository path in a stat command.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
314 lines
14 KiB
PHP
314 lines
14 KiB
PHP
<?php
|
|
|
|
use App\Actions\Application\LoadComposeFile;
|
|
use App\Exceptions\DeploymentException;
|
|
use App\Jobs\ApplicationDeploymentJob;
|
|
use App\Livewire\Project\Application\General;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationDeploymentQueue;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
const SAFE_APPLICATION_COMPOSE = "services:\n web:\n image: nginx:alpine\n";
|
|
|
|
beforeEach(function () {
|
|
InstanceSettings::forceCreate(['id' => 0]);
|
|
config(['constants.ssh.mux_enabled' => false]);
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->user = User::factory()->create();
|
|
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
|
session(['currentTeam' => $this->team]);
|
|
$this->token = $this->user->createToken('compose-injection-test', ['*'])->plainTextToken;
|
|
|
|
$this->server = Server::factory()->create([
|
|
'team_id' => $this->team->id,
|
|
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
|
|
]);
|
|
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
|
$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(),
|
|
'build_pack' => 'dockercompose',
|
|
'git_repository' => 'https://github.com/coollabsio/compose-app',
|
|
'git_branch' => 'main',
|
|
'base_directory' => '/',
|
|
'docker_compose_location' => '/docker-compose.yml',
|
|
'docker_compose_raw' => SAFE_APPLICATION_COMPOSE,
|
|
'redirect' => 'both',
|
|
'static_image' => 'nginx:alpine',
|
|
])->fresh();
|
|
});
|
|
|
|
/**
|
|
* @return array<string, array{string}>
|
|
*/
|
|
function composeInjectionPayloads(): array
|
|
{
|
|
return [
|
|
'service name command substitution' => ["services:\n web\$(touch /tmp/pwned):\n image: nginx\n"],
|
|
'service name command separator' => ["services:\n 'web;touch /tmp/pwned':\n image: nginx\n"],
|
|
'network name command substitution' => ["services:\n web:\n image: nginx\n networks:\n - edge\nnetworks:\n edge:\n name: 'edge\$(touch /tmp/pwned)'\n"],
|
|
'volume source command separator' => ["services:\n web:\n image: nginx\n volumes:\n - './data;touch /tmp/pwned:/data'\n"],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* The server returns $compose when Coolify reads the Compose file from the repository.
|
|
*/
|
|
function fakeRepositoryCompose(string $compose): void
|
|
{
|
|
Process::fake(function ($process) use ($compose) {
|
|
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
|
|
if (str_contains($command, 'git --version')) {
|
|
return Process::result(output: 'git version 2.43.0');
|
|
}
|
|
if (str_contains($command, 'head -c')) {
|
|
return Process::result(output: $compose);
|
|
}
|
|
|
|
return Process::result(output: '');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* A deployment job that only has the state needed to load the Compose file.
|
|
*
|
|
* @param array<int, array{0: string, 1: string}> $logEntries
|
|
*/
|
|
function composeDeploymentJob(Application $application, int $pullRequestId, array &$logEntries): ApplicationDeploymentJob
|
|
{
|
|
$queue = Mockery::mock(ApplicationDeploymentQueue::class);
|
|
$queue->shouldReceive('addLogEntry')->andReturnUsing(function (string $message, string $type = 'stdout', bool $hidden = false) use (&$logEntries) {
|
|
$logEntries[] = [$message, $type, $hidden];
|
|
});
|
|
|
|
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
|
|
foreach ([
|
|
'application' => $application,
|
|
'application_deployment_queue' => $queue,
|
|
'pull_request_id' => $pullRequestId,
|
|
'deployment_uuid' => 'deployment-uuid',
|
|
] as $property => $value) {
|
|
(new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value);
|
|
}
|
|
|
|
return $job;
|
|
}
|
|
|
|
test('loading the Compose file from Git rejects injection and does not save it', function (string $compose) {
|
|
fakeRepositoryCompose($compose);
|
|
|
|
expect(fn () => $this->application->loadComposeFile())
|
|
->toThrow(DeploymentException::class, 'The Docker Compose file at /docker-compose.yml (branch: main) is not safe to use');
|
|
|
|
expect($this->application->refresh()->docker_compose_raw)->toBe(SAFE_APPLICATION_COMPOSE);
|
|
})->with(composeInjectionPayloads());
|
|
|
|
test('the rejection message is HTML-safe for error toasts', function () {
|
|
fakeRepositoryCompose("services:\n web:\n image: nginx\n volumes:\n - './data>/etc/passwd:/data'\n");
|
|
|
|
expect(fn () => $this->application->loadComposeFile())
|
|
->toThrow(function (DeploymentException $exception) {
|
|
expect($exception->getMessage())->toContain('>')->not->toContain("'>'");
|
|
});
|
|
});
|
|
|
|
test('loading the Compose file from Git restores the previous location when validation fails', function () {
|
|
fakeRepositoryCompose(composeInjectionPayloads()['service name command separator'][0]);
|
|
$this->application->docker_compose_location = '/compose/evil.yml';
|
|
|
|
expect(fn () => $this->application->loadComposeFile(restoreDockerComposeLocation: '/docker-compose.yml', restoreBaseDirectory: '/'))
|
|
->toThrow(DeploymentException::class);
|
|
|
|
expect($this->application->refresh())
|
|
->docker_compose_location->toBe('/docker-compose.yml')
|
|
->docker_compose_raw->toBe(SAFE_APPLICATION_COMPOSE);
|
|
});
|
|
|
|
test('loading a safe Compose file from Git saves it', function () {
|
|
$compose = "services:\n api:\n image: acme/api:\${TAG:-latest}\n command: sh -c 'migrate && serve > /dev/null'\n";
|
|
fakeRepositoryCompose($compose);
|
|
|
|
$this->application->loadComposeFile();
|
|
|
|
expect($this->application->refresh()->docker_compose_raw)->toBe(trim($compose));
|
|
});
|
|
|
|
test('the queued LoadComposeFile action rejects injection', function () {
|
|
fakeRepositoryCompose(composeInjectionPayloads()['service name command substitution'][0]);
|
|
|
|
expect(fn () => LoadComposeFile::run($this->application))->toThrow(DeploymentException::class);
|
|
expect($this->application->refresh()->docker_compose_raw)->toBe(SAFE_APPLICATION_COMPOSE);
|
|
});
|
|
|
|
test('the Reload Compose File button shows the validation error and does not save', function (string $compose) {
|
|
$this->actingAs($this->user);
|
|
fakeRepositoryCompose($compose);
|
|
|
|
Livewire::test(General::class, ['application' => $this->application])
|
|
->call('loadComposeFile')
|
|
->assertDispatched('error', fn (string $event, array $params): bool => str_contains($params[0], 'is not safe to use'))
|
|
->assertNotDispatched('success')
|
|
->assertSet('dockerComposeRaw', SAFE_APPLICATION_COMPOSE);
|
|
|
|
expect($this->application->refresh()->docker_compose_raw)->toBe(SAFE_APPLICATION_COMPOSE);
|
|
})->with(composeInjectionPayloads());
|
|
|
|
test('saving the General form rejects an injected raw Compose file', function (string $compose) {
|
|
$this->actingAs($this->user);
|
|
fakeRepositoryCompose(SAFE_APPLICATION_COMPOSE);
|
|
|
|
Livewire::test(General::class, ['application' => $this->application])
|
|
->set('dockerComposeRaw', $compose)
|
|
->call('submit')
|
|
->assertDispatched('error', fn (string $event, array $params): bool => str_starts_with($params[0], 'Invalid Docker'))
|
|
->assertNotDispatched('success');
|
|
|
|
expect($this->application->refresh()->docker_compose_raw)->toBe(SAFE_APPLICATION_COMPOSE);
|
|
})->with(composeInjectionPayloads());
|
|
|
|
test('an instant save of the General form rejects an injected raw Compose file', function () {
|
|
$this->actingAs($this->user);
|
|
fakeRepositoryCompose(SAFE_APPLICATION_COMPOSE);
|
|
|
|
Livewire::test(General::class, ['application' => $this->application])
|
|
->set('dockerComposeRaw', composeInjectionPayloads()['service name command substitution'][0])
|
|
->set('isHttpBasicAuthEnabled', true)
|
|
->set('httpBasicAuthUsername', 'admin')
|
|
->set('httpBasicAuthPassword', 'password123')
|
|
->call('instantSave')
|
|
->assertDispatched('error', fn (string $event, array $params): bool => str_starts_with($params[0], 'Invalid Docker Compose service name'))
|
|
->assertNotDispatched('success');
|
|
|
|
expect($this->application->refresh())
|
|
->docker_compose_raw->toBe(SAFE_APPLICATION_COMPOSE)
|
|
->is_http_basic_auth_enabled->toBeFalsy();
|
|
});
|
|
|
|
test('saving the General form accepts a safe raw Compose file', function () {
|
|
$this->actingAs($this->user);
|
|
$compose = "services:\n web:\n image: nginx:alpine\n healthcheck:\n test: ['CMD-SHELL', 'curl -f http://localhost || exit 1']\n";
|
|
// Saving the form reloads the Compose file from the repository when it resets the labels.
|
|
fakeRepositoryCompose($compose);
|
|
|
|
Livewire::test(General::class, ['application' => $this->application])
|
|
->set('dockerComposeRaw', $compose)
|
|
->call('submit')
|
|
// submit() also runs real DNS checks for the domains, so only Compose errors are relevant here.
|
|
->assertNotDispatched('error', fn (string $event, array $params): bool => str_contains((string) ($params[0] ?? ''), 'Docker Compose'));
|
|
|
|
expect($this->application->refresh()->docker_compose_raw)->toBe(trim($compose));
|
|
});
|
|
|
|
test('API create rejects an injected docker_compose_raw and creates no application', function (string $compose) {
|
|
Queue::fake();
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->token])
|
|
->postJson('/api/v1/applications/public', [
|
|
'project_uuid' => $this->project->uuid,
|
|
'environment_uuid' => $this->environment->uuid,
|
|
'server_uuid' => $this->server->uuid,
|
|
'git_repository' => 'https://gitlab.com/coolify/compose-app',
|
|
'git_branch' => 'main',
|
|
'build_pack' => 'dockercompose',
|
|
'ports_exposes' => '80',
|
|
'autogenerate_domain' => false,
|
|
'docker_compose_raw' => $compose,
|
|
])
|
|
->assertUnprocessable()
|
|
->assertJsonPath('message', 'Validation failed.')
|
|
->assertJsonStructure(['errors' => ['docker_compose_raw']]);
|
|
|
|
expect(Application::query()->where('git_repository', 'https://gitlab.com/coolify/compose-app')->exists())->toBeFalse();
|
|
})->with(composeInjectionPayloads());
|
|
|
|
test('API create accepts a safe docker_compose_raw', function () {
|
|
Queue::fake();
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->token])
|
|
->postJson('/api/v1/applications/public', [
|
|
'project_uuid' => $this->project->uuid,
|
|
'environment_uuid' => $this->environment->uuid,
|
|
'server_uuid' => $this->server->uuid,
|
|
'git_repository' => 'https://gitlab.com/coolify/compose-app',
|
|
'git_branch' => 'main',
|
|
'build_pack' => 'dockercompose',
|
|
'ports_exposes' => '80',
|
|
'autogenerate_domain' => false,
|
|
'docker_compose_raw' => SAFE_APPLICATION_COMPOSE,
|
|
])
|
|
->assertCreated();
|
|
});
|
|
|
|
test('API update does not accept docker_compose_raw', function () {
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->token])
|
|
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
|
'docker_compose_raw' => composeInjectionPayloads()['service name command substitution'][0],
|
|
])
|
|
->assertUnprocessable()
|
|
->assertJsonStructure(['errors' => ['docker_compose_raw']]);
|
|
|
|
expect($this->application->refresh()->docker_compose_raw)->toBe(SAFE_APPLICATION_COMPOSE);
|
|
});
|
|
|
|
test('a deployment stops with a visible log line when the repository Compose file is not safe', function (int $pullRequestId) {
|
|
fakeRepositoryCompose(composeInjectionPayloads()['service name command substitution'][0]);
|
|
$logEntries = [];
|
|
$job = composeDeploymentJob($this->application, $pullRequestId, $logEntries);
|
|
|
|
expect(fn () => (new ReflectionMethod(ApplicationDeploymentJob::class, 'loadComposeFileForDeployment'))->invoke($job))
|
|
->toThrow(DeploymentException::class, 'Deployment stopped: the Docker Compose file at /docker-compose.yml (branch: main) is not safe to use');
|
|
|
|
$visible = collect($logEntries)->filter(fn (array $entry): bool => $entry[2] === false)->pluck(0)->implode("\n");
|
|
expect($visible)
|
|
->toContain('Deployment stopped: the Docker Compose file at /docker-compose.yml (branch: main) is not safe to use')
|
|
->toContain('Invalid Docker Compose service name')
|
|
->not->toContain(''')
|
|
->not->toContain('#0 ')
|
|
->not->toContain('.php');
|
|
expect($this->application->refresh()->docker_compose_raw)->toBe(SAFE_APPLICATION_COMPOSE);
|
|
Process::assertNotRan(fn ($process) => str_contains(is_array($process->command) ? implode(' ', $process->command) : $process->command, 'base64 -d'));
|
|
})->with(['production' => 0, 'pull request preview' => 42]);
|
|
|
|
test('a deployment loads a safe repository Compose file', function () {
|
|
$compose = "services:\n web:\n image: nginx:1.27\n";
|
|
fakeRepositoryCompose($compose);
|
|
$logEntries = [];
|
|
|
|
(new ReflectionMethod(ApplicationDeploymentJob::class, 'loadComposeFileForDeployment'))
|
|
->invoke(composeDeploymentJob($this->application, 0, $logEntries));
|
|
|
|
expect($this->application->refresh()->docker_compose_raw)->toBe(trim($compose));
|
|
});
|
|
|
|
test('the Compose deployment validates the repository file before any command uses it', function () {
|
|
$method = new ReflectionMethod(ApplicationDeploymentJob::class, 'deploy_docker_compose_buildpack');
|
|
$lines = array_slice(file($method->getFileName()), $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1);
|
|
$body = implode('', $lines);
|
|
|
|
$loadPosition = strpos($body, '$this->loadComposeFileForDeployment();');
|
|
expect($loadPosition)->not->toBeFalse()
|
|
->and($body)->not->toContain('$this->application->loadComposeFile(')
|
|
->and($loadPosition)->toBeLessThan(strpos($body, '$this->application->oldRawParser()'))
|
|
->and($loadPosition)->toBeLessThan(strpos($body, '$this->application->parse('))
|
|
->and($loadPosition)->toBeLessThan(strpos($body, 'base64 -d'))
|
|
->and($body)->toContain('"stat -c \'%F\' ".escapeshellarg($realPathInGit)');
|
|
});
|