0, 'is_api_enabled' => true]); config([ 'app.maintenance.store' => 'array', '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->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(); $project = Project::factory()->create(['team_id' => $this->team->id]); $this->environment = Environment::factory()->create(['project_id' => $project->id]); }); afterEach(function () { Server::flushIdentityMap(); }); function contentHostPathService(string $compose): Service { return Service::factory()->create([ 'name' => 'content-host-path', 'environment_id' => test()->environment->id, 'server_id' => test()->server->id, 'destination_id' => test()->destination->id, 'destination_type' => test()->destination->getMorphClass(), 'docker_compose_raw' => $compose, 'connect_to_docker_network' => false, ]); } function contentHostPathApplication(string $compose): Application { return Application::factory()->create([ 'environment_id' => test()->environment->id, 'destination_id' => test()->destination->id, 'destination_type' => test()->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' => $compose, ]); } function contentHostPathUseServerUser(string $user): void { test()->server->update(['user' => $user]); Server::flushIdentityMap(); } /** * Fakes the server: `test -f`/`test -d` print NOK (the file is missing), the symlink check prints OK. */ function contentHostPathFakeServer(): void { Process::fake(fn ($process) => Process::result(output: match (true) { str_contains($process->command, 'readlink -f') => 'OK', str_contains($process->command, 'test -') => 'NOK', default => '', })); } /** * The stored fs_path and the host path that Coolify writes, for a source in CONTENT_HOST_PATH_COMPOSE. * `~` and `./` are relative to the resource directory, as in v4.3.23. A bare relative path is * stored as written and written inside the resource directory (never relative to the SSH user's * working directory). * * @return array{0: string, 1: string} */ function contentHostPathExpected(string $target, string $workdir): array { $parent = dirname($workdir); return match ($target) { '/etc/a.conf' => ['/etc/myapp/app.conf', '/etc/myapp/app.conf'], '/etc/b.conf' => [$workdir.'/x/app.conf', $workdir.'/x/app.conf'], '/etc/c.conf' => ['app.conf', $workdir.'/app.conf'], '/etc/d.conf' => [$parent.'/shared/app.conf', $parent.'/shared/app.conf'], }; } function contentHostPathAssertWritten(string $path, string $content, string $user): void { $sudo = $user === 'root' ? '' : 'sudo '; $escapedPath = escapeshellarg($path); $escapedParent = escapeshellarg(dirname($path)); $base64 = base64_encode($content); Process::assertRan(fn ($process) => str_contains($process->command, "{$sudo}mkdir -p {$escapedParent}")); Process::assertRan(fn ($process) => str_contains($process->command, "echo '{$base64}' | {$sudo}base64 -d | {$sudo}tee {$escapedPath} > /dev/null")); } /** * @return array */ function contentHostPathTargets(): array { return [ 'absolute host file' => ['/etc/a.conf', 'a'], 'home directory' => ['/etc/b.conf', 'b'], 'bare relative path' => ['/etc/c.conf', 'c'], 'parent directory' => ['/etc/d.conf', 'd'], ]; } test('the service parser stores a content volume at its host path and Coolify writes it there', function (string $target, string $content, string $user) { contentHostPathUseServerUser($user); $service = contentHostPathService(CONTENT_HOST_PATH_COMPOSE); serviceParser($service); [$fsPath, $writePath] = contentHostPathExpected($target, $service->workdir()); $fileVolume = LocalFileVolume::query()->where('mount_path', $target)->sole(); expect($fileVolume->fs_path)->toBe($fsPath) ->and($fileVolume->content)->toBe($content) ->and($fileVolume->is_directory)->toBeFalse() ->and($fileVolume->contentPathOnServer())->toBe($writePath); contentHostPathFakeServer(); $fileVolume->saveStorageOnServer(); contentHostPathAssertWritten($writePath, $content, $user); })->with(contentHostPathTargets())->with(['root', 'coolify']); test('the application parser stores a content volume at its host path and Coolify writes it there', function (string $target, string $content, string $user) { contentHostPathUseServerUser($user); $application = contentHostPathApplication(CONTENT_HOST_PATH_COMPOSE); applicationParser($application); [$fsPath, $writePath] = contentHostPathExpected($target, $application->workdir()); $fileVolume = LocalFileVolume::query()->where('mount_path', $target)->sole(); expect($fileVolume->fs_path)->toBe($fsPath) ->and($fileVolume->content)->toBe($content) ->and($fileVolume->contentPathOnServer())->toBe($writePath); contentHostPathFakeServer(); $fileVolume->saveStorageOnServer(); contentHostPathAssertWritten($writePath, $content, $user); })->with(contentHostPathTargets())->with(['root', 'coolify']); test('loading files for a service writes content inside the resource directory', function (string $target, string $content, string $user) { contentHostPathUseServerUser($user); $service = contentHostPathService(CONTENT_HOST_PATH_COMPOSE); serviceParser($service); $fileVolume = LocalFileVolume::query()->where('mount_path', $target)->sole(); [, $writePath] = contentHostPathExpected($target, $service->workdir()); contentHostPathFakeServer(); getFilesystemVolumesFromServer($fileVolume->resource, true); $sudo = $user === 'root' ? '' : 'sudo '; $escapedPath = escapeshellarg($writePath); $base64 = base64_encode($content); Process::assertRan(fn ($process) => str_contains($process->command, "{$sudo}mkdir -p -- \"$({$sudo}dirname -- {$escapedPath})\"")); Process::assertRan(fn ($process) => str_contains($process->command, "echo '{$base64}' | {$sudo}base64 -d | {$sudo}tee -- {$escapedPath}")); })->with([ 'home directory' => ['/etc/b.conf', 'b'], 'bare relative path' => ['/etc/c.conf', 'c'], ])->with(['root', 'coolify']); // A token with only the write permission can save a Compose file. Content outside the resource // directory is written when the resource is deployed or started, not when its files are loaded. test('loading files for a service does not write content outside the resource directory', function (string $target, string $content) { $service = contentHostPathService(CONTENT_HOST_PATH_COMPOSE); serviceParser($service); $fileVolume = LocalFileVolume::query()->where('mount_path', $target)->sole(); contentHostPathFakeServer(); getFilesystemVolumesFromServer($fileVolume->resource, true); Process::assertNotRan(fn ($process) => str_contains($process->command, base64_encode($content))); })->with([ 'absolute host file' => ['/etc/a.conf', 'a'], 'parent directory' => ['/etc/d.conf', 'd'], ]); test('a quote in a content path is shell-escaped in every write command', function (string $user) { contentHostPathUseServerUser($user); $path = "/etc/my'app/app.conf"; $compose = "services:\n app:\n image: nginx:alpine\n volumes:\n - type: bind\n source: \"{$path}\"\n target: /etc/app.conf\n content: x\n"; $service = contentHostPathService($compose); serviceParser($service); $fileVolume = LocalFileVolume::query()->sole(); expect($fileVolume->fs_path)->toBe($path); contentHostPathFakeServer(); $fileVolume->saveStorageOnServer(); $sudo = $user === 'root' ? '' : 'sudo '; Process::assertRan(fn ($process) => str_contains($process->command, "{$sudo}tee '/etc/my'\\''app/app.conf' > /dev/null")); Process::assertRan(fn ($process) => str_contains($process->command, "{$sudo}mkdir -p '/etc/my'\\''app'")); })->with(['root', 'coolify']); test('the parsers reject shell injection in a content volume source', function () { expect(fn () => serviceParser(contentHostPathService(CONTENT_HOST_PATH_INJECTION_COMPOSE))) ->toThrow(Exception::class, 'Invalid Docker volume definition (array syntax)') ->and(fn () => applicationParser(contentHostPathApplication(CONTENT_HOST_PATH_INJECTION_COMPOSE))) ->toThrow(Exception::class, 'Invalid Docker volume definition (array syntax)'); expect(LocalFileVolume::query()->count())->toBe(0); }); test('a bind mount without content keeps its administrator-selected host path', function () { $compose = "services:\n app:\n image: nginx:alpine\n volumes:\n - /srv/shared:/keys\n"; $service = contentHostPathService($compose); $serviceApplication = ServiceApplication::create(['name' => 'app', 'service_id' => $service->id]); $fileVolume = LocalFileVolume::create([ 'fs_path' => '/srv/shared', 'mount_path' => '/keys', 'is_directory' => true, 'resource_id' => $serviceApplication->id, 'resource_type' => $serviceApplication->getMorphClass(), ])->fresh(); contentHostPathFakeServer(); $fileVolume->saveStorageOnServer(); Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '/srv/shared'")); Process::assertDidntRun(fn ($process) => str_contains($process->command, 'readlink -f')); }); test('the service stack form saves content volumes with host paths', function () { $this->actingAs($this->user); $service = contentHostPathService(CONTENT_HOST_PATH_SAFE_COMPOSE); contentHostPathFakeServer(); Livewire::test(StackForm::class, ['service' => $service]) ->set('dockerComposeRaw', CONTENT_HOST_PATH_COMPOSE) ->call('submit') ->assertNotDispatched('error'); expect($service->fresh()->docker_compose_raw)->toContain('source: /etc/myapp/app.conf') ->and(LocalFileVolume::query()->where('fs_path', '/etc/myapp/app.conf')->value('content'))->toBe('a'); }); test('the service stack form rejects shell injection in a content volume source', function () { $this->actingAs($this->user); $service = contentHostPathService(CONTENT_HOST_PATH_SAFE_COMPOSE); Livewire::test(StackForm::class, ['service' => $service]) ->set('dockerComposeRaw', CONTENT_HOST_PATH_INJECTION_COMPOSE) ->call('submit') ->assertDispatched('error', fn (string $event, array $params): bool => str_contains($params[0], 'Invalid Docker volume definition')); expect($service->fresh()->docker_compose_raw)->toBe(CONTENT_HOST_PATH_SAFE_COMPOSE) ->and(LocalFileVolume::query()->count())->toBe(0); }); test('the application General form saves content volumes with host paths', function () { $this->actingAs($this->user); $application = contentHostPathApplication(CONTENT_HOST_PATH_SAFE_COMPOSE); contentHostPathFakeGitCompose(CONTENT_HOST_PATH_COMPOSE); Livewire::test(General::class, ['application' => $application->fresh()]) ->set('dockerComposeRaw', CONTENT_HOST_PATH_COMPOSE) ->call('submit') ->assertNotDispatched('error'); expect($application->fresh()->docker_compose_raw)->toContain('source: /etc/myapp/app.conf') ->and(LocalFileVolume::query()->where('fs_path', '/etc/myapp/app.conf')->value('content'))->toBe('a'); }); test('the application General form rejects shell injection in a content volume source', function () { $this->actingAs($this->user); $application = contentHostPathApplication(CONTENT_HOST_PATH_SAFE_COMPOSE); Process::fake(); Livewire::test(General::class, ['application' => $application->fresh()]) ->set('dockerComposeRaw', CONTENT_HOST_PATH_INJECTION_COMPOSE) ->call('submit') ->assertDispatched('error', fn (string $event, array $params): bool => str_contains($params[0], 'Invalid Docker volume definition')) ->assertNotDispatched('success'); expect($application->fresh()->docker_compose_raw)->toBe(CONTENT_HOST_PATH_SAFE_COMPOSE) ->and(LocalFileVolume::query()->count())->toBe(0); }); function contentHostPathFakeGitCompose(string $compose): void { Process::fake(function ($process) use ($compose) { $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; return Process::result(output: match (true) { str_contains($command, 'git --version') => 'git version 2.43.0', str_contains($command, 'head -c') => $compose, default => '', }); }); } test('loading a Git Compose file accepts content volumes with host paths', function () { $application = contentHostPathApplication(CONTENT_HOST_PATH_SAFE_COMPOSE); contentHostPathFakeGitCompose(CONTENT_HOST_PATH_COMPOSE); $application->loadComposeFile(); expect($application->fresh()->docker_compose_raw)->toContain('source: /etc/myapp/app.conf') ->and(LocalFileVolume::query()->orderBy('mount_path')->pluck('fs_path')->all())->toBe([ '/etc/myapp/app.conf', $application->workdir().'/x/app.conf', 'app.conf', dirname($application->workdir()).'/shared/app.conf', ]); }); test('loading a Git Compose file rejects shell injection in a content volume source', function () { $application = contentHostPathApplication(CONTENT_HOST_PATH_SAFE_COMPOSE); contentHostPathFakeGitCompose(CONTENT_HOST_PATH_INJECTION_COMPOSE); expect(fn () => $application->loadComposeFile()) ->toThrow(Exception::class, 'Invalid Docker volume definition'); expect($application->fresh()->docker_compose_raw)->toBe(CONTENT_HOST_PATH_SAFE_COMPOSE) ->and(LocalFileVolume::query()->count())->toBe(0); }); function contentHostPathPatchService(Service $service, string $compose): TestResponse { $plainTextToken = Str::random(40); $token = test()->user->tokens()->create([ 'name' => 'content-host-path', 'token' => hash('sha256', $plainTextToken), 'abilities' => ['*'], 'team_id' => test()->team->id, ]); return test()->withHeaders(['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]) ->patchJson("/api/v1/services/{$service->uuid}", ['docker_compose_raw' => base64_encode($compose)]); } test('the service API saves content volumes with host paths', function () { $service = contentHostPathService(CONTENT_HOST_PATH_SAFE_COMPOSE); contentHostPathFakeServer(); contentHostPathPatchService($service, CONTENT_HOST_PATH_COMPOSE)->assertSuccessful(); expect($service->fresh()->docker_compose_raw)->toContain('source: /etc/myapp/app.conf') ->and(LocalFileVolume::query()->where('fs_path', '/etc/myapp/app.conf')->value('content'))->toBe('a'); }); test('the service API rejects shell injection in a content volume source', function () { $service = contentHostPathService(CONTENT_HOST_PATH_SAFE_COMPOSE); contentHostPathPatchService($service, CONTENT_HOST_PATH_INJECTION_COMPOSE) ->assertStatus(422) ->assertJsonPath('errors.docker_compose_raw', fn (string $error): bool => str_contains($error, 'Invalid Docker volume definition')); expect($service->fresh()->docker_compose_raw)->toBe(CONTENT_HOST_PATH_SAFE_COMPOSE) ->and(LocalFileVolume::query()->count())->toBe(0); }); test('loading files for a service does not create directories outside the resource directory', function () { $compose = "services:\n app:\n image: nginx:alpine\n volumes:\n - type: bind\n source: /etc/cron.d/coolify-test\n target: /data\n"; $service = contentHostPathService($compose); serviceParser($service); $fileVolume = LocalFileVolume::query()->sole(); contentHostPathFakeServer(); getFilesystemVolumesFromServer($fileVolume->resource, true); Process::assertNotRan(fn ($process) => str_contains($process->command, '/etc/cron.d/coolify-test') && str_contains($process->command, 'mkdir')); });