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
This commit is contained in:
Andras Bacsai
2026-10-06 11:03:39 +02:00
parent 06b5a708b8
commit 116806df1b
11 changed files with 310 additions and 440 deletions
@@ -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.',
+1 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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 () {
+1 -1
View File
@@ -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;
+7 -79
View File
@@ -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.
+13 -42
View File
@@ -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<string, mixed> $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');
+7 -12
View File
@@ -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
+1 -1
View File
@@ -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');
@@ -1,6 +1,5 @@
<?php
use App\Jobs\ServerStorageSaveJob;
use App\Livewire\Project\Application\General;
use App\Livewire\Project\Service\StackForm;
use App\Models\Application;
@@ -19,36 +18,55 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use Illuminate\Testing\TestResponse;
use Livewire\Livewire;
/**
* Only administrators can edit a Compose file, and they can mount any host path. Coolify writes the
* `content:` of a Compose bind volume to the source path, like Coolify v4.3.23 did, also outside
* the resource directory. Every path in a remote command is shell-escaped, and the source must not
* contain shell metacharacters.
*/
uses(RefreshDatabase::class);
const CONTENT_CONFINEMENT_SAFE_COMPOSE = "services:\n app:\n image: nginx:alpine\n";
const CONTENT_HOST_PATH_SAFE_COMPOSE = "services:\n app:\n image: nginx:alpine\n";
const CONTENT_CONFINEMENT_OUTSIDE_COMPOSE = <<<'YAML'
const CONTENT_HOST_PATH_COMPOSE = <<<'YAML'
services:
app:
image: nginx:alpine
volumes:
- type: bind
source: /root/.ssh/authorized_keys
target: /keys
content: ssh-ed25519 AAAA attacker
source: /etc/myapp/app.conf
target: /etc/a.conf
content: a
- type: bind
source: ~/x/app.conf
target: /etc/b.conf
content: b
- type: bind
source: app.conf
target: /etc/c.conf
content: c
- type: bind
source: ./../shared/app.conf
target: /etc/d.conf
content: d
YAML;
const CONTENT_CONFINEMENT_RELATIVE_COMPOSE = <<<'YAML'
const CONTENT_HOST_PATH_INJECTION_COMPOSE = <<<'YAML'
services:
app:
image: nginx:alpine
volumes:
- type: bind
source: ./config/app.conf
source: /etc/$(id)/app.conf
target: /etc/app.conf
content: |
listen 8080;
content: x
YAML;
beforeEach(function () {
Server::flushIdentityMap();
Bus::fake();
InstanceSettings::forceCreate(['id' => 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<string, array{string, string}>
*/
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);
});
+4 -3
View File
@@ -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 () {
@@ -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<string, array{string}>
*/
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);
});