From 116806df1b51ac916aa4eca43048c842a25cbb5f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:03:39 +0200 Subject: [PATCH] fix(compose): allow content files outside the resource directory again Since aabd12737 a Compose bind volume with a content block had to stay inside the resource directory. Services and Compose applications saved in v4.3.23 with other paths, and Git Compose files with them, no longer started or deployed. Only admins and owners can edit Compose files, and they can already write any host file with a plain bind mount, so the rule added no security. - Write content files to their host path again, as in v4.3.23 - Keep shell escaping and the safe-character checks on volume sources, so injection attempts are still rejected --- .../Controllers/Api/ServicesController.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 | 86 +--- bootstrap/helpers/parsers.php | 55 +-- bootstrap/helpers/services.php | 19 +- bootstrap/helpers/shared.php | 2 +- .../ComposeContentVolumeConfinementTest.php | 429 ++++++++++-------- tests/Feature/ComposeParentBindPathTest.php | 7 +- ...mposeContentVolumeSourceValidationTest.php | 144 ++---- 11 files changed, 310 insertions(+), 440 deletions(-) diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 521e848a88..eccd726526 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1282,7 +1282,7 @@ class ServicesController extends Controller // Validate for command injection BEFORE saving to database try { - validateDockerComposeForInjection($dockerComposeRaw, composeResourceDirectory($service)); + validateDockerComposeForInjection($dockerComposeRaw); } catch (\Exception $e) { return response()->json([ 'message' => 'Validation failed.', diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 9849811dab..cf3aa235df 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -356,7 +356,7 @@ class General extends Component } try { - validateDockerComposeForInjection($this->dockerComposeRaw, composeResourceDirectory($this->application)); + validateDockerComposeForInjection($this->dockerComposeRaw); } 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 fd968c7f0e..9c55156489 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -168,7 +168,7 @@ class StackForm extends Component $this->syncData(true); // Validate for command injection BEFORE any database operations - validateDockerComposeForInjection($this->service->docker_compose_raw, composeResourceDirectory($this->service)); + validateDockerComposeForInjection($this->service->docker_compose_raw); // 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 277b7ec246..23fb3e96e9 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -2336,7 +2336,7 @@ class Application extends BaseModel } if ($composeFileContent) { try { - validateDockerComposeForInjection($composeFileContent, composeResourceDirectory($this)); + validateDockerComposeForInjection($composeFileContent); } 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 a74514c471..6f73eb8326 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -306,21 +306,14 @@ class LocalFileVolume extends BaseModel $path = $this->resolvedFsPath($workdir); $content = data_get($this, 'content'); - $writesContent = $this->writesContentOnServer(); - if ($this->isAdminControlledComposeMount()) { - // Compose `content:` stays in the resource directory: the Compose file can come from a Git repository. - if ($writesContent) { - $path = str($this->confinedContentPath($path->value(), $server)); - } - } else { + // A Compose bind mount (also with `content:`) uses the host path that the administrator wrote. + if (! $this->isAdminControlledComposeMount()) { [$hostPath, $resourceDirectory] = $this->hostPathAndResourceDirectory(); if ($resourceDirectory === null) { if (! $this->is_directory) { self::assertRemotePathIsNotSymlink($hostPath, $server); } - } elseif ($writesContent) { - $hostPath = $this->confinedContentPath($hostPath, $server); } else { $this->assertRemotePathIsConfined($resourceDirectory, $hostPath, $server); } @@ -434,21 +427,17 @@ class LocalFileVolume extends BaseModel } /** - * The absolute path where Coolify writes the content of this file on the server. This check is - * only on the Coolify side; saveStorageOnServer() checks the path on the server again. + * The path where saveStorageOnServer() writes the content of this file on the server. * - * @throws \RuntimeException If the path is not inside the resource directory + * @throws \Exception If the path is not allowed */ public function contentPathOnServer(): string { - if (! $this->isAdminControlledComposeMount()) { - [$hostPath, $resourceDirectory] = $this->hostPathAndResourceDirectory(); - if ($resourceDirectory === null) { - return $hostPath; - } + if ($this->isAdminControlledComposeMount()) { + return $this->resolvedFsPath($this->ownerResource()->workdir())->value(); } - return $this->localConfinedContentPath($this->resolvedFsPath($this->ownerResource()->workdir())->value())[1]; + return $this->hostPathAndResourceDirectory()[0]; } /** @@ -614,67 +603,6 @@ 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 - { - [$baseDirectory, $confinedPath] = $this->localConfinedContentPath($path); - - try { - self::assertRemotePathIsConfined($baseDirectory, $confinedPath, $server); - } catch (\RuntimeException) { - throw $this->contentOutsideResourceDirectoryException($path); - } - - return $confinedPath; - } - - /** - * The first resource base directory that contains the path (not the directory itself), and the - * normalized path. This check is only on the Coolify side. - * - * @return array{0: string, 1: string} - * - * @throws \RuntimeException If the path is not inside the resource directory - */ - protected function localConfinedContentPath(string $path): array - { - foreach ($this->contentBaseDirectories() as $baseDirectory) { - try { - $confinedPath = confinePathToBase($baseDirectory, $path, 'storage path'); - } catch (\Exception) { - continue; - } - if ($confinedPath === normalizeUnixPath($baseDirectory)) { - continue; - } - - return [$baseDirectory, $confinedPath]; - } - - throw $this->contentOutsideResourceDirectoryException($path); - } - - protected function contentOutsideResourceDirectoryException(string $path): \RuntimeException - { - return 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." - ); - } - /** * The resource workdir, and the directory where the Compose parser resolves `./` sources. They * differ only for services that use parser version 3. diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 44e1ed5c85..5b435f0105 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -22,11 +22,10 @@ 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, ?string $resourceDirectory = null): void +function validateDockerComposeForInjection(string $composeYaml): void { try { $parsed = Yaml::parse($composeYaml); @@ -79,7 +78,7 @@ function validateDockerComposeForInjection(string $composeYaml, ?string $resourc } } } - validateComposeContentVolumeSource($volume, $resourceDirectory); + validateComposeContentVolumeSource($volume); } } } @@ -130,55 +129,27 @@ function composeResourceDirectory(Application|Service $resource): string } /** - * 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. + * Coolify writes the `content:` of a Compose bind volume to the source path on the host. Only + * administrators can edit a Compose file, and they can mount any host path, so the source can be + * any path, also outside the resource directory. It must exist and be safe to + * use in a shell command; Coolify escapes it in every remote command. * * @param array $volume A long-syntax Compose volume * - * @throws Exception If Coolify would write the content outside the resource directory + * @throws Exception If the source is missing or contains shell metacharacters */ -function validateComposeContentVolumeSource(array $volume, ?string $resourceDirectory = null): void +function validateComposeContentVolumeSource(array $volume): 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 (! is_string($source) || trim($source) === '') { + throw new Exception('Invalid Docker volume definition (array syntax): A bind volume with content needs a source path.'); } - 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; - } + validateComposeArrayVolumeSource($source); } /** @@ -1419,7 +1390,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($source !== null && ! empty($source->value())) { validateComposeArrayVolumeSource($source->value()); } - validateComposeContentVolumeSource($volume, composeResourceDirectory($resource)); + validateComposeContentVolumeSource($volume); if ($target !== null && ! empty($target->value())) { try { validateShellSafePath($target->value(), 'volume target'); @@ -2791,7 +2762,7 @@ function serviceParser(Service $resource): Collection if ($source !== null && ! empty($source->value())) { validateComposeArrayVolumeSource($source->value()); } - validateComposeContentVolumeSource($volume, composeResourceDirectory($resource)); + validateComposeContentVolumeSource($volume); 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 0b81342c77..4beec10305 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -204,14 +204,11 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli ]); instant_remote_process($commands, $server); foreach ($fileVolumes as $fileVolume) { - $path = str(data_get($fileVolume, 'fs_path')); $content = data_get($fileVolume, 'content'); - if ($path->startsWith('.')) { - $path = $path->after('.'); - $fileLocation = $workdir.$path; - } else { - $fileLocation = $path; - } + // A relative path is inside the resource directory, never in the SSH user's working + // directory. A persisted Compose expression (`${VAR:-/path}`) stays as it is. + $fsPath = trim((string) $fileVolume->fs_path); + $fileLocation = str_starts_with($fsPath, '$') ? $fsPath : $fileVolume->resolvedFsPath($workdir)->value(); $escapedFileLocation = filesystemVolumeShellArgument((string) $fileLocation); // Exists and is a file $isFile = instant_remote_process(["test -f {$escapedFileLocation} && echo OK || echo NOK"], $server); @@ -237,16 +234,14 @@ 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. - // Content is written only inside the resource directory, as a literal path. - $escapedContentLocation = escapeshellarg($fileVolume->confinedContentPath((string) $fileLocation, $server)); + // Does not exists (no dir or file), not flagged as directory, is init, has content $fileVolume->content = $content; $fileVolume->is_directory = false; $fileVolume->save(); $content = base64_encode($content); instant_remote_process([ - 'mkdir -p -- "$(dirname -- '.$escapedContentLocation.')"', - "echo '$content' | base64 -d | tee -- {$escapedContentLocation}", + 'mkdir -p -- "$(dirname -- '.$escapedFileLocation.')"', + "echo '$content' | base64 -d | tee -- {$escapedFileLocation}", ], $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 7494787459..2e9fdbb253 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -2955,7 +2955,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()); + validateComposeContentVolumeSource($volume); $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 index 10b805764e..8dc867bf65 100644 --- a/tests/Feature/ComposeContentVolumeConfinementTest.php +++ b/tests/Feature/ComposeContentVolumeConfinementTest.php @@ -1,6 +1,5 @@ 0, 'is_api_enabled' => true]); config([ @@ -70,18 +88,24 @@ beforeEach(function () { $this->environment = Environment::factory()->create(['project_id' => $project->id]); }); -function contentConfinementService(string $compose): Service +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 contentConfinementApplication(string $compose): Application +function contentHostPathApplication(string $compose): Application { return Application::factory()->create([ 'environment_id' => test()->environment->id, @@ -96,266 +120,293 @@ function contentConfinementApplication(string $compose): Application ]); } -/** - * 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 +function contentHostPathUseServerUser(string $user): void { - $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(); + test()->server->update(['user' => $user]); + Server::flushIdentityMap(); } /** - * Fakes the server. The symlink check prints $confinement, `test -f`/`test -d` print NOK. + * Fakes the server: `test -f`/`test -d` print NOK (the file is missing), the symlink check prints OK. */ -function contentConfinementFakeServer(string $confinement = 'OK'): void +function contentHostPathFakeServer(): void { Process::fake(fn ($process) => Process::result(output: match (true) { - str_contains($process->command, 'readlink -f') => $confinement, + str_contains($process->command, 'readlink -f') => 'OK', str_contains($process->command, 'test -') => 'NOK', default => '', })); } -function contentConfinementAssertNoContentWrite(): void +/** + * 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 { - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'base64 -d')); - Process::assertDidntRun(fn ($process) => str_contains($process->command, 'touch ')); + $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'], + }; } -test('the service parser rejects a content volume outside the service directory', function () { - $service = contentConfinementService(CONTENT_CONFINEMENT_OUTSIDE_COMPOSE); +function contentHostPathAssertWritten(string $path, string $content, string $user): void +{ + $sudo = $user === 'root' ? '' : 'sudo '; + $escapedPath = escapeshellarg($path); + $escapedParent = escapeshellarg(dirname($path)); + $base64 = base64_encode($content); - expect(fn () => serviceParser($service)) - ->toThrow(Exception::class, 'Volume source /root/.ssh/authorized_keys with content must be inside the resource directory.'); + 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")); +} - expect(LocalFileVolume::query()->count())->toBe(0); - Bus::assertNotDispatched(ServerStorageSaveJob::class); -}); +/** + * @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 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); +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); - $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(); + [$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); - contentConfinementFakeServer(); + contentHostPathFakeServer(); $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")); -}); + contentHostPathAssertWritten($writePath, $content, $user); +})->with(contentHostPathTargets())->with(['root', 'coolify']); -test('a relative content volume is stored inside the application directory', function () { - $application = contentConfinementApplication(CONTENT_CONFINEMENT_RELATIVE_COMPOSE); +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); - expect(LocalFileVolume::query()->sole()->fs_path)->toBe($application->workdir().'/config/app.conf'); -}); + [$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); -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'); + contentHostPathFakeServer(); + $fileVolume->saveStorageOnServer(); - contentConfinementFakeServer(); + contentHostPathAssertWritten($writePath, $content, $user); +})->with(contentHostPathTargets())->with(['root', 'coolify']); - expect(fn () => $fileVolume->saveStorageOnServer()) - ->toThrow(RuntimeException::class, 'Coolify writes file content only inside the resource directory.'); - contentConfinementAssertNoContentWrite(); -}); +test('loading files for a service writes content at its host path', 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()); -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'); + contentHostPathFakeServer(); + getFilesystemVolumesFromServer($fileVolume->resource, true); - contentConfinementFakeServer(); + $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(contentHostPathTargets())->with(['root', 'coolify']); - expect(fn () => $fileVolume->saveStorageOnServer()) - ->toThrow(RuntimeException::class, 'Coolify writes file content only inside the resource directory.'); - contentConfinementAssertNoContentWrite(); -}); +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); -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;'); + contentHostPathFakeServer(); + $fileVolume->saveStorageOnServer(); + getFilesystemVolumesFromServer($fileVolume->resource, true); - // The server resolves the symlinked config directory to a path outside the resource directory. - contentConfinementFakeServer('NOK'); + $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}tee -- '/etc/my'\\''app/app.conf'")); + Process::assertRan(fn ($process) => str_contains($process->command, "{$sudo}mkdir -p '/etc/my'\\''app'")); +})->with(['root', 'coolify']); - expect(fn () => $fileVolume->saveStorageOnServer()) - ->toThrow(RuntimeException::class, 'Coolify writes file content only inside the resource directory.'); - contentConfinementAssertNoContentWrite(); -}); +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)'); -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')); + 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 = contentConfinementService($compose); - $fileVolume = contentConfinementFileVolume($service, '/srv/shared', null, isDirectory: true); + $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(); - contentConfinementFakeServer(); + 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('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 () { +test('the service stack form saves content volumes with host paths', function () { $this->actingAs($this->user); - $service = contentConfinementService(CONTENT_CONFINEMENT_SAFE_COMPOSE); + $service = contentHostPathService(CONTENT_HOST_PATH_SAFE_COMPOSE); + contentHostPathFakeServer(); Livewire::test(StackForm::class, ['service' => $service]) - ->set('dockerComposeRaw', CONTENT_CONFINEMENT_OUTSIDE_COMPOSE) + ->set('dockerComposeRaw', CONTENT_HOST_PATH_COMPOSE) ->call('submit') - ->assertDispatched('error', fn (string $event, array $params): bool => str_contains($params[0], 'with content must be inside the resource directory')); + ->assertNotDispatched('error'); - expect($service->fresh()->docker_compose_raw)->toBe(CONTENT_CONFINEMENT_SAFE_COMPOSE) + 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 rejects a content volume outside the application directory', function () { +test('the application General form saves content volumes with host paths', function () { $this->actingAs($this->user); - $application = contentConfinementApplication(CONTENT_CONFINEMENT_SAFE_COMPOSE); + $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_CONFINEMENT_OUTSIDE_COMPOSE) + ->set('dockerComposeRaw', CONTENT_HOST_PATH_INJECTION_COMPOSE) ->call('submit') - ->assertDispatched('error', fn (string $event, array $params): bool => str_contains($params[0], 'with content must be inside the resource directory')) + ->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_CONFINEMENT_SAFE_COMPOSE) + expect($application->fresh()->docker_compose_raw)->toBe(CONTENT_HOST_PATH_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) { +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') => CONTENT_CONFINEMENT_OUTSIDE_COMPOSE, + 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, 'with content must be inside the resource directory'); + ->toThrow(Exception::class, 'Invalid Docker volume definition'); - expect($application->fresh()->docker_compose_raw)->toBe(CONTENT_CONFINEMENT_SAFE_COMPOSE) + expect($application->fresh()->docker_compose_raw)->toBe(CONTENT_HOST_PATH_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); +function contentHostPathPatchService(Service $service, string $compose): TestResponse +{ $plainTextToken = Str::random(40); - $token = $this->user->tokens()->create([ - 'name' => 'content-confinement', + $token = test()->user->tokens()->create([ + 'name' => 'content-host-path', 'token' => hash('sha256', $plainTextToken), 'abilities' => ['*'], - 'team_id' => $this->team->id, + 'team_id' => test()->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.'); + return test()->withHeaders(['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]) + ->patchJson("/api/v1/services/{$service->uuid}", ['docker_compose_raw' => base64_encode($compose)]); +} - expect($service->fresh()->docker_compose_raw)->toBe(CONTENT_CONFINEMENT_SAFE_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); }); diff --git a/tests/Feature/ComposeParentBindPathTest.php b/tests/Feature/ComposeParentBindPathTest.php index ea28d49206..fa2636bdc9 100644 --- a/tests/Feature/ComposeParentBindPathTest.php +++ b/tests/Feature/ComposeParentBindPathTest.php @@ -229,7 +229,7 @@ describe('validation', function () { 'long syntax' => [parentBindCompose('../outside', true)], ]); - it('still rejects parent directory segments in content volumes', function () { + it('accepts parent directory segments in content volumes', function () { $compose = <<<'YAML' services: web: @@ -241,8 +241,9 @@ services: content: hello YAML; - expect(fn () => validateDockerComposeForInjection($compose, '/data/coolify/services/abc')) - ->toThrow(Exception::class, 'must be inside the resource directory'); + validateDockerComposeForInjection($compose); + + expect(true)->toBeTrue(); }); it('rejects shell metacharacters in a resolved parent directory path', function () { diff --git a/tests/Unit/ComposeContentVolumeSourceValidationTest.php b/tests/Unit/ComposeContentVolumeSourceValidationTest.php index fffb271af4..068ba0dc93 100644 --- a/tests/Unit/ComposeContentVolumeSourceValidationTest.php +++ b/tests/Unit/ComposeContentVolumeSourceValidationTest.php @@ -5,11 +5,10 @@ use App\Models\Service; use Symfony\Component\Yaml\Yaml; /** - * Coolify writes the `content:` of a Compose bind volume to the host. The validator makes sure - * that the file is inside the resource directory, for Services and Git-based Compose applications. + * Coolify writes the `content:` of a Compose bind volume to the host. Only administrators can edit + * a Compose file, and they can mount any host path, so the source can be any host path. It must + * still be safe to use in a shell command. */ -const CONTENT_VOLUME_RESOURCE_DIRECTORY = '/data/coolify/services/content-test-uuid'; - function contentVolumeCompose(string $source, array $extra = ['content' => "key=value\n"]): string { return Yaml::dump([ @@ -24,90 +23,45 @@ function contentVolumeCompose(string $source, array $extra = ['content' => "key= ], 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('accepts content volumes with any host path, like Coolify v4.3.23', function (string $source) { + validateDockerComposeForInjection(contentVolumeCompose($source)); -it('rejects content volumes outside the resource directory', function (string $source) { + expect(true)->toBeTrue(); +})->with([ + 'absolute host file' => ['/etc/myapp/app.conf'], + 'home directory' => ['~/x/app.conf'], + 'bare relative path' => ['app.conf'], + 'parent directory after ./' => ['./../shared/app.conf'], + 'parent directory' => ['../shared/app.conf'], + 'relative path' => ['./config/app.conf'], + 'braced variable with a path' => ['${DATA_DIR}/app.conf'], + 'single quote' => ["/etc/my'app/app.conf"], +]); + +it('rejects shell injection in a content volume source', 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]); + ->toThrow(Exception::class, 'Invalid Docker volume definition (array syntax)'); +})->with([ + 'command substitution' => ['/etc/$(id)/app.conf'], + 'backtick' => ['/etc/`id`/app.conf'], + 'command separator' => ['/etc/app.conf;id'], + 'pipe' => ['/etc/app.conf|id'], + 'background operator' => ['/etc/app.conf&id'], + 'redirect' => ['/etc/app.conf>/root/x'], + 'newline' => ["/etc/app.conf\nid"], + 'control character' => ["/etc/app\x01.conf"], + 'command substitution in a variable default' => ['${DATA:-/etc/$(id)}'], +]); 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.'); + ->toThrow(Exception::class, 'A bind volume with content needs a source path.'); }); it('does not change bind volumes without content', function (string $compose) { validateDockerComposeForInjection($compose); - validateDockerComposeForInjection($compose, CONTENT_VOLUME_RESOURCE_DIRECTORY); expect(true)->toBeTrue(); })->with([ @@ -155,38 +109,8 @@ function composeTemplatesWithContentVolumes(): array 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); +it('accepts every service template with content volumes', function (string $file) { + validateDockerComposeForInjection(file_get_contents($file)); - $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); + expect(true)->toBeTrue(); })->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(91); -});