Merge remote-tracking branch 'origin/main' into next

This commit is contained in:
github-actions[bot]
2026-10-06 09:25:55 +00:00
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
@@ -371,7 +371,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
@@ -2352,7 +2352,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);
});