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); -});