mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Merge remote-tracking branch 'origin/main' into next
This commit is contained in:
@@ -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.',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user