From aabd1273749936b98220fadcdb5145389f751614 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:37:21 +0200 Subject: [PATCH] fix(compose): keep content files inside the resource directory - A Compose bind volume with a Coolify `content:` block could make Coolify write any host file (for example /root/.ssh/authorized_keys) when the file was loaded. Content sources must now be inside the resource directory (./ paths); other sources get a clear validation error for services and applications (UI, API, load, deployment), and every content write is confined again on the server. Bind mounts without content are unchanged. All 94 template content volumes use ./ paths and still work. - Quote container names in scheduled tasks and in the stop actions for applications, previews, and services. - Fix a development-only crash when a preview with a bind mount was parsed (the preview suffix returned a plain string). Co-Authored-By: Claude Opus 5.5 --- app/Actions/Application/StopApplication.php | 7 +- .../Application/StopApplicationOneServer.php | 2 +- .../Application/StopApplicationPreview.php | 5 +- app/Actions/Service/StopService.php | 2 +- .../Controllers/Api/ServicesController.php | 2 +- app/Jobs/ScheduledTaskJob.php | 2 +- app/Livewire/Project/Application/General.php | 2 +- app/Livewire/Project/Service/StackForm.php | 2 +- app/Models/Application.php | 2 +- app/Models/LocalFileVolume.php | 101 ++++- bootstrap/helpers/parsers.php | 73 +++- bootstrap/helpers/services.php | 8 +- bootstrap/helpers/shared.php | 1 + .../ComposeContentVolumeConfinementTest.php | 361 ++++++++++++++++++ .../Feature/ContainerNameShellQuotingTest.php | 240 ++++++++++++ ...mposeContentVolumeSourceValidationTest.php | 192 ++++++++++ tests/Unit/LocalFileVolumeContentSizeTest.php | 15 +- 17 files changed, 984 insertions(+), 33 deletions(-) create mode 100644 tests/Feature/ComposeContentVolumeConfinementTest.php create mode 100644 tests/Feature/ContainerNameShellQuotingTest.php create mode 100644 tests/Unit/ComposeContentVolumeSourceValidationTest.php diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index 12ac569009..14291d5f91 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -28,7 +28,7 @@ class StopApplication if ($server->isSwarm()) { $containerPresent = false; - instant_remote_process(["docker stack rm {$application->uuid}"], $server); + instant_remote_process(['docker stack rm '.escapeshellarg($application->uuid)], $server); continue; } @@ -41,9 +41,10 @@ class StopApplication $timeout = $application->settings->stopGracePeriodSeconds(); foreach ($containersToStop as $containerName) { - $commands = [dockerStopCommand($timeout, $containerName, $server)]; + $escapedContainerName = escapeshellarg($containerName); + $commands = [dockerStopCommand($timeout, $escapedContainerName, $server)]; if ($removeContainers) { - $commands[] = "docker rm -f $containerName"; + $commands[] = "docker rm -f {$escapedContainerName}"; } instant_remote_process(command: $commands, server: $server, throwError: false); diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php index b25eb481b6..5b40702cd8 100644 --- a/app/Actions/Application/StopApplicationOneServer.php +++ b/app/Actions/Application/StopApplicationOneServer.php @@ -28,7 +28,7 @@ class StopApplicationOneServer if ($containerName) { instant_remote_process( [ - dockerStopCommand($timeout, $containerName, $server), + dockerStopCommand($timeout, escapeshellarg($containerName), $server), dockerRemoveCommand($containerName), ], $server diff --git a/app/Actions/Application/StopApplicationPreview.php b/app/Actions/Application/StopApplicationPreview.php index 8bb3a3dc08..954bde879a 100644 --- a/app/Actions/Application/StopApplicationPreview.php +++ b/app/Actions/Application/StopApplicationPreview.php @@ -17,9 +17,10 @@ class StopApplicationPreview $containers = getCurrentApplicationContainerStatus($server, $application->id, $preview->pull_request_id); foreach ($containers->pluck('Names') as $containerName) { - $commands = [dockerStopCommand($application->settings->stopGracePeriodSeconds(), $containerName, $server)]; + $escapedContainerName = escapeshellarg($containerName); + $commands = [dockerStopCommand($application->settings->stopGracePeriodSeconds(), $escapedContainerName, $server)]; if ($removeContainer) { - $commands[] = "docker rm -f $containerName"; + $commands[] = "docker rm -f {$escapedContainerName}"; } instant_remote_process($commands, $server, false); } diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index 52d9edda19..ce101c5b80 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -74,7 +74,7 @@ class StopService { $timeout = count($containersToStop) > 5 ? 10 : 30; $commands = []; - $containerList = implode(' ', $containersToStop); + $containerList = implode(' ', array_map('escapeshellarg', $containersToStop)); $commands[] = dockerStopCommand($timeout, $containerList, $server); $commands[] = "docker rm -f $containerList"; instant_remote_process( diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 393f997b9b..fee94e874d 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1246,7 +1246,7 @@ class ServicesController extends Controller // Validate for command injection BEFORE saving to database try { - validateDockerComposeForInjection($dockerComposeRaw); + validateDockerComposeForInjection($dockerComposeRaw, composeResourceDirectory($service)); } catch (\Exception $e) { return response()->json([ 'message' => 'Validation failed.', diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index 6435c1a64b..6407f0b500 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -157,7 +157,7 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; $dockerCommand = $this->server->isNonRoot() ? 'sudo docker' : 'docker'; - $execCommand = "{$dockerCommand} exec {$containerName} {$cmd}"; + $execCommand = "{$dockerCommand} exec ".escapeshellarg($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 diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index bcc2996449..75742c8cd9 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -347,7 +347,7 @@ class General extends Component } try { - validateDockerComposeForInjection($this->dockerComposeRaw); + validateDockerComposeForInjection($this->dockerComposeRaw, composeResourceDirectory($this->application)); } catch (Exception $e) { throw new Exception(e($e->getMessage()), 0, $e); } diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php index 0572932dae..c27b739e8b 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -166,7 +166,7 @@ class StackForm extends Component $this->syncData(true); // Validate for command injection BEFORE any database operations - validateDockerComposeForInjection($this->service->docker_compose_raw); + validateDockerComposeForInjection($this->service->docker_compose_raw, composeResourceDirectory($this->service)); // Use transaction to ensure atomicity - if parse fails, save is rolled back DB::transaction(function () { diff --git a/app/Models/Application.php b/app/Models/Application.php index 605a320562..0bc88f592d 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -2281,7 +2281,7 @@ class Application extends BaseModel } if ($composeFileContent) { try { - validateDockerComposeForInjection($composeFileContent); + validateDockerComposeForInjection($composeFileContent, composeResourceDirectory($this)); } catch (\Exception $e) { $this->docker_compose_location = $initialDockerComposeLocation; $this->base_directory = $initialBaseDirectory; diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index e5d6b971ea..7021b24af4 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -283,7 +283,20 @@ class LocalFileVolume extends BaseModel } $path = data_get_str($this, 'fs_path'); $content = data_get($this, 'content'); - $pathForParentDirectory = str($this->fs_path); + $writesContent = $this->writesContentOnServer(); + if ($path->startsWith('.')) { + $path = $path->after('.'); + $path = $workdir.$path; + } + + if ($writesContent) { + $path = str($this->confinedContentPath($path->value(), $server)); + } elseif (! $this->isAdminControlledComposeMount()) { + $path = str(confinePathToBase($workdir, $path->value(), 'storage path')); + $this->assertRemotePathIsConfined($workdir, $path->value(), $server); + } + + $pathForParentDirectory = $writesContent ? $path : str($this->fs_path); if ($pathForParentDirectory->startsWith('.') || $pathForParentDirectory->startsWith('/') || $pathForParentDirectory->startsWith('~')) { $parent_dir = $pathForParentDirectory->beforeLast('/'); if ($parent_dir != '') { @@ -291,15 +304,6 @@ class LocalFileVolume extends BaseModel $commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true"); } } - if ($path->startsWith('.')) { - $path = $path->after('.'); - $path = $workdir.$path; - } - - if (! $this->isAdminControlledComposeMount()) { - $path = str(confinePathToBase($workdir, $path->value(), 'storage path')); - $this->assertRemotePathIsConfined($workdir, $path->value(), $server); - } // Validate and escape resolved path (may differ from fs_path if relative) validateShellSafePath($path, 'storage path'); @@ -373,6 +377,81 @@ class LocalFileVolume extends BaseModel return 'sh -c '.escapeshellarg(self::REMOTE_PATH_CONFINEMENT_SCRIPT).' sh '.escapeshellarg($baseDirectory).' '.escapeshellarg($path); } + /** + * Coolify writes file content (from Compose `content:`, the UI or the API) to the server. + */ + public function writesContentOnServer(): bool + { + return ! $this->is_directory && (string) $this->content !== ''; + } + + /** + * Coolify writes file content only inside the resource directory, also for Compose bind mounts + * that an administrator can point anywhere. The path must be below the directory (not the + * directory itself), and it must stay inside it after the server resolves symlinks. + * + * @throws \RuntimeException If the path is not inside the resource directory + */ + public function confinedContentPath(string $path, Server $server): string + { + $error = new \RuntimeException( + "Coolify writes file content only inside the resource directory. The path {$path} is outside of it. Use a relative source such as ./config/app.conf." + ); + + foreach ($this->contentBaseDirectories() as $baseDirectory) { + try { + $confinedPath = confinePathToBase($baseDirectory, $path, 'storage path'); + } catch (\Exception) { + continue; + } + if ($confinedPath === normalizeUnixPath($baseDirectory)) { + continue; + } + + try { + self::assertRemotePathIsConfined($baseDirectory, $confinedPath, $server); + } catch (\RuntimeException) { + throw $error; + } + + return $confinedPath; + } + + throw $error; + } + + /** + * The resource workdir, and the directory where the Compose parser resolves `./` sources. They + * differ only for services that use parser version 3. + * + * @return list + */ + public function contentBaseDirectories(): array + { + return array_values(array_unique([$this->ownerResource()->workdir(), $this->composeSourceDirectory()])); + } + + protected function composeSourceDirectory(): string + { + $owner = $this->ownerResource(); + + return $owner instanceof Application || $owner instanceof Service + ? composeResourceDirectory($owner) + : $owner->workdir(); + } + + /** + * The Application, Service or standalone database that owns the storage directory. + */ + protected function ownerResource(): mixed + { + $resource = $this->resource; + + return $resource instanceof ServiceApplication || $resource instanceof ServiceDatabase + ? $resource->service + : $resource; + } + /** * Raw Compose bind mounts keep administrator-selected host path semantics. */ @@ -402,7 +481,7 @@ class LocalFileVolume extends BaseModel continue; } - $resolvedSource = replaceLocalSource(str((string) $source), str($this->resource->workdir())); + $resolvedSource = replaceLocalSource(str((string) $source), str($this->composeSourceDirectory())); if (normalizeUnixPath($resolvedSource->value()) === normalizeUnixPath($this->fs_path)) { return true; } diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 21ef175a5e..e363b1c862 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -22,10 +22,11 @@ use Symfony\Component\Yaml\Yaml; * This should be called BEFORE saving to database to prevent malicious data from being stored. * * @param string $composeYaml The raw Docker Compose YAML content + * @param string|null $resourceDirectory The resource directory, if the resource exists (see validateComposeContentVolumeSource()) * * @throws Exception If the compose file contains command injection attempts */ -function validateDockerComposeForInjection(string $composeYaml): void +function validateDockerComposeForInjection(string $composeYaml, ?string $resourceDirectory = null): void { try { $parsed = Yaml::parse($composeYaml); @@ -78,6 +79,7 @@ function validateDockerComposeForInjection(string $composeYaml): void } } } + validateComposeContentVolumeSource($volume, $resourceDirectory); } } } @@ -105,6 +107,71 @@ function validateDockerComposeForInjection(string $composeYaml): void } } +/** + * The directory that the Compose parsers resolve `./` bind sources in. Services that use parser + * version 3 resolve them in the applications directory. + */ +function composeResourceDirectory(Application|Service $resource): string +{ + if ($resource instanceof Service && (int) $resource->compose_parsing_version === 3) { + return application_configuration_dir().'/'.$resource->uuid; + } + + return $resource->workdir(); +} + +/** + * Coolify writes the `content:` of a Compose bind volume to the host, so that file must be inside + * the resource directory. The source must be a `./` path that stays inside the resource directory, + * or an absolute path inside $resourceDirectory. Sources with `~`, `..` or variables are rejected, + * because their host path is not known before the write. Bind volumes without `content:` are not + * changed: an administrator can mount any host path. + * + * @param array $volume A long-syntax Compose volume + * + * @throws Exception If Coolify would write the content outside the resource directory + */ +function validateComposeContentVolumeSource(array $volume, ?string $resourceDirectory = null): void +{ + if (! array_key_exists('content', $volume) || ($volume['type'] ?? null) !== 'bind') { + return; + } + + $source = $volume['source'] ?? null; + $displaySource = is_scalar($source) && (string) $source !== '' ? (string) $source : '(empty)'; + $error = new Exception( + "Volume source {$displaySource} with content must be inside the resource directory. Use a relative path such as ./config/app.conf." + ); + + if (! is_string($source) || str_contains($source, '$') || str_contains($source, '\\')) { + throw $error; + } + if (in_array('..', explode('/', $source), true)) { + throw $error; + } + + if (str_starts_with($source, './')) { + $baseDirectory = $resourceDirectory ?? '/coolify-resource-directory'; + $path = $baseDirectory.'/'.substr($source, 2); + } elseif (str_starts_with($source, '/') && $resourceDirectory !== null) { + $baseDirectory = $resourceDirectory; + $path = $source; + } else { + throw $error; + } + + try { + $baseDirectory = normalizeUnixPath($baseDirectory); + $path = normalizeUnixPath($path); + } catch (Exception) { + throw $error; + } + + if (! str_starts_with($path, $baseDirectory.'/')) { + throw $error; + } +} + /** * Keep the existing array-source forms, but inspect the default that was previously skipped. */ @@ -982,6 +1049,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($source !== null && ! empty($source->value())) { validateComposeArrayVolumeSource($source->value()); } + validateComposeContentVolumeSource($volume, composeResourceDirectory($resource)); if ($target !== null && ! empty($target->value())) { try { validateShellSafePath($target->value(), 'volume target'); @@ -1026,7 +1094,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int ? (bool) data_get($foundConfig, 'is_preview_suffix_enabled', true) : true; if ($isPullRequest && $isPreviewSuffixEnabled) { - $source = addPreviewDeploymentSuffix($source, $pull_request_id); + $source = str(addPreviewDeploymentSuffix($source, $pull_request_id)); } LocalFileVolume::updateOrCreate( [ @@ -2341,6 +2409,7 @@ function serviceParser(Service $resource): Collection if ($source !== null && ! empty($source->value())) { validateComposeArrayVolumeSource($source->value()); } + validateComposeContentVolumeSource($volume, composeResourceDirectory($resource)); if ($target !== null && ! empty($target->value())) { try { validateShellSafePath($target->value(), 'volume target'); diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 8e56f06fcb..4273115ed5 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -230,14 +230,16 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli $fileVolume->is_directory = true; $fileVolume->save(); } elseif ($isFile === 'NOK' && $isDir === 'NOK' && ! $fileVolume->is_directory && $isInit && $content) { - // Does not exists (no dir or file), not flagged as directory, is init, has content + // Does not exists (no dir or file), not flagged as directory, is init, has content. + // Content is written only inside the resource directory, as a literal path. + $escapedContentLocation = escapeshellarg($fileVolume->confinedContentPath((string) $fileLocation, $server)); $fileVolume->content = $content; $fileVolume->is_directory = false; $fileVolume->save(); $content = base64_encode($content); instant_remote_process([ - 'mkdir -p -- "$(dirname -- '.$escapedFileLocation.')"', - "echo '$content' | base64 -d | tee -- {$escapedFileLocation}", + 'mkdir -p -- "$(dirname -- '.$escapedContentLocation.')"', + "echo '$content' | base64 -d | tee -- {$escapedContentLocation}", ], $server); } elseif ($isFile === 'NOK' && $isDir === 'NOK' && $fileVolume->is_directory && $isInit) { // Does not exists (no dir or file), flagged as directory, is init diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 2c5a878261..e8b69a53b6 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -2799,6 +2799,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $target = data_get_str($volume, 'target'); $content = data_get($volume, 'content'); $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null); + validateComposeContentVolumeSource($volume, $savedService->service->workdir()); $foundConfig = $savedService->fileStorages()->whereMountPath($target)->first(); if ($foundConfig) { $contentNotNull = data_get($foundConfig, 'content'); diff --git a/tests/Feature/ComposeContentVolumeConfinementTest.php b/tests/Feature/ComposeContentVolumeConfinementTest.php new file mode 100644 index 0000000000..10b805764e --- /dev/null +++ b/tests/Feature/ComposeContentVolumeConfinementTest.php @@ -0,0 +1,361 @@ + 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]); +}); + +function contentConfinementService(string $compose): Service +{ + return Service::factory()->create([ + 'environment_id' => test()->environment->id, + 'server_id' => test()->server->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + 'docker_compose_raw' => $compose, + ]); +} + +function contentConfinementApplication(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, + ]); +} + +/** + * A file volume row that skipped the Compose validation, for example because it was stored before + * the validation existed or because the content came from the UI. + */ +function contentConfinementFileVolume(Service $service, string $fsPath, ?string $content, bool $isDirectory = false): LocalFileVolume +{ + $serviceApplication = ServiceApplication::create(['name' => 'app', 'service_id' => $service->id]); + + // Bus::fake() keeps the ServerStorageSaveJob of the `created` hook from running. + return LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => '/keys', + 'content' => $content, + 'is_directory' => $isDirectory, + 'resource_id' => $serviceApplication->id, + 'resource_type' => $serviceApplication->getMorphClass(), + ])->fresh(); +} + +/** + * Fakes the server. The symlink check prints $confinement, `test -f`/`test -d` print NOK. + */ +function contentConfinementFakeServer(string $confinement = 'OK'): void +{ + Process::fake(fn ($process) => Process::result(output: match (true) { + str_contains($process->command, 'readlink -f') => $confinement, + str_contains($process->command, 'test -') => 'NOK', + default => '', + })); +} + +function contentConfinementAssertNoContentWrite(): void +{ + Process::assertDidntRun(fn ($process) => str_contains($process->command, 'base64 -d')); + Process::assertDidntRun(fn ($process) => str_contains($process->command, 'touch ')); +} + +test('the service parser rejects a content volume outside the service directory', function () { + $service = contentConfinementService(CONTENT_CONFINEMENT_OUTSIDE_COMPOSE); + + expect(fn () => serviceParser($service)) + ->toThrow(Exception::class, 'Volume source /root/.ssh/authorized_keys with content must be inside the resource directory.'); + + expect(LocalFileVolume::query()->count())->toBe(0); + Bus::assertNotDispatched(ServerStorageSaveJob::class); +}); + +test('the application parser rejects a content volume outside the application directory', function () { + $application = contentConfinementApplication(CONTENT_CONFINEMENT_OUTSIDE_COMPOSE); + + expect(fn () => applicationParser($application)) + ->toThrow(Exception::class, 'Volume source /root/.ssh/authorized_keys with content must be inside the resource directory.'); + + expect(LocalFileVolume::query()->count())->toBe(0); + Bus::assertNotDispatched(ServerStorageSaveJob::class); +}); + +test('the parsers reject traversal, home and variable sources with content', function (string $source) { + $compose = str_replace('/root/.ssh/authorized_keys', $source, CONTENT_CONFINEMENT_OUTSIDE_COMPOSE); + + expect(fn () => serviceParser(contentConfinementService($compose))) + ->toThrow(Exception::class, 'with content must be inside the resource directory.') + ->and(fn () => applicationParser(contentConfinementApplication($compose))) + ->toThrow(Exception::class, 'with content must be inside the resource directory.'); + + expect(LocalFileVolume::query()->count())->toBe(0); +})->with([ + './../../../root/.ssh/authorized_keys', + '../authorized_keys', + '~/.ssh/authorized_keys', + '${HOME}/.ssh/authorized_keys', +]); + +test('a relative content volume is stored and written inside the service directory', function () { + $service = contentConfinementService(CONTENT_CONFINEMENT_RELATIVE_COMPOSE); + serviceParser($service); + + $fileVolume = LocalFileVolume::query()->sole(); + $expectedPath = $service->workdir().'/config/app.conf'; + expect($fileVolume->fs_path)->toBe($expectedPath) + ->and($fileVolume->content)->toBe('listen 8080;') + ->and($fileVolume->is_directory)->toBeFalse(); + + contentConfinementFakeServer(); + $fileVolume->saveStorageOnServer(); + + Process::assertRan(fn ($process) => str_contains($process->command, 'readlink -f') + && str_contains($process->command, "'{$service->workdir()}' '{$expectedPath}'")); + Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p '{$service->workdir()}/config'")); + Process::assertRan(fn ($process) => str_contains($process->command, "| base64 -d | tee '{$expectedPath}' > /dev/null")); +}); + +test('a relative content volume is stored inside the application directory', function () { + $application = contentConfinementApplication(CONTENT_CONFINEMENT_RELATIVE_COMPOSE); + applicationParser($application); + + expect(LocalFileVolume::query()->sole()->fs_path)->toBe($application->workdir().'/config/app.conf'); +}); + +test('the write path refuses content outside the resource directory for a Compose bind mount', function () { + // The Compose file no longer has `content:` (the parser removes it), so the mount is administrator-controlled. + $compose = "services:\n app:\n image: nginx:alpine\n volumes:\n - type: bind\n source: /root/.ssh/authorized_keys\n target: /keys\n"; + $service = contentConfinementService($compose); + $fileVolume = contentConfinementFileVolume($service, '/root/.ssh/authorized_keys', 'ssh-ed25519 AAAA attacker'); + + contentConfinementFakeServer(); + + expect(fn () => $fileVolume->saveStorageOnServer()) + ->toThrow(RuntimeException::class, 'Coolify writes file content only inside the resource directory.'); + contentConfinementAssertNoContentWrite(); +}); + +test('the write path refuses ../ traversal out of the resource directory', function () { + $source = './../../../root/.ssh/authorized_keys'; + $compose = "services:\n app:\n image: nginx:alpine\n volumes:\n - type: bind\n source: {$source}\n target: /keys\n"; + $service = contentConfinementService($compose); + $fileVolume = contentConfinementFileVolume($service, $service->workdir().'/../../../root/.ssh/authorized_keys', 'attacker'); + + contentConfinementFakeServer(); + + expect(fn () => $fileVolume->saveStorageOnServer()) + ->toThrow(RuntimeException::class, 'Coolify writes file content only inside the resource directory.'); + contentConfinementAssertNoContentWrite(); +}); + +test('the write path refuses a symlink that leaves the resource directory', function () { + $service = contentConfinementService(CONTENT_CONFINEMENT_RELATIVE_COMPOSE); + $fileVolume = contentConfinementFileVolume($service, $service->workdir().'/config/app.conf', 'listen 8080;'); + + // The server resolves the symlinked config directory to a path outside the resource directory. + contentConfinementFakeServer('NOK'); + + expect(fn () => $fileVolume->saveStorageOnServer()) + ->toThrow(RuntimeException::class, 'Coolify writes file content only inside the resource directory.'); + contentConfinementAssertNoContentWrite(); +}); + +test('the write path refuses to write content to the resource directory itself', function () { + $service = contentConfinementService(CONTENT_CONFINEMENT_SAFE_COMPOSE); + $fileVolume = contentConfinementFileVolume($service, $service->workdir(), 'content'); + + contentConfinementFakeServer(); + + expect(fn () => $fileVolume->saveStorageOnServer()) + ->toThrow(RuntimeException::class, 'Coolify writes file content only inside the resource directory.'); + contentConfinementAssertNoContentWrite(); + Process::assertDidntRun(fn ($process) => str_contains($process->command, 'rm -fr')); +}); + +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 = contentConfinementService($compose); + $fileVolume = contentConfinementFileVolume($service, '/srv/shared', null, isDirectory: true); + + contentConfinementFakeServer(); + $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('a host file bind mount without content is still touched in place', function () { + $compose = "services:\n app:\n image: nginx:alpine\n volumes:\n - /etc/localtime:/keys:ro\n"; + $service = contentConfinementService($compose); + $fileVolume = contentConfinementFileVolume($service, '/etc/localtime', null); + + contentConfinementFakeServer(); + $fileVolume->saveStorageOnServer(); + + Process::assertRan(fn ($process) => str_contains($process->command, "touch '/etc/localtime'")); + Process::assertDidntRun(fn ($process) => str_contains($process->command, 'readlink -f')); +}); + +test('loading files for a service does not write content outside the resource directory', function () { + $compose = "services:\n app:\n image: nginx:alpine\n volumes:\n - type: bind\n source: /etc/cron.d/coolify\n target: /keys\n"; + $service = contentConfinementService($compose); + $fileVolume = contentConfinementFileVolume($service, '/etc/cron.d/coolify', '* * * * * root id'); + + contentConfinementFakeServer(); + + expect(fn () => getFilesystemVolumesFromServer($fileVolume->resource, true)) + ->toThrow(Exception::class, 'Coolify writes file content only inside the resource directory.'); + contentConfinementAssertNoContentWrite(); +}); + +test('loading files for a service writes relative content inside the resource directory', function () { + $service = contentConfinementService(CONTENT_CONFINEMENT_RELATIVE_COMPOSE); + $expectedPath = $service->workdir().'/config/app.conf'; + $fileVolume = contentConfinementFileVolume($service, $expectedPath, 'listen 8080;'); + + contentConfinementFakeServer(); + getFilesystemVolumesFromServer($fileVolume->resource, true); + + Process::assertRan(fn ($process) => str_contains($process->command, "base64 -d | tee -- '{$expectedPath}'")); +}); + +test('the service stack form rejects a content volume outside the service directory', function () { + $this->actingAs($this->user); + $service = contentConfinementService(CONTENT_CONFINEMENT_SAFE_COMPOSE); + + Livewire::test(StackForm::class, ['service' => $service]) + ->set('dockerComposeRaw', CONTENT_CONFINEMENT_OUTSIDE_COMPOSE) + ->call('submit') + ->assertDispatched('error', fn (string $event, array $params): bool => str_contains($params[0], 'with content must be inside the resource directory')); + + expect($service->fresh()->docker_compose_raw)->toBe(CONTENT_CONFINEMENT_SAFE_COMPOSE) + ->and(LocalFileVolume::query()->count())->toBe(0); +}); + +test('the application General form rejects a content volume outside the application directory', function () { + $this->actingAs($this->user); + $application = contentConfinementApplication(CONTENT_CONFINEMENT_SAFE_COMPOSE); + Process::fake(); + + Livewire::test(General::class, ['application' => $application->fresh()]) + ->set('dockerComposeRaw', CONTENT_CONFINEMENT_OUTSIDE_COMPOSE) + ->call('submit') + ->assertDispatched('error', fn (string $event, array $params): bool => str_contains($params[0], 'with content must be inside the resource directory')) + ->assertNotDispatched('success'); + + expect($application->fresh()->docker_compose_raw)->toBe(CONTENT_CONFINEMENT_SAFE_COMPOSE) + ->and(LocalFileVolume::query()->count())->toBe(0); +}); + +test('loading a Git Compose file rejects a content volume outside the application directory', function () { + $application = contentConfinementApplication(CONTENT_CONFINEMENT_SAFE_COMPOSE); + Process::fake(function ($process) { + $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') => CONTENT_CONFINEMENT_OUTSIDE_COMPOSE, + default => '', + }); + }); + + expect(fn () => $application->loadComposeFile()) + ->toThrow(Exception::class, 'with content must be inside the resource directory'); + + expect($application->fresh()->docker_compose_raw)->toBe(CONTENT_CONFINEMENT_SAFE_COMPOSE) + ->and(LocalFileVolume::query()->count())->toBe(0); +}); + +test('the service API rejects a content volume outside the service directory', function () { + $service = contentConfinementService(CONTENT_CONFINEMENT_SAFE_COMPOSE); + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'content-confinement', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]) + ->patchJson("/api/v1/services/{$service->uuid}", [ + 'docker_compose_raw' => base64_encode(CONTENT_CONFINEMENT_OUTSIDE_COMPOSE), + ]) + ->assertStatus(422) + ->assertJsonPath('errors.docker_compose_raw', 'Volume source /root/.ssh/authorized_keys with content must be inside the resource directory. Use a relative path such as ./config/app.conf.'); + + expect($service->fresh()->docker_compose_raw)->toBe(CONTENT_CONFINEMENT_SAFE_COMPOSE) + ->and(LocalFileVolume::query()->count())->toBe(0); +}); diff --git a/tests/Feature/ContainerNameShellQuotingTest.php b/tests/Feature/ContainerNameShellQuotingTest.php new file mode 100644 index 0000000000..0fa8a6c6eb --- /dev/null +++ b/tests/Feature/ContainerNameShellQuotingTest.php @@ -0,0 +1,240 @@ + 0]); + config([ + 'app.maintenance.store' => 'array', + 'constants.ssh.mux_enabled' => false, + ]); + + $this->team = Team::factory()->create(); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false, 'docker_version' => '28.0.0']); + $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]); + + $this->service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'docker_compose_raw' => "services:\n app:\n image: nginx\n", + ]); + $this->serviceApplication = ServiceApplication::create([ + 'name' => QUOTING_HOSTILE_NAME, + 'service_id' => $this->service->id, + 'status' => 'running:healthy', + ]); + $this->containerName = escapeshellarg(QUOTING_HOSTILE_NAME.'-'.$this->service->uuid); + + Process::fake(fn () => Process::result(output: 'done')); +}); + +function quotingUseNonRootUser(): void +{ + test()->server->update(['user' => 'ubuntu']); + test()->server->refresh(); + test()->service->refresh(); + test()->serviceApplication->refresh(); +} + +function quotingAssertCommandRan(string $command): void +{ + Process::assertRan(fn ($process) => str_contains($process->command, $command)); +} + +function quotingAssertNoUnquotedName(): void +{ + Process::assertDidntRun(fn ($process) => str_contains($process->command, ' app;touch')); +} + +test('stopping a service application quotes its container name', function (bool $nonRoot, string $sudo) { + if ($nonRoot) { + quotingUseNonRootUser(); + } + + StopServiceApplication::run($this->serviceApplication); + StopServiceApplication::run($this->serviceApplication->refresh(), removeContainer: true); + + quotingAssertCommandRan("\n{$sudo}docker stop {$this->containerName}\n"); + quotingAssertCommandRan("\n{$sudo}docker rm -f {$this->containerName}\n"); + quotingAssertNoUnquotedName(); +})->with(['root' => [false, ''], 'non-root' => [true, 'sudo ']]); + +test('restarting a service application quotes its container name', function (bool $nonRoot, string $sudo) { + if ($nonRoot) { + quotingUseNonRootUser(); + } + + RestartServiceApplication::run($this->serviceApplication); + + quotingAssertCommandRan("\n{$sudo}docker restart {$this->containerName}\n"); + quotingAssertNoUnquotedName(); +})->with(['root' => [false, ''], 'non-root' => [true, 'sudo ']]); + +test('stopping a service quotes every container name', function (bool $nonRoot, string $sudo) { + if ($nonRoot) { + quotingUseNonRootUser(); + } + ServiceDatabase::create(['name' => 'db', 'service_id' => $this->service->id]); + $databaseContainer = escapeshellarg('db-'.$this->service->uuid); + + StopService::run($this->service, dockerCleanup: false); + + quotingAssertCommandRan("{$sudo}docker stop --timeout=30 {$this->containerName} {$databaseContainer}\n"); + quotingAssertCommandRan("\n{$sudo}docker rm -f {$this->containerName} {$databaseContainer}\n"); + quotingAssertNoUnquotedName(); +})->with(['root' => [false, ''], 'non-root' => [true, 'sudo ']]); + +test('a scheduled task in a service quotes the container name', function (bool $nonRoot, string $sudo) { + if ($nonRoot) { + quotingUseNonRootUser(); + } + $task = ScheduledTask::factory()->create([ + 'team_id' => $this->team->id, + 'service_id' => $this->service->id, + 'container' => QUOTING_HOSTILE_NAME, + 'command' => "echo 'hello'", + ]); + + (new ScheduledTaskJob($task))->handle(); + + quotingAssertCommandRan("{$sudo}docker exec {$this->containerName} sh -c 'echo '\\''hello'\\''' 2>&1 |"); + quotingAssertNoUnquotedName(); + expect($task->executions()->sole()->status)->toBe('success'); +})->with(['root' => [false, ''], 'non-root' => [true, 'sudo ']]); + +/** + * `docker ps` returns one container for the application. + */ +function quotingFakeApplicationContainer(string $name): void +{ + Process::fake(function ($process) use ($name) { + if (str_contains($process->command, 'docker ps -a --filter')) { + return Process::result(output: json_encode(['Names' => $name, 'Labels' => 'coolify.applicationId=1', 'State' => 'running'])); + } + + return Process::result(output: ''); + }); +} + +function quotingApplication(): Application +{ + return Application::factory()->create([ + 'environment_id' => test()->environment->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + ]); +} + +test('stopping an application quotes the container names from docker ps', function (bool $nonRoot, string $sudo) { + if ($nonRoot) { + quotingUseNonRootUser(); + } + $application = quotingApplication(); + quotingFakeApplicationContainer(QUOTING_HOSTILE_NAME); + $quoted = escapeshellarg(QUOTING_HOSTILE_NAME); + + StopApplication::run($application, dockerCleanup: false); + + quotingAssertCommandRan("{$sudo}docker stop --timeout="); + Process::assertRan(fn ($process) => preg_match('/docker stop --timeout=\d+ '.preg_quote($quoted, '/').'\n/', $process->command) === 1); + quotingAssertCommandRan("\n{$sudo}docker rm -f {$quoted}\n"); + quotingAssertNoUnquotedName(); +})->with(['root' => [false, ''], 'non-root' => [true, 'sudo ']]); + +test('stopping an application preview quotes the container names from docker ps', function () { + $application = quotingApplication(); + $preview = ApplicationPreview::forceCreate([ + 'application_id' => $application->id, + 'pull_request_id' => 1, + 'pull_request_html_url' => 'https://example.com/pull/1', + ]); + Process::fake(function ($process) { + if (str_contains($process->command, 'docker ps -a --filter')) { + return Process::result(output: json_encode(['Names' => QUOTING_HOSTILE_NAME, 'Labels' => 'coolify.applicationId=1,coolify.pullRequestId=1'])); + } + + return Process::result(output: ''); + }); + $quoted = escapeshellarg(QUOTING_HOSTILE_NAME); + + StopApplicationPreview::run($preview); + + Process::assertRan(fn ($process) => preg_match('/docker stop --timeout=\d+ '.preg_quote($quoted, '/').'\n/', $process->command) === 1); + quotingAssertCommandRan("\ndocker rm -f {$quoted}\n"); + quotingAssertNoUnquotedName(); +}); + +test('stopping an application on one server quotes the container names from docker ps', function () { + $application = quotingApplication(); + quotingFakeApplicationContainer(QUOTING_HOSTILE_NAME); + $quoted = escapeshellarg(QUOTING_HOSTILE_NAME); + + StopApplicationOneServer::run($application, $this->server); + + Process::assertRan(fn ($process) => preg_match('/docker stop --timeout=\d+ '.preg_quote($quoted, '/').'\n/', $process->command) === 1); + quotingAssertNoUnquotedName(); +}); + +test('the non-root sudo parser keeps quoted container names as one argument', function () { + $server = new Server(['user' => 'ubuntu']); + $name = escapeshellarg(QUOTING_HOSTILE_NAME.'-uuid'); + + expect(parseCommandsByLineForSudo(collect([ + "docker stop {$name}", + "docker restart {$name}", + "docker rm -f {$name} 'db-uuid'", + dockerStopCommand(30, "{$name} 'db-uuid'", '28.0.0'), + ]), $server))->toBe([ + "sudo docker stop {$name}", + "sudo docker restart {$name}", + "sudo docker rm -f {$name} 'db-uuid'", + "sudo docker stop --timeout=30 {$name} 'db-uuid'", + ]); +}); diff --git a/tests/Unit/ComposeContentVolumeSourceValidationTest.php b/tests/Unit/ComposeContentVolumeSourceValidationTest.php new file mode 100644 index 0000000000..f59ebfc3b3 --- /dev/null +++ b/tests/Unit/ComposeContentVolumeSourceValidationTest.php @@ -0,0 +1,192 @@ + "key=value\n"]): string +{ + return Yaml::dump([ + 'services' => [ + 'app' => [ + 'image' => 'nginx:alpine', + 'volumes' => [ + array_merge(['type' => 'bind', 'source' => $source, 'target' => '/etc/app.conf'], $extra), + ], + ], + ], + ], 10, 2); +} + +/** + * @return array + */ +function contentVolumeSourcesOutsideTheResourceDirectory(): array +{ + return [ + 'absolute host file' => ['/root/.ssh/authorized_keys'], + 'absolute cron file' => ['/etc/cron.d/coolify'], + 'absolute path in another resource directory' => ['/data/coolify/services/other-uuid/app.conf'], + 'home directory' => ['~/app.conf'], + 'home directory of another user' => ['~root/.ssh/authorized_keys'], + 'parent directory' => ['../app.conf'], + 'traversal after ./' => ['./../../../root/.ssh/authorized_keys'], + 'traversal inside the path' => ['./config/../../outside.conf'], + 'dot segment in the path' => ['./config/../app.conf'], + 'braced variable' => ['${HOME}/.ssh/authorized_keys'], + 'variable with default' => ['${DATA:-/etc/cron.d/coolify}'], + 'plain variable' => ['$HOME/.ssh/authorized_keys'], + 'variable after ./' => ['./$HOME/app.conf'], + 'resource directory itself' => ['./'], + 'current directory' => ['.'], + 'hidden sibling' => ['.ssh/authorized_keys'], + 'bare relative path' => ['config/app.conf'], + 'backslash' => ['./config\\app.conf'], + ]; +} + +it('rejects content volumes outside the resource directory', function (string $source) { + expect(fn () => validateDockerComposeForInjection(contentVolumeCompose($source))) + ->toThrow(Exception::class, 'with content must be inside the resource directory. Use a relative path such as ./config/app.conf.'); +})->with(contentVolumeSourcesOutsideTheResourceDirectory()); + +it('rejects content volumes outside the resource directory of an existing resource', function (string $source) { + expect(fn () => validateDockerComposeForInjection(contentVolumeCompose($source), CONTENT_VOLUME_RESOURCE_DIRECTORY)) + ->toThrow(Exception::class, 'with content must be inside the resource directory.'); +})->with(contentVolumeSourcesOutsideTheResourceDirectory()); + +it('shows the source in the error message', function () { + expect(fn () => validateDockerComposeForInjection(contentVolumeCompose('/root/.ssh/authorized_keys'))) + ->toThrow(Exception::class, 'Volume source /root/.ssh/authorized_keys with content must be inside the resource directory. Use a relative path such as ./config/app.conf.'); +}); + +it('rejects an outside content volume that is also marked as a directory', function (string $flag) { + $compose = contentVolumeCompose('/etc/cron.d', ['content' => '', $flag => true]); + + expect(fn () => validateDockerComposeForInjection($compose)) + ->toThrow(Exception::class, 'with content must be inside the resource directory.'); +})->with(['is_directory', 'isDirectory']); + +it('rejects an empty or missing content value on an outside source', function (mixed $content) { + expect(fn () => validateDockerComposeForInjection(contentVolumeCompose('/etc/nginx', ['content' => $content]))) + ->toThrow(Exception::class, 'with content must be inside the resource directory.'); +})->with(['empty string' => '', 'null' => null]); + +it('rejects a content volume without a source', function () { + $compose = "services:\n app:\n image: nginx\n volumes:\n - type: bind\n target: /etc/app.conf\n content: x\n"; + + expect(fn () => validateDockerComposeForInjection($compose)) + ->toThrow(Exception::class, 'Volume source (empty) with content must be inside the resource directory.'); +}); + +it('accepts relative content volumes inside the resource directory', function (string $source) { + validateDockerComposeForInjection(contentVolumeCompose($source)); + validateDockerComposeForInjection(contentVolumeCompose($source), CONTENT_VOLUME_RESOURCE_DIRECTORY); + + expect(true)->toBeTrue(); +})->with(['./app.conf', './config/app.conf', './config/nested/app.conf', './config/', './.env.local']); + +it('accepts an absolute content source only inside the directory of an existing resource', function () { + $inside = CONTENT_VOLUME_RESOURCE_DIRECTORY.'/config/app.conf'; + + validateDockerComposeForInjection(contentVolumeCompose($inside), CONTENT_VOLUME_RESOURCE_DIRECTORY); + + expect(fn () => validateDockerComposeForInjection(contentVolumeCompose($inside))) + ->toThrow(Exception::class, 'with content must be inside the resource directory.') + ->and(fn () => validateDockerComposeForInjection(contentVolumeCompose(CONTENT_VOLUME_RESOURCE_DIRECTORY), CONTENT_VOLUME_RESOURCE_DIRECTORY)) + ->toThrow(Exception::class, 'with content must be inside the resource directory.') + ->and(fn () => validateDockerComposeForInjection(contentVolumeCompose(CONTENT_VOLUME_RESOURCE_DIRECTORY.'-sibling/app.conf'), CONTENT_VOLUME_RESOURCE_DIRECTORY)) + ->toThrow(Exception::class, 'with content must be inside the resource directory.'); +}); + +it('does not change bind volumes without content', function (string $compose) { + validateDockerComposeForInjection($compose); + validateDockerComposeForInjection($compose, CONTENT_VOLUME_RESOURCE_DIRECTORY); + + expect(true)->toBeTrue(); +})->with([ + 'docker socket short syntax' => ["services:\n app:\n image: nginx\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n"], + 'docker socket long syntax' => ["services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: /var/run/docker.sock\n target: /var/run/docker.sock\n"], + 'absolute host directory' => ["services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: /srv/shared\n target: /shared\n is_directory: true\n"], + 'home directory' => ["services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: ~/booklore\n target: /bookdrop\n is_directory: true\n"], + 'variable with default' => ["services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: \${FOUNDRY_DATA:-/data/foundryvtt}\n target: /data\n is_directory: true\n"], + 'parent directory short syntax' => ["services:\n app:\n image: nginx\n volumes:\n - ../shared:/shared\n"], +]); + +it('ignores content on named volumes because Coolify does not write it', function () { + validateDockerComposeForInjection(contentVolumeCompose('app-data', ['type' => 'volume', 'content' => 'x'])); + + expect(true)->toBeTrue(); +}); + +it('resolves the directory the parsers use for each resource', function () { + $service = new Service; + $service->uuid = 'service-uuid'; + $service->compose_parsing_version = '5'; + $legacyService = new Service; + $legacyService->uuid = 'legacy-uuid'; + $legacyService->compose_parsing_version = '3'; + $application = new Application; + $application->uuid = 'application-uuid'; + + expect(composeResourceDirectory($service))->toBe('/data/coolify/services/service-uuid') + ->and(composeResourceDirectory($legacyService))->toBe('/data/coolify/applications/legacy-uuid') + ->and(composeResourceDirectory($application))->toBe('/data/coolify/applications/application-uuid'); +}); + +/** + * @return array + */ +function composeTemplatesWithContentVolumes(): array +{ + $templates = []; + foreach (glob(dirname(__DIR__, 2).'/templates/compose/*.{yaml,yml}', GLOB_BRACE) as $file) { + if (preg_match('/^\s+content:/m', file_get_contents($file))) { + $templates[basename($file)] = [$file]; + } + } + + return $templates; +} + +it('still accepts every service template with content volumes', function (string $file) { + $compose = file_get_contents($file); + validateDockerComposeForInjection($compose); + validateDockerComposeForInjection($compose, CONTENT_VOLUME_RESOURCE_DIRECTORY); + + $contentVolumes = 0; + foreach (Yaml::parse($compose)['services'] as $service) { + foreach ($service['volumes'] ?? [] as $volume) { + if (! is_array($volume) || ! array_key_exists('content', $volume) || ($volume['type'] ?? null) !== 'bind') { + continue; + } + $contentVolumes++; + $resolved = replaceLocalSource(str($volume['source']), str(CONTENT_VOLUME_RESOURCE_DIRECTORY))->value(); + + expect(confinePathToBase(CONTENT_VOLUME_RESOURCE_DIRECTORY, $resolved))->toStartWith(CONTENT_VOLUME_RESOURCE_DIRECTORY.'/'); + } + } + + expect($contentVolumes)->toBeGreaterThan(0); +})->with(composeTemplatesWithContentVolumes()); + +it('checks the complete content volume template corpus', function () { + $contentVolumes = 0; + foreach (composeTemplatesWithContentVolumes() as [$file]) { + foreach (Yaml::parse(file_get_contents($file))['services'] as $service) { + foreach ($service['volumes'] ?? [] as $volume) { + if (is_array($volume) && array_key_exists('content', $volume)) { + $contentVolumes++; + } + } + } + } + + expect($contentVolumes)->toBeGreaterThanOrEqual(94); +}); diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php index abd03c32d3..949767cf9a 100644 --- a/tests/Unit/LocalFileVolumeContentSizeTest.php +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -181,7 +181,7 @@ it('quotes literal file-storage paths and safely expands persisted expressions', $literal->is_directory = true; $literal->shouldReceive('save')->once(); $file = Mockery::mock(LocalFileVolume::class)->makePartial(); - $file->fs_path = '/data/my files/settings.json'; + $file->fs_path = '/data/application/my files/settings.json'; $file->content = '{}'; $file->is_directory = false; $file->shouldReceive('save')->once(); @@ -195,17 +195,22 @@ it('quotes literal file-storage paths and safely expands persisted expressions', $application = Mockery::mock(Application::class)->makePartial(); $application->shouldReceive('getMorphClass')->andReturn(Application::class); - $application->shouldReceive('workdir')->once()->andReturn('/data/application'); + $application->shouldReceive('workdir')->andReturn('/data/application'); $application->shouldReceive('fileStorages')->once()->andReturn($fileStorages); $application->setRelation('destination', (object) ['server' => $server]); + $file->setRelation('resource', $application); - Process::fake(fn ($process) => Process::result(output: str_contains($process->command, 'test -') ? 'NOK' : '')); + Process::fake(fn ($process) => Process::result(output: match (true) { + str_contains($process->command, 'readlink -f') => 'OK', + str_contains($process->command, 'test -') => 'NOK', + default => '', + })); getFilesystemVolumesFromServer($application, true); Process::assertRan(fn ($process) => str_contains($process->command, "test -f '/data/my files/config.yaml'")); Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p -- '/data/my files/config.yaml'")); - Process::assertRan(fn ($process) => str_contains($process->command, "dirname -- '/data/my files/settings.json'")); - Process::assertRan(fn ($process) => str_contains($process->command, "tee -- '/data/my files/settings.json'")); + Process::assertRan(fn ($process) => str_contains($process->command, "dirname -- '/data/application/my files/settings.json'")); + Process::assertRan(fn ($process) => str_contains($process->command, "tee -- '/data/application/my files/settings.json'")); Process::assertRan(fn ($process) => str_contains($process->command, '${DATA_PATH:-/srv/app/config.yaml}')); });