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

This commit is contained in:
github-actions[bot]
2026-10-06 09:25:55 +00:00
11 changed files with 310 additions and 440 deletions
+13 -42
View File
@@ -22,11 +22,10 @@ use Symfony\Component\Yaml\Yaml;
* This should be called BEFORE saving to database to prevent malicious data from being stored.
*
* @param string $composeYaml The raw Docker Compose YAML content
* @param string|null $resourceDirectory The resource directory, if the resource exists (see validateComposeContentVolumeSource())
*
* @throws Exception If the compose file contains command injection attempts
*/
function validateDockerComposeForInjection(string $composeYaml, ?string $resourceDirectory = null): void
function validateDockerComposeForInjection(string $composeYaml): void
{
try {
$parsed = Yaml::parse($composeYaml);
@@ -79,7 +78,7 @@ function validateDockerComposeForInjection(string $composeYaml, ?string $resourc
}
}
}
validateComposeContentVolumeSource($volume, $resourceDirectory);
validateComposeContentVolumeSource($volume);
}
}
}
@@ -130,55 +129,27 @@ function composeResourceDirectory(Application|Service $resource): string
}
/**
* Coolify writes the `content:` of a Compose bind volume to the host, so that file must be inside
* the resource directory. The source must be a `./` path that stays inside the resource directory,
* or an absolute path inside $resourceDirectory. Sources with `~`, `..` or variables are rejected,
* because their host path is not known before the write. Bind volumes without `content:` are not
* changed: an administrator can mount any host path.
* Coolify writes the `content:` of a Compose bind volume to the source path on the host. Only
* administrators can edit a Compose file, and they can mount any host path, so the source can be
* any path, also outside the resource directory. It must exist and be safe to
* use in a shell command; Coolify escapes it in every remote command.
*
* @param array<string, mixed> $volume A long-syntax Compose volume
*
* @throws Exception If Coolify would write the content outside the resource directory
* @throws Exception If the source is missing or contains shell metacharacters
*/
function validateComposeContentVolumeSource(array $volume, ?string $resourceDirectory = null): void
function validateComposeContentVolumeSource(array $volume): void
{
if (! array_key_exists('content', $volume) || ($volume['type'] ?? null) !== 'bind') {
return;
}
$source = $volume['source'] ?? null;
$displaySource = is_scalar($source) && (string) $source !== '' ? (string) $source : '(empty)';
$error = new Exception(
"Volume source {$displaySource} with content must be inside the resource directory. Use a relative path such as ./config/app.conf."
);
if (! is_string($source) || str_contains($source, '$') || str_contains($source, '\\')) {
throw $error;
}
if (in_array('..', explode('/', $source), true)) {
throw $error;
if (! is_string($source) || trim($source) === '') {
throw new Exception('Invalid Docker volume definition (array syntax): A bind volume with content needs a source path.');
}
if (str_starts_with($source, './')) {
$baseDirectory = $resourceDirectory ?? '/coolify-resource-directory';
$path = $baseDirectory.'/'.substr($source, 2);
} elseif (str_starts_with($source, '/') && $resourceDirectory !== null) {
$baseDirectory = $resourceDirectory;
$path = $source;
} else {
throw $error;
}
try {
$baseDirectory = normalizeUnixPath($baseDirectory);
$path = normalizeUnixPath($path);
} catch (Exception) {
throw $error;
}
if (! str_starts_with($path, $baseDirectory.'/')) {
throw $error;
}
validateComposeArrayVolumeSource($source);
}
/**
@@ -1419,7 +1390,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if ($source !== null && ! empty($source->value())) {
validateComposeArrayVolumeSource($source->value());
}
validateComposeContentVolumeSource($volume, composeResourceDirectory($resource));
validateComposeContentVolumeSource($volume);
if ($target !== null && ! empty($target->value())) {
try {
validateShellSafePath($target->value(), 'volume target');
@@ -2791,7 +2762,7 @@ function serviceParser(Service $resource): Collection
if ($source !== null && ! empty($source->value())) {
validateComposeArrayVolumeSource($source->value());
}
validateComposeContentVolumeSource($volume, composeResourceDirectory($resource));
validateComposeContentVolumeSource($volume);
if ($target !== null && ! empty($target->value())) {
try {
validateShellSafePath($target->value(), 'volume target');
+7 -12
View File
@@ -204,14 +204,11 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
]);
instant_remote_process($commands, $server);
foreach ($fileVolumes as $fileVolume) {
$path = str(data_get($fileVolume, 'fs_path'));
$content = data_get($fileVolume, 'content');
if ($path->startsWith('.')) {
$path = $path->after('.');
$fileLocation = $workdir.$path;
} else {
$fileLocation = $path;
}
// A relative path is inside the resource directory, never in the SSH user's working
// directory. A persisted Compose expression (`${VAR:-/path}`) stays as it is.
$fsPath = trim((string) $fileVolume->fs_path);
$fileLocation = str_starts_with($fsPath, '$') ? $fsPath : $fileVolume->resolvedFsPath($workdir)->value();
$escapedFileLocation = filesystemVolumeShellArgument((string) $fileLocation);
// Exists and is a file
$isFile = instant_remote_process(["test -f {$escapedFileLocation} && echo OK || echo NOK"], $server);
@@ -237,16 +234,14 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
$fileVolume->is_directory = true;
$fileVolume->save();
} elseif ($isFile === 'NOK' && $isDir === 'NOK' && ! $fileVolume->is_directory && $isInit && $content) {
// Does not exists (no dir or file), not flagged as directory, is init, has content.
// Content is written only inside the resource directory, as a literal path.
$escapedContentLocation = escapeshellarg($fileVolume->confinedContentPath((string) $fileLocation, $server));
// Does not exists (no dir or file), not flagged as directory, is init, has content
$fileVolume->content = $content;
$fileVolume->is_directory = false;
$fileVolume->save();
$content = base64_encode($content);
instant_remote_process([
'mkdir -p -- "$(dirname -- '.$escapedContentLocation.')"',
"echo '$content' | base64 -d | tee -- {$escapedContentLocation}",
'mkdir -p -- "$(dirname -- '.$escapedFileLocation.')"',
"echo '$content' | base64 -d | tee -- {$escapedFileLocation}",
], $server);
} elseif ($isFile === 'NOK' && $isDir === 'NOK' && $fileVolume->is_directory && $isInit) {
// Does not exists (no dir or file), flagged as directory, is init
+1 -1
View File
@@ -2955,7 +2955,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$target = data_get_str($volume, 'target');
$content = data_get($volume, 'content');
$isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
validateComposeContentVolumeSource($volume, $savedService->service->workdir());
validateComposeContentVolumeSource($volume);
$foundConfig = $savedService->fileStorages()->whereMountPath($target)->first();
if ($foundConfig) {
$contentNotNull = data_get($foundConfig, 'content');