mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-07 14:36:44 -04:00
fix(compose): keep content files inside the resource directory
- A Compose bind volume with a Coolify `content:` block could make Coolify write any host file (for example /root/.ssh/authorized_keys) when the file was loaded. Content sources must now be inside the resource directory (./ paths); other sources get a clear validation error for services and applications (UI, API, load, deployment), and every content write is confined again on the server. Bind mounts without content are unchanged. All 94 template content volumes use ./ paths and still work. - Quote container names in scheduled tasks and in the stop actions for applications, previews, and services. - Fix a development-only crash when a preview with a bind mount was parsed (the preview suffix returned a plain string). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ebfee2ec3f
commit
aabd127374
@@ -28,7 +28,7 @@ class StopApplication
|
||||
|
||||
if ($server->isSwarm()) {
|
||||
$containerPresent = false;
|
||||
instant_remote_process(["docker stack rm {$application->uuid}"], $server);
|
||||
instant_remote_process(['docker stack rm '.escapeshellarg($application->uuid)], $server);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -41,9 +41,10 @@ class StopApplication
|
||||
$timeout = $application->settings->stopGracePeriodSeconds();
|
||||
|
||||
foreach ($containersToStop as $containerName) {
|
||||
$commands = [dockerStopCommand($timeout, $containerName, $server)];
|
||||
$escapedContainerName = escapeshellarg($containerName);
|
||||
$commands = [dockerStopCommand($timeout, $escapedContainerName, $server)];
|
||||
if ($removeContainers) {
|
||||
$commands[] = "docker rm -f $containerName";
|
||||
$commands[] = "docker rm -f {$escapedContainerName}";
|
||||
}
|
||||
|
||||
instant_remote_process(command: $commands, server: $server, throwError: false);
|
||||
|
||||
@@ -28,7 +28,7 @@ class StopApplicationOneServer
|
||||
if ($containerName) {
|
||||
instant_remote_process(
|
||||
[
|
||||
dockerStopCommand($timeout, $containerName, $server),
|
||||
dockerStopCommand($timeout, escapeshellarg($containerName), $server),
|
||||
dockerRemoveCommand($containerName),
|
||||
],
|
||||
$server
|
||||
|
||||
@@ -17,9 +17,10 @@ class StopApplicationPreview
|
||||
$containers = getCurrentApplicationContainerStatus($server, $application->id, $preview->pull_request_id);
|
||||
|
||||
foreach ($containers->pluck('Names') as $containerName) {
|
||||
$commands = [dockerStopCommand($application->settings->stopGracePeriodSeconds(), $containerName, $server)];
|
||||
$escapedContainerName = escapeshellarg($containerName);
|
||||
$commands = [dockerStopCommand($application->settings->stopGracePeriodSeconds(), $escapedContainerName, $server)];
|
||||
if ($removeContainer) {
|
||||
$commands[] = "docker rm -f $containerName";
|
||||
$commands[] = "docker rm -f {$escapedContainerName}";
|
||||
}
|
||||
instant_remote_process($commands, $server, false);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ class StopService
|
||||
{
|
||||
$timeout = count($containersToStop) > 5 ? 10 : 30;
|
||||
$commands = [];
|
||||
$containerList = implode(' ', $containersToStop);
|
||||
$containerList = implode(' ', array_map('escapeshellarg', $containersToStop));
|
||||
$commands[] = dockerStopCommand($timeout, $containerList, $server);
|
||||
$commands[] = "docker rm -f $containerList";
|
||||
instant_remote_process(
|
||||
|
||||
@@ -1246,7 +1246,7 @@ class ServicesController extends Controller
|
||||
|
||||
// Validate for command injection BEFORE saving to database
|
||||
try {
|
||||
validateDockerComposeForInjection($dockerComposeRaw);
|
||||
validateDockerComposeForInjection($dockerComposeRaw, composeResourceDirectory($service));
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
|
||||
@@ -157,7 +157,7 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||
if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) {
|
||||
$cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'";
|
||||
$dockerCommand = $this->server->isNonRoot() ? 'sudo docker' : 'docker';
|
||||
$execCommand = "{$dockerCommand} exec {$containerName} {$cmd}";
|
||||
$execCommand = "{$dockerCommand} exec ".escapeshellarg($containerName)." {$cmd}";
|
||||
$exec = $this->boundedTaskCommand($execCommand);
|
||||
// Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently
|
||||
// See: https://github.com/coollabsio/coolify/issues/6736
|
||||
|
||||
@@ -347,7 +347,7 @@ class General extends Component
|
||||
}
|
||||
|
||||
try {
|
||||
validateDockerComposeForInjection($this->dockerComposeRaw);
|
||||
validateDockerComposeForInjection($this->dockerComposeRaw, composeResourceDirectory($this->application));
|
||||
} catch (Exception $e) {
|
||||
throw new Exception(e($e->getMessage()), 0, $e);
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ class StackForm extends Component
|
||||
$this->syncData(true);
|
||||
|
||||
// Validate for command injection BEFORE any database operations
|
||||
validateDockerComposeForInjection($this->service->docker_compose_raw);
|
||||
validateDockerComposeForInjection($this->service->docker_compose_raw, composeResourceDirectory($this->service));
|
||||
|
||||
// Use transaction to ensure atomicity - if parse fails, save is rolled back
|
||||
DB::transaction(function () {
|
||||
|
||||
@@ -2281,7 +2281,7 @@ class Application extends BaseModel
|
||||
}
|
||||
if ($composeFileContent) {
|
||||
try {
|
||||
validateDockerComposeForInjection($composeFileContent);
|
||||
validateDockerComposeForInjection($composeFileContent, composeResourceDirectory($this));
|
||||
} catch (\Exception $e) {
|
||||
$this->docker_compose_location = $initialDockerComposeLocation;
|
||||
$this->base_directory = $initialBaseDirectory;
|
||||
|
||||
@@ -283,7 +283,20 @@ class LocalFileVolume extends BaseModel
|
||||
}
|
||||
$path = data_get_str($this, 'fs_path');
|
||||
$content = data_get($this, 'content');
|
||||
$pathForParentDirectory = str($this->fs_path);
|
||||
$writesContent = $this->writesContentOnServer();
|
||||
if ($path->startsWith('.')) {
|
||||
$path = $path->after('.');
|
||||
$path = $workdir.$path;
|
||||
}
|
||||
|
||||
if ($writesContent) {
|
||||
$path = str($this->confinedContentPath($path->value(), $server));
|
||||
} elseif (! $this->isAdminControlledComposeMount()) {
|
||||
$path = str(confinePathToBase($workdir, $path->value(), 'storage path'));
|
||||
$this->assertRemotePathIsConfined($workdir, $path->value(), $server);
|
||||
}
|
||||
|
||||
$pathForParentDirectory = $writesContent ? $path : str($this->fs_path);
|
||||
if ($pathForParentDirectory->startsWith('.') || $pathForParentDirectory->startsWith('/') || $pathForParentDirectory->startsWith('~')) {
|
||||
$parent_dir = $pathForParentDirectory->beforeLast('/');
|
||||
if ($parent_dir != '') {
|
||||
@@ -291,15 +304,6 @@ class LocalFileVolume extends BaseModel
|
||||
$commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true");
|
||||
}
|
||||
}
|
||||
if ($path->startsWith('.')) {
|
||||
$path = $path->after('.');
|
||||
$path = $workdir.$path;
|
||||
}
|
||||
|
||||
if (! $this->isAdminControlledComposeMount()) {
|
||||
$path = str(confinePathToBase($workdir, $path->value(), 'storage path'));
|
||||
$this->assertRemotePathIsConfined($workdir, $path->value(), $server);
|
||||
}
|
||||
|
||||
// Validate and escape resolved path (may differ from fs_path if relative)
|
||||
validateShellSafePath($path, 'storage path');
|
||||
@@ -373,6 +377,81 @@ 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
|
||||
{
|
||||
$error = 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."
|
||||
);
|
||||
|
||||
foreach ($this->contentBaseDirectories() as $baseDirectory) {
|
||||
try {
|
||||
$confinedPath = confinePathToBase($baseDirectory, $path, 'storage path');
|
||||
} catch (\Exception) {
|
||||
continue;
|
||||
}
|
||||
if ($confinedPath === normalizeUnixPath($baseDirectory)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
self::assertRemotePathIsConfined($baseDirectory, $confinedPath, $server);
|
||||
} catch (\RuntimeException) {
|
||||
throw $error;
|
||||
}
|
||||
|
||||
return $confinedPath;
|
||||
}
|
||||
|
||||
throw $error;
|
||||
}
|
||||
|
||||
/**
|
||||
* The resource workdir, and the directory where the Compose parser resolves `./` sources. They
|
||||
* differ only for services that use parser version 3.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function contentBaseDirectories(): array
|
||||
{
|
||||
return array_values(array_unique([$this->ownerResource()->workdir(), $this->composeSourceDirectory()]));
|
||||
}
|
||||
|
||||
protected function composeSourceDirectory(): string
|
||||
{
|
||||
$owner = $this->ownerResource();
|
||||
|
||||
return $owner instanceof Application || $owner instanceof Service
|
||||
? composeResourceDirectory($owner)
|
||||
: $owner->workdir();
|
||||
}
|
||||
|
||||
/**
|
||||
* The Application, Service or standalone database that owns the storage directory.
|
||||
*/
|
||||
protected function ownerResource(): mixed
|
||||
{
|
||||
$resource = $this->resource;
|
||||
|
||||
return $resource instanceof ServiceApplication || $resource instanceof ServiceDatabase
|
||||
? $resource->service
|
||||
: $resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw Compose bind mounts keep administrator-selected host path semantics.
|
||||
*/
|
||||
@@ -402,7 +481,7 @@ class LocalFileVolume extends BaseModel
|
||||
continue;
|
||||
}
|
||||
|
||||
$resolvedSource = replaceLocalSource(str((string) $source), str($this->resource->workdir()));
|
||||
$resolvedSource = replaceLocalSource(str((string) $source), str($this->composeSourceDirectory()));
|
||||
if (normalizeUnixPath($resolvedSource->value()) === normalizeUnixPath($this->fs_path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -22,10 +22,11 @@ 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): void
|
||||
function validateDockerComposeForInjection(string $composeYaml, ?string $resourceDirectory = null): void
|
||||
{
|
||||
try {
|
||||
$parsed = Yaml::parse($composeYaml);
|
||||
@@ -78,6 +79,7 @@ function validateDockerComposeForInjection(string $composeYaml): void
|
||||
}
|
||||
}
|
||||
}
|
||||
validateComposeContentVolumeSource($volume, $resourceDirectory);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +107,71 @@ function validateDockerComposeForInjection(string $composeYaml): void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory that the Compose parsers resolve `./` bind sources in. Services that use parser
|
||||
* version 3 resolve them in the applications directory.
|
||||
*/
|
||||
function composeResourceDirectory(Application|Service $resource): string
|
||||
{
|
||||
if ($resource instanceof Service && (int) $resource->compose_parsing_version === 3) {
|
||||
return application_configuration_dir().'/'.$resource->uuid;
|
||||
}
|
||||
|
||||
return $resource->workdir();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param array<string, mixed> $volume A long-syntax Compose volume
|
||||
*
|
||||
* @throws Exception If Coolify would write the content outside the resource directory
|
||||
*/
|
||||
function validateComposeContentVolumeSource(array $volume, ?string $resourceDirectory = null): 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 (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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the existing array-source forms, but inspect the default that was previously skipped.
|
||||
*/
|
||||
@@ -982,6 +1049,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
if ($source !== null && ! empty($source->value())) {
|
||||
validateComposeArrayVolumeSource($source->value());
|
||||
}
|
||||
validateComposeContentVolumeSource($volume, composeResourceDirectory($resource));
|
||||
if ($target !== null && ! empty($target->value())) {
|
||||
try {
|
||||
validateShellSafePath($target->value(), 'volume target');
|
||||
@@ -1026,7 +1094,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
? (bool) data_get($foundConfig, 'is_preview_suffix_enabled', true)
|
||||
: true;
|
||||
if ($isPullRequest && $isPreviewSuffixEnabled) {
|
||||
$source = addPreviewDeploymentSuffix($source, $pull_request_id);
|
||||
$source = str(addPreviewDeploymentSuffix($source, $pull_request_id));
|
||||
}
|
||||
LocalFileVolume::updateOrCreate(
|
||||
[
|
||||
@@ -2341,6 +2409,7 @@ function serviceParser(Service $resource): Collection
|
||||
if ($source !== null && ! empty($source->value())) {
|
||||
validateComposeArrayVolumeSource($source->value());
|
||||
}
|
||||
validateComposeContentVolumeSource($volume, composeResourceDirectory($resource));
|
||||
if ($target !== null && ! empty($target->value())) {
|
||||
try {
|
||||
validateShellSafePath($target->value(), 'volume target');
|
||||
|
||||
@@ -230,14 +230,16 @@ 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
|
||||
// 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));
|
||||
$fileVolume->content = $content;
|
||||
$fileVolume->is_directory = false;
|
||||
$fileVolume->save();
|
||||
$content = base64_encode($content);
|
||||
instant_remote_process([
|
||||
'mkdir -p -- "$(dirname -- '.$escapedFileLocation.')"',
|
||||
"echo '$content' | base64 -d | tee -- {$escapedFileLocation}",
|
||||
'mkdir -p -- "$(dirname -- '.$escapedContentLocation.')"',
|
||||
"echo '$content' | base64 -d | tee -- {$escapedContentLocation}",
|
||||
], $server);
|
||||
} elseif ($isFile === 'NOK' && $isDir === 'NOK' && $fileVolume->is_directory && $isInit) {
|
||||
// Does not exists (no dir or file), flagged as directory, is init
|
||||
|
||||
@@ -2799,6 +2799,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());
|
||||
$foundConfig = $savedService->fileStorages()->whereMountPath($target)->first();
|
||||
if ($foundConfig) {
|
||||
$contentNotNull = data_get($foundConfig, 'content');
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ServerStorageSaveJob;
|
||||
use App\Livewire\Project\Application\General;
|
||||
use App\Livewire\Project\Service\StackForm;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
const CONTENT_CONFINEMENT_SAFE_COMPOSE = "services:\n app:\n image: nginx:alpine\n";
|
||||
|
||||
const CONTENT_CONFINEMENT_OUTSIDE_COMPOSE = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx:alpine
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /root/.ssh/authorized_keys
|
||||
target: /keys
|
||||
content: ssh-ed25519 AAAA attacker
|
||||
YAML;
|
||||
|
||||
const CONTENT_CONFINEMENT_RELATIVE_COMPOSE = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx:alpine
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./config/app.conf
|
||||
target: /etc/app.conf
|
||||
content: |
|
||||
listen 8080;
|
||||
YAML;
|
||||
|
||||
beforeEach(function () {
|
||||
Bus::fake();
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
config([
|
||||
'app.maintenance.store' => 'array',
|
||||
'constants.ssh.mux_enabled' => false,
|
||||
]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
|
||||
]);
|
||||
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
});
|
||||
|
||||
function contentConfinementService(string $compose): Service
|
||||
{
|
||||
return Service::factory()->create([
|
||||
'environment_id' => test()->environment->id,
|
||||
'server_id' => test()->server->id,
|
||||
'destination_id' => test()->destination->id,
|
||||
'destination_type' => test()->destination->getMorphClass(),
|
||||
'docker_compose_raw' => $compose,
|
||||
]);
|
||||
}
|
||||
|
||||
function contentConfinementApplication(string $compose): Application
|
||||
{
|
||||
return Application::factory()->create([
|
||||
'environment_id' => test()->environment->id,
|
||||
'destination_id' => test()->destination->id,
|
||||
'destination_type' => test()->destination->getMorphClass(),
|
||||
'build_pack' => 'dockercompose',
|
||||
'git_repository' => 'https://github.com/coollabsio/compose-app',
|
||||
'git_branch' => 'main',
|
||||
'base_directory' => '/',
|
||||
'docker_compose_location' => '/docker-compose.yml',
|
||||
'docker_compose_raw' => $compose,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fakes the server. The symlink check prints $confinement, `test -f`/`test -d` print NOK.
|
||||
*/
|
||||
function contentConfinementFakeServer(string $confinement = 'OK'): void
|
||||
{
|
||||
Process::fake(fn ($process) => Process::result(output: match (true) {
|
||||
str_contains($process->command, 'readlink -f') => $confinement,
|
||||
str_contains($process->command, 'test -') => 'NOK',
|
||||
default => '',
|
||||
}));
|
||||
}
|
||||
|
||||
function contentConfinementAssertNoContentWrite(): void
|
||||
{
|
||||
Process::assertDidntRun(fn ($process) => str_contains($process->command, 'base64 -d'));
|
||||
Process::assertDidntRun(fn ($process) => str_contains($process->command, 'touch '));
|
||||
}
|
||||
|
||||
test('the service parser rejects a content volume outside the service directory', function () {
|
||||
$service = contentConfinementService(CONTENT_CONFINEMENT_OUTSIDE_COMPOSE);
|
||||
|
||||
expect(fn () => serviceParser($service))
|
||||
->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 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);
|
||||
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();
|
||||
|
||||
contentConfinementFakeServer();
|
||||
$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"));
|
||||
});
|
||||
|
||||
test('a relative content volume is stored inside the application directory', function () {
|
||||
$application = contentConfinementApplication(CONTENT_CONFINEMENT_RELATIVE_COMPOSE);
|
||||
applicationParser($application);
|
||||
|
||||
expect(LocalFileVolume::query()->sole()->fs_path)->toBe($application->workdir().'/config/app.conf');
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
contentConfinementFakeServer();
|
||||
|
||||
expect(fn () => $fileVolume->saveStorageOnServer())
|
||||
->toThrow(RuntimeException::class, 'Coolify writes file content only inside the resource directory.');
|
||||
contentConfinementAssertNoContentWrite();
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
contentConfinementFakeServer();
|
||||
|
||||
expect(fn () => $fileVolume->saveStorageOnServer())
|
||||
->toThrow(RuntimeException::class, 'Coolify writes file content only inside the resource directory.');
|
||||
contentConfinementAssertNoContentWrite();
|
||||
});
|
||||
|
||||
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;');
|
||||
|
||||
// The server resolves the symlinked config directory to a path outside the resource directory.
|
||||
contentConfinementFakeServer('NOK');
|
||||
|
||||
expect(fn () => $fileVolume->saveStorageOnServer())
|
||||
->toThrow(RuntimeException::class, 'Coolify writes file content only inside the resource directory.');
|
||||
contentConfinementAssertNoContentWrite();
|
||||
});
|
||||
|
||||
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'));
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
contentConfinementFakeServer();
|
||||
$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 () {
|
||||
$this->actingAs($this->user);
|
||||
$service = contentConfinementService(CONTENT_CONFINEMENT_SAFE_COMPOSE);
|
||||
|
||||
Livewire::test(StackForm::class, ['service' => $service])
|
||||
->set('dockerComposeRaw', CONTENT_CONFINEMENT_OUTSIDE_COMPOSE)
|
||||
->call('submit')
|
||||
->assertDispatched('error', fn (string $event, array $params): bool => str_contains($params[0], 'with content must be inside the resource directory'));
|
||||
|
||||
expect($service->fresh()->docker_compose_raw)->toBe(CONTENT_CONFINEMENT_SAFE_COMPOSE)
|
||||
->and(LocalFileVolume::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('the application General form rejects a content volume outside the application directory', function () {
|
||||
$this->actingAs($this->user);
|
||||
$application = contentConfinementApplication(CONTENT_CONFINEMENT_SAFE_COMPOSE);
|
||||
Process::fake();
|
||||
|
||||
Livewire::test(General::class, ['application' => $application->fresh()])
|
||||
->set('dockerComposeRaw', CONTENT_CONFINEMENT_OUTSIDE_COMPOSE)
|
||||
->call('submit')
|
||||
->assertDispatched('error', fn (string $event, array $params): bool => str_contains($params[0], 'with content must be inside the resource directory'))
|
||||
->assertNotDispatched('success');
|
||||
|
||||
expect($application->fresh()->docker_compose_raw)->toBe(CONTENT_CONFINEMENT_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) {
|
||||
$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,
|
||||
default => '',
|
||||
});
|
||||
});
|
||||
|
||||
expect(fn () => $application->loadComposeFile())
|
||||
->toThrow(Exception::class, 'with content must be inside the resource directory');
|
||||
|
||||
expect($application->fresh()->docker_compose_raw)->toBe(CONTENT_CONFINEMENT_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);
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $this->user->tokens()->create([
|
||||
'name' => 'content-confinement',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => ['*'],
|
||||
'team_id' => $this->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.');
|
||||
|
||||
expect($service->fresh()->docker_compose_raw)->toBe(CONTENT_CONFINEMENT_SAFE_COMPOSE)
|
||||
->and(LocalFileVolume::query()->count())->toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Actions\Application\StopApplicationOneServer;
|
||||
use App\Actions\Application\StopApplicationPreview;
|
||||
use App\Actions\Service\RestartServiceApplication;
|
||||
use App\Actions\Service\StopService;
|
||||
use App\Actions\Service\StopServiceApplication;
|
||||
use App\Events\ScheduledTaskDone;
|
||||
use App\Events\ServiceStatusChanged;
|
||||
use App\Jobs\ScheduledTaskJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Container names come from the database (service names) or from `docker ps`. Coolify quotes them
|
||||
* in shell commands, also when the non-root sudo parser adds `sudo`.
|
||||
*/
|
||||
const QUOTING_HOSTILE_NAME = 'app;touch /tmp/pwned';
|
||||
|
||||
beforeEach(function () {
|
||||
// StandaloneDocker::server() uses the Server identity map; a map from an earlier test has a stale SSH user.
|
||||
Server::flushIdentityMap();
|
||||
Bus::fake();
|
||||
Event::fake([ServiceStatusChanged::class, ScheduledTaskDone::class]);
|
||||
Notification::fake();
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
config([
|
||||
'app.maintenance.store' => 'array',
|
||||
'constants.ssh.mux_enabled' => false,
|
||||
]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
|
||||
]);
|
||||
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false, 'docker_version' => '28.0.0']);
|
||||
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->service = Service::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'docker_compose_raw' => "services:\n app:\n image: nginx\n",
|
||||
]);
|
||||
$this->serviceApplication = ServiceApplication::create([
|
||||
'name' => QUOTING_HOSTILE_NAME,
|
||||
'service_id' => $this->service->id,
|
||||
'status' => 'running:healthy',
|
||||
]);
|
||||
$this->containerName = escapeshellarg(QUOTING_HOSTILE_NAME.'-'.$this->service->uuid);
|
||||
|
||||
Process::fake(fn () => Process::result(output: 'done'));
|
||||
});
|
||||
|
||||
function quotingUseNonRootUser(): void
|
||||
{
|
||||
test()->server->update(['user' => 'ubuntu']);
|
||||
test()->server->refresh();
|
||||
test()->service->refresh();
|
||||
test()->serviceApplication->refresh();
|
||||
}
|
||||
|
||||
function quotingAssertCommandRan(string $command): void
|
||||
{
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, $command));
|
||||
}
|
||||
|
||||
function quotingAssertNoUnquotedName(): void
|
||||
{
|
||||
Process::assertDidntRun(fn ($process) => str_contains($process->command, ' app;touch'));
|
||||
}
|
||||
|
||||
test('stopping a service application quotes its container name', function (bool $nonRoot, string $sudo) {
|
||||
if ($nonRoot) {
|
||||
quotingUseNonRootUser();
|
||||
}
|
||||
|
||||
StopServiceApplication::run($this->serviceApplication);
|
||||
StopServiceApplication::run($this->serviceApplication->refresh(), removeContainer: true);
|
||||
|
||||
quotingAssertCommandRan("\n{$sudo}docker stop {$this->containerName}\n");
|
||||
quotingAssertCommandRan("\n{$sudo}docker rm -f {$this->containerName}\n");
|
||||
quotingAssertNoUnquotedName();
|
||||
})->with(['root' => [false, ''], 'non-root' => [true, 'sudo ']]);
|
||||
|
||||
test('restarting a service application quotes its container name', function (bool $nonRoot, string $sudo) {
|
||||
if ($nonRoot) {
|
||||
quotingUseNonRootUser();
|
||||
}
|
||||
|
||||
RestartServiceApplication::run($this->serviceApplication);
|
||||
|
||||
quotingAssertCommandRan("\n{$sudo}docker restart {$this->containerName}\n");
|
||||
quotingAssertNoUnquotedName();
|
||||
})->with(['root' => [false, ''], 'non-root' => [true, 'sudo ']]);
|
||||
|
||||
test('stopping a service quotes every container name', function (bool $nonRoot, string $sudo) {
|
||||
if ($nonRoot) {
|
||||
quotingUseNonRootUser();
|
||||
}
|
||||
ServiceDatabase::create(['name' => 'db', 'service_id' => $this->service->id]);
|
||||
$databaseContainer = escapeshellarg('db-'.$this->service->uuid);
|
||||
|
||||
StopService::run($this->service, dockerCleanup: false);
|
||||
|
||||
quotingAssertCommandRan("{$sudo}docker stop --timeout=30 {$this->containerName} {$databaseContainer}\n");
|
||||
quotingAssertCommandRan("\n{$sudo}docker rm -f {$this->containerName} {$databaseContainer}\n");
|
||||
quotingAssertNoUnquotedName();
|
||||
})->with(['root' => [false, ''], 'non-root' => [true, 'sudo ']]);
|
||||
|
||||
test('a scheduled task in a service quotes the container name', function (bool $nonRoot, string $sudo) {
|
||||
if ($nonRoot) {
|
||||
quotingUseNonRootUser();
|
||||
}
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'service_id' => $this->service->id,
|
||||
'container' => QUOTING_HOSTILE_NAME,
|
||||
'command' => "echo 'hello'",
|
||||
]);
|
||||
|
||||
(new ScheduledTaskJob($task))->handle();
|
||||
|
||||
quotingAssertCommandRan("{$sudo}docker exec {$this->containerName} sh -c 'echo '\\''hello'\\''' 2>&1 |");
|
||||
quotingAssertNoUnquotedName();
|
||||
expect($task->executions()->sole()->status)->toBe('success');
|
||||
})->with(['root' => [false, ''], 'non-root' => [true, 'sudo ']]);
|
||||
|
||||
/**
|
||||
* `docker ps` returns one container for the application.
|
||||
*/
|
||||
function quotingFakeApplicationContainer(string $name): void
|
||||
{
|
||||
Process::fake(function ($process) use ($name) {
|
||||
if (str_contains($process->command, 'docker ps -a --filter')) {
|
||||
return Process::result(output: json_encode(['Names' => $name, 'Labels' => 'coolify.applicationId=1', 'State' => 'running']));
|
||||
}
|
||||
|
||||
return Process::result(output: '');
|
||||
});
|
||||
}
|
||||
|
||||
function quotingApplication(): Application
|
||||
{
|
||||
return Application::factory()->create([
|
||||
'environment_id' => test()->environment->id,
|
||||
'destination_id' => test()->destination->id,
|
||||
'destination_type' => test()->destination->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
test('stopping an application quotes the container names from docker ps', function (bool $nonRoot, string $sudo) {
|
||||
if ($nonRoot) {
|
||||
quotingUseNonRootUser();
|
||||
}
|
||||
$application = quotingApplication();
|
||||
quotingFakeApplicationContainer(QUOTING_HOSTILE_NAME);
|
||||
$quoted = escapeshellarg(QUOTING_HOSTILE_NAME);
|
||||
|
||||
StopApplication::run($application, dockerCleanup: false);
|
||||
|
||||
quotingAssertCommandRan("{$sudo}docker stop --timeout=");
|
||||
Process::assertRan(fn ($process) => preg_match('/docker stop --timeout=\d+ '.preg_quote($quoted, '/').'\n/', $process->command) === 1);
|
||||
quotingAssertCommandRan("\n{$sudo}docker rm -f {$quoted}\n");
|
||||
quotingAssertNoUnquotedName();
|
||||
})->with(['root' => [false, ''], 'non-root' => [true, 'sudo ']]);
|
||||
|
||||
test('stopping an application preview quotes the container names from docker ps', function () {
|
||||
$application = quotingApplication();
|
||||
$preview = ApplicationPreview::forceCreate([
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => 1,
|
||||
'pull_request_html_url' => 'https://example.com/pull/1',
|
||||
]);
|
||||
Process::fake(function ($process) {
|
||||
if (str_contains($process->command, 'docker ps -a --filter')) {
|
||||
return Process::result(output: json_encode(['Names' => QUOTING_HOSTILE_NAME, 'Labels' => 'coolify.applicationId=1,coolify.pullRequestId=1']));
|
||||
}
|
||||
|
||||
return Process::result(output: '');
|
||||
});
|
||||
$quoted = escapeshellarg(QUOTING_HOSTILE_NAME);
|
||||
|
||||
StopApplicationPreview::run($preview);
|
||||
|
||||
Process::assertRan(fn ($process) => preg_match('/docker stop --timeout=\d+ '.preg_quote($quoted, '/').'\n/', $process->command) === 1);
|
||||
quotingAssertCommandRan("\ndocker rm -f {$quoted}\n");
|
||||
quotingAssertNoUnquotedName();
|
||||
});
|
||||
|
||||
test('stopping an application on one server quotes the container names from docker ps', function () {
|
||||
$application = quotingApplication();
|
||||
quotingFakeApplicationContainer(QUOTING_HOSTILE_NAME);
|
||||
$quoted = escapeshellarg(QUOTING_HOSTILE_NAME);
|
||||
|
||||
StopApplicationOneServer::run($application, $this->server);
|
||||
|
||||
Process::assertRan(fn ($process) => preg_match('/docker stop --timeout=\d+ '.preg_quote($quoted, '/').'\n/', $process->command) === 1);
|
||||
quotingAssertNoUnquotedName();
|
||||
});
|
||||
|
||||
test('the non-root sudo parser keeps quoted container names as one argument', function () {
|
||||
$server = new Server(['user' => 'ubuntu']);
|
||||
$name = escapeshellarg(QUOTING_HOSTILE_NAME.'-uuid');
|
||||
|
||||
expect(parseCommandsByLineForSudo(collect([
|
||||
"docker stop {$name}",
|
||||
"docker restart {$name}",
|
||||
"docker rm -f {$name} 'db-uuid'",
|
||||
dockerStopCommand(30, "{$name} 'db-uuid'", '28.0.0'),
|
||||
]), $server))->toBe([
|
||||
"sudo docker stop {$name}",
|
||||
"sudo docker restart {$name}",
|
||||
"sudo docker rm -f {$name} 'db-uuid'",
|
||||
"sudo docker stop --timeout=30 {$name} 'db-uuid'",
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
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.
|
||||
*/
|
||||
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([
|
||||
'services' => [
|
||||
'app' => [
|
||||
'image' => 'nginx:alpine',
|
||||
'volumes' => [
|
||||
array_merge(['type' => 'bind', 'source' => $source, 'target' => '/etc/app.conf'], $extra),
|
||||
],
|
||||
],
|
||||
],
|
||||
], 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('rejects content volumes outside the resource directory', 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]);
|
||||
|
||||
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.');
|
||||
});
|
||||
|
||||
it('does not change bind volumes without content', function (string $compose) {
|
||||
validateDockerComposeForInjection($compose);
|
||||
validateDockerComposeForInjection($compose, CONTENT_VOLUME_RESOURCE_DIRECTORY);
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
})->with([
|
||||
'docker socket short syntax' => ["services:\n app:\n image: nginx\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n"],
|
||||
'docker socket long syntax' => ["services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: /var/run/docker.sock\n target: /var/run/docker.sock\n"],
|
||||
'absolute host directory' => ["services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: /srv/shared\n target: /shared\n is_directory: true\n"],
|
||||
'home directory' => ["services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: ~/booklore\n target: /bookdrop\n is_directory: true\n"],
|
||||
'variable with default' => ["services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: \${FOUNDRY_DATA:-/data/foundryvtt}\n target: /data\n is_directory: true\n"],
|
||||
'parent directory short syntax' => ["services:\n app:\n image: nginx\n volumes:\n - ../shared:/shared\n"],
|
||||
]);
|
||||
|
||||
it('ignores content on named volumes because Coolify does not write it', function () {
|
||||
validateDockerComposeForInjection(contentVolumeCompose('app-data', ['type' => 'volume', 'content' => 'x']));
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
it('resolves the directory the parsers use for each resource', function () {
|
||||
$service = new Service;
|
||||
$service->uuid = 'service-uuid';
|
||||
$service->compose_parsing_version = '5';
|
||||
$legacyService = new Service;
|
||||
$legacyService->uuid = 'legacy-uuid';
|
||||
$legacyService->compose_parsing_version = '3';
|
||||
$application = new Application;
|
||||
$application->uuid = 'application-uuid';
|
||||
|
||||
expect(composeResourceDirectory($service))->toBe('/data/coolify/services/service-uuid')
|
||||
->and(composeResourceDirectory($legacyService))->toBe('/data/coolify/applications/legacy-uuid')
|
||||
->and(composeResourceDirectory($application))->toBe('/data/coolify/applications/application-uuid');
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
function composeTemplatesWithContentVolumes(): array
|
||||
{
|
||||
$templates = [];
|
||||
foreach (glob(dirname(__DIR__, 2).'/templates/compose/*.{yaml,yml}', GLOB_BRACE) as $file) {
|
||||
if (preg_match('/^\s+content:/m', file_get_contents($file))) {
|
||||
$templates[basename($file)] = [$file];
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
$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);
|
||||
})->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(94);
|
||||
});
|
||||
@@ -181,7 +181,7 @@ it('quotes literal file-storage paths and safely expands persisted expressions',
|
||||
$literal->is_directory = true;
|
||||
$literal->shouldReceive('save')->once();
|
||||
$file = Mockery::mock(LocalFileVolume::class)->makePartial();
|
||||
$file->fs_path = '/data/my files/settings.json';
|
||||
$file->fs_path = '/data/application/my files/settings.json';
|
||||
$file->content = '{}';
|
||||
$file->is_directory = false;
|
||||
$file->shouldReceive('save')->once();
|
||||
@@ -195,17 +195,22 @@ it('quotes literal file-storage paths and safely expands persisted expressions',
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->shouldReceive('getMorphClass')->andReturn(Application::class);
|
||||
$application->shouldReceive('workdir')->once()->andReturn('/data/application');
|
||||
$application->shouldReceive('workdir')->andReturn('/data/application');
|
||||
$application->shouldReceive('fileStorages')->once()->andReturn($fileStorages);
|
||||
$application->setRelation('destination', (object) ['server' => $server]);
|
||||
$file->setRelation('resource', $application);
|
||||
|
||||
Process::fake(fn ($process) => Process::result(output: str_contains($process->command, 'test -') ? 'NOK' : ''));
|
||||
Process::fake(fn ($process) => Process::result(output: match (true) {
|
||||
str_contains($process->command, 'readlink -f') => 'OK',
|
||||
str_contains($process->command, 'test -') => 'NOK',
|
||||
default => '',
|
||||
}));
|
||||
getFilesystemVolumesFromServer($application, true);
|
||||
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "test -f '/data/my files/config.yaml'"));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p -- '/data/my files/config.yaml'"));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "dirname -- '/data/my files/settings.json'"));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "tee -- '/data/my files/settings.json'"));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "dirname -- '/data/application/my files/settings.json'"));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "tee -- '/data/application/my files/settings.json'"));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, '${DATA_PATH:-/srv/app/config.yaml}'));
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user