mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-07 22:45:27 -04:00
- 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>
603 lines
22 KiB
PHP
603 lines
22 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Events\FileStorageChanged;
|
|
use App\Jobs\ServerStorageSaveJob;
|
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
|
use Symfony\Component\Yaml\Yaml;
|
|
|
|
class LocalFileVolume extends BaseModel
|
|
{
|
|
public const MAX_CONTENT_SIZE = 5_242_880;
|
|
|
|
public const BINARY_PLACEHOLDER = '[binary file]';
|
|
|
|
public const TOO_LARGE_PLACEHOLDER = '[file too large to display]';
|
|
|
|
/**
|
|
* Resolves $1 and $2 like `realpath -m`, but only with POSIX sh and `readlink -f`, which
|
|
* BusyBox also has. It walks up to the deepest existing path, resolves it, and appends the
|
|
* missing rest. A dangling symlink or `.`/`..` in the missing rest fails closed.
|
|
*/
|
|
private const REMOTE_PATH_CONFINEMENT_SCRIPT = <<<'SH'
|
|
resolve() {
|
|
path=$1
|
|
rest=
|
|
case $path in /*) ;; *) return 1 ;; esac
|
|
while [ ! -e "$path" ]; do
|
|
if [ -L "$path" ]; then return 1; fi
|
|
rest=/${path##*/}$rest
|
|
path=${path%/*}
|
|
[ -n "$path" ] || path=/
|
|
done
|
|
case "$rest/" in */./*|*/../*) return 1 ;; esac
|
|
path=$(readlink -f "$path") || return 1
|
|
printf "%s\n" "${path%/}$rest"
|
|
}
|
|
base=$(resolve "$1") || exit 1
|
|
target=$(resolve "$2") || exit 1
|
|
case $target in "$base"|"$base"/*) echo OK ;; *) echo NOK ;; esac
|
|
SH;
|
|
|
|
protected $casts = [
|
|
// 'fs_path' => 'encrypted',
|
|
// 'mount_path' => 'encrypted',
|
|
'content' => 'encrypted',
|
|
'is_directory' => 'boolean',
|
|
'is_host_file' => 'boolean',
|
|
'is_preview_suffix_enabled' => 'boolean',
|
|
];
|
|
|
|
protected $hidden = [
|
|
'content',
|
|
];
|
|
|
|
use HasFactory;
|
|
|
|
protected $fillable = [
|
|
'fs_path',
|
|
'mount_path',
|
|
'content',
|
|
'resource_type',
|
|
'resource_id',
|
|
'is_directory',
|
|
'is_host_file',
|
|
'is_based_on_git',
|
|
'is_preview_suffix_enabled',
|
|
];
|
|
|
|
public $appends = ['is_binary', 'is_too_large'];
|
|
|
|
protected static function booted()
|
|
{
|
|
static::created(function (LocalFileVolume $fileVolume) {
|
|
if ($fileVolume->is_host_file) {
|
|
return;
|
|
}
|
|
|
|
ServerStorageSaveJob::dispatch($fileVolume)->afterCommit();
|
|
});
|
|
|
|
static::deleting(function (LocalFileVolume $fileVolume): void {
|
|
if ($fileVolume->scheduledBackups()->exists()) {
|
|
throw new \RuntimeException('Delete this directory backup schedule and its archives before deleting the directory.');
|
|
}
|
|
});
|
|
}
|
|
|
|
protected function isBinary(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => $this->content === self::BINARY_PLACEHOLDER
|
|
);
|
|
}
|
|
|
|
protected function isTooLarge(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => $this->content === self::TOO_LARGE_PLACEHOLDER
|
|
);
|
|
}
|
|
|
|
public function resource(): MorphTo
|
|
{
|
|
return $this->morphTo();
|
|
}
|
|
|
|
public function service(): MorphTo
|
|
{
|
|
return $this->morphTo('resource');
|
|
}
|
|
|
|
public function scheduledBackups(): MorphMany
|
|
{
|
|
return $this->morphMany(ScheduledVolumeBackup::class, 'backupable');
|
|
}
|
|
|
|
public function abortIfScheduledBackupsExist(): void
|
|
{
|
|
if ($this->scheduledBackups()->exists()) {
|
|
abort(422, 'Delete this directory backup schedule and its archives before deleting the directory.');
|
|
}
|
|
}
|
|
|
|
public function loadStorageOnServer()
|
|
{
|
|
if ($this->is_host_file) {
|
|
return;
|
|
}
|
|
|
|
$this->load(['service']);
|
|
$isService = data_get($this->resource, 'service');
|
|
if ($isService) {
|
|
$workdir = $this->resource->service->workdir();
|
|
$server = $this->resource->service->server;
|
|
} else {
|
|
$workdir = $this->resource->workdir();
|
|
$server = $this->resource->destination->server;
|
|
}
|
|
$commands = collect([]);
|
|
$path = data_get_str($this, 'fs_path');
|
|
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 path to prevent command injection
|
|
validateShellSafePath($path, 'storage path');
|
|
$escapedPath = escapeshellarg($path);
|
|
|
|
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
|
|
if ($isFile === 'OK') {
|
|
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
|
|
$this->content = self::TOO_LARGE_PLACEHOLDER;
|
|
$this->is_directory = false;
|
|
$this->save();
|
|
|
|
return;
|
|
}
|
|
$content = $this->readRemoteFileContent($escapedPath, $server);
|
|
// Check if content contains binary data by looking for null bytes or non-printable characters
|
|
if ($content !== self::TOO_LARGE_PLACEHOLDER && (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content))) {
|
|
$content = self::BINARY_PLACEHOLDER;
|
|
}
|
|
$this->content = $content;
|
|
$this->is_directory = false;
|
|
$this->save();
|
|
}
|
|
}
|
|
|
|
protected function remoteFileExceedsLimit(string $escapedPath, $server): bool
|
|
{
|
|
$sizeOutput = instant_remote_process(
|
|
["stat -c%s {$escapedPath} 2>/dev/null || wc -c < {$escapedPath}"],
|
|
$server,
|
|
false,
|
|
);
|
|
$size = (int) trim((string) $sizeOutput);
|
|
|
|
return $size > self::MAX_CONTENT_SIZE;
|
|
}
|
|
|
|
/**
|
|
* Cap the remote read itself so a file that grows after the size check
|
|
* cannot be fully slurped into PHP memory.
|
|
*/
|
|
protected function readRemoteFileContent(string $escapedPath, $server): string
|
|
{
|
|
$readLimit = self::MAX_CONTENT_SIZE + 1;
|
|
$content = instant_remote_process(["head -c {$readLimit} {$escapedPath}"], $server, false);
|
|
|
|
return self::contentFromBoundedRead($content);
|
|
}
|
|
|
|
public static function contentFromBoundedRead(?string $content): string
|
|
{
|
|
if (strlen((string) $content) > self::MAX_CONTENT_SIZE) {
|
|
return self::TOO_LARGE_PLACEHOLDER;
|
|
}
|
|
|
|
return (string) $content;
|
|
}
|
|
|
|
public function deleteStorageOnServer()
|
|
{
|
|
if ($this->is_host_file) {
|
|
return;
|
|
}
|
|
|
|
$this->load(['service']);
|
|
$isService = data_get($this->resource, 'service');
|
|
if ($isService) {
|
|
$workdir = $this->resource->service->workdir();
|
|
$server = $this->resource->service->server;
|
|
} else {
|
|
$workdir = $this->resource->workdir();
|
|
$server = $this->resource->destination->server;
|
|
}
|
|
$commands = collect([]);
|
|
$path = data_get_str($this, 'fs_path');
|
|
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 path to prevent command injection
|
|
validateShellSafePath($path, 'storage path');
|
|
$escapedPath = escapeshellarg($path);
|
|
|
|
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
|
|
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
|
|
if ($path && $path != '/' && $path != '.' && $path != '..') {
|
|
if ($isFile === 'OK') {
|
|
$commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true");
|
|
} elseif ($isDir === 'OK') {
|
|
$commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true");
|
|
$commands->push("rmdir {$escapedPath} > /dev/null 2>&1 || true");
|
|
}
|
|
}
|
|
if ($commands->count() > 0) {
|
|
return instant_remote_process($commands, $server);
|
|
}
|
|
}
|
|
|
|
public function saveStorageOnServer()
|
|
{
|
|
if ($this->is_host_file) {
|
|
return;
|
|
}
|
|
|
|
$this->load(['service']);
|
|
$isService = data_get($this->resource, 'service');
|
|
if ($isService) {
|
|
$workdir = $this->resource->service->workdir();
|
|
$server = $this->resource->service->server;
|
|
} else {
|
|
$workdir = $this->resource->workdir();
|
|
$server = $this->resource->destination->server;
|
|
}
|
|
$commands = collect([]);
|
|
$escapedWorkdir = escapeshellarg($workdir);
|
|
|
|
if ($this->is_directory) {
|
|
// Validate fs_path early before any shell interpolation
|
|
validateShellSafePath($this->fs_path, 'storage path');
|
|
$escapedFsPath = escapeshellarg($this->fs_path);
|
|
$commands->push("mkdir -p {$escapedFsPath} > /dev/null 2>&1 || true");
|
|
$commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true");
|
|
$commands->push("cd {$escapedWorkdir}");
|
|
}
|
|
$path = data_get_str($this, 'fs_path');
|
|
$content = data_get($this, 'content');
|
|
$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 != '') {
|
|
$escapedParentDir = escapeshellarg($parent_dir);
|
|
$commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true");
|
|
}
|
|
}
|
|
|
|
// Validate and escape resolved path (may differ from fs_path if relative)
|
|
validateShellSafePath($path, 'storage path');
|
|
$escapedPath = escapeshellarg($path);
|
|
|
|
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
|
|
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
|
|
if ($isFile === 'OK' && $this->is_directory) {
|
|
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
|
|
$this->content = self::TOO_LARGE_PLACEHOLDER;
|
|
} else {
|
|
$this->content = $this->readRemoteFileContent($escapedPath, $server);
|
|
}
|
|
$this->is_directory = false;
|
|
$this->save();
|
|
FileStorageChanged::dispatch(data_get($server, 'team_id'));
|
|
throw new \Exception('The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.');
|
|
} elseif ($isDir === 'OK' && ! $this->is_directory) {
|
|
if ($path === '/' || $path === '.' || $path === '..' || $path === '' || str($path)->isEmpty() || is_null($path)) {
|
|
$this->is_directory = true;
|
|
$this->save();
|
|
throw new \Exception('The following file is a directory on the server, but you are trying to mark it as a file. <br><br>Please delete the directory on the server or mark it as directory.');
|
|
}
|
|
instant_remote_process([
|
|
"rm -fr {$escapedPath}",
|
|
"touch {$escapedPath}",
|
|
], $server, false);
|
|
FileStorageChanged::dispatch(data_get($server, 'team_id'));
|
|
}
|
|
if ($isDir === 'NOK' && ! $this->is_directory) {
|
|
$chmod = data_get($this, 'chmod');
|
|
$chown = data_get($this, 'chown');
|
|
if ($content) {
|
|
$content = base64_encode($content);
|
|
$commands->push("echo '$content' | base64 -d | tee {$escapedPath} > /dev/null");
|
|
} else {
|
|
$commands->push("touch {$escapedPath}");
|
|
}
|
|
$commands->push("chmod +x {$escapedPath}");
|
|
if ($chown) {
|
|
$commands->push('chown -- '.escapeshellarg($chown)." {$escapedPath}");
|
|
}
|
|
if ($chmod) {
|
|
$commands->push('chmod -- '.escapeshellarg($chmod)." {$escapedPath}");
|
|
}
|
|
} elseif ($isDir === 'NOK' && $this->is_directory) {
|
|
$commands->push("mkdir -p {$escapedPath} > /dev/null 2>&1 || true");
|
|
}
|
|
|
|
return instant_remote_process($commands, $server);
|
|
}
|
|
|
|
/**
|
|
* Reject symlink escapes immediately before a managed path is used remotely.
|
|
*/
|
|
public static function assertRemotePathIsConfined(string $baseDirectory, string $path, Server $server): void
|
|
{
|
|
$result = instant_remote_process([self::remotePathConfinementCommand($baseDirectory, $path)], $server, false);
|
|
|
|
if (trim((string) $result) !== 'OK') {
|
|
throw new \RuntimeException('Invalid storage path: resolved path must stay inside the resource configuration directory.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One `sh -c` line with the paths as arguments, so the non-root sudo parser only puts
|
|
* sudo in front of it and never changes the script.
|
|
*/
|
|
public static function remotePathConfinementCommand(string $baseDirectory, string $path): string
|
|
{
|
|
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.
|
|
*/
|
|
protected function isAdminControlledComposeMount(): bool
|
|
{
|
|
$compose = data_get($this->resource, 'docker_compose_raw')
|
|
?? data_get($this->resource, 'service.docker_compose_raw');
|
|
|
|
if (! is_string($compose) || $compose === '') {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$services = data_get(Yaml::parse($compose), 'services', []);
|
|
foreach ($services as $service) {
|
|
foreach (data_get($service, 'volumes', []) as $volume) {
|
|
if (is_string($volume)) {
|
|
$parsed = parseDockerVolumeString($volume);
|
|
$source = data_get($parsed, 'source');
|
|
$target = data_get($parsed, 'target');
|
|
} else {
|
|
$source = data_get($volume, 'source');
|
|
$target = data_get($volume, 'target');
|
|
}
|
|
|
|
if ((string) $target !== $this->mount_path || ! sourceIsLocal(str((string) $source))) {
|
|
continue;
|
|
}
|
|
|
|
$resolvedSource = replaceLocalSource(str((string) $source), str($this->composeSourceDirectory()));
|
|
if (normalizeUnixPath($resolvedSource->value()) === normalizeUnixPath($this->fs_path)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
} catch (\Throwable) {
|
|
return false;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Accessor for convenient access
|
|
protected function plainMountPath(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => $this->mount_path,
|
|
set: fn ($value) => $this->mount_path = $value
|
|
);
|
|
}
|
|
|
|
// Scope for searching
|
|
public function scopeWherePlainMountPath($query, $path)
|
|
{
|
|
return $query->get()->where('plain_mount_path', $path);
|
|
}
|
|
|
|
// Check if this volume belongs to a service resource
|
|
public function isServiceResource(): bool
|
|
{
|
|
return in_array($this->resource_type, [
|
|
'App\Models\ServiceApplication',
|
|
'App\Models\ServiceDatabase',
|
|
]);
|
|
}
|
|
|
|
// Determine if this volume should be read-only in the UI
|
|
// File/directory mounts can be edited even for services
|
|
public function shouldBeReadOnlyInUI(): bool
|
|
{
|
|
// Check for explicit :ro flag in compose (existing logic)
|
|
return $this->isReadOnlyVolume();
|
|
}
|
|
|
|
// Check if this volume is read-only by parsing the docker-compose content
|
|
public function isReadOnlyVolume(): bool
|
|
{
|
|
try {
|
|
// Only check for services
|
|
$service = $this->service;
|
|
if (! $service || ! method_exists($service, 'service')) {
|
|
return false;
|
|
}
|
|
|
|
$actualService = $service->service;
|
|
if (! $actualService || ! $actualService->docker_compose_raw) {
|
|
return false;
|
|
}
|
|
|
|
// Parse the docker-compose content
|
|
$compose = Yaml::parse($actualService->docker_compose_raw);
|
|
if (! isset($compose['services'])) {
|
|
return false;
|
|
}
|
|
|
|
// Find the service that this volume belongs to
|
|
$serviceName = $service->name;
|
|
if (! isset($compose['services'][$serviceName]['volumes'])) {
|
|
return false;
|
|
}
|
|
|
|
$volumes = $compose['services'][$serviceName]['volumes'];
|
|
|
|
// Check each volume to find a match
|
|
// Note: We match on mount_path (container path) only, since fs_path gets transformed
|
|
// from relative (./file) to absolute (/data/coolify/services/uuid/file) during parsing
|
|
foreach ($volumes as $volume) {
|
|
// Volume can be string like "host:container:ro" or "host:container"
|
|
if (is_string($volume)) {
|
|
$parts = explode(':', $volume);
|
|
|
|
// Check if this volume matches our mount_path
|
|
if (count($parts) >= 2) {
|
|
$containerPath = $parts[1];
|
|
$options = $parts[2] ?? null;
|
|
|
|
// Match based on mount_path
|
|
// Remove leading slash from mount_path if present for comparison
|
|
$mountPath = str($this->mount_path)->ltrim('/')->toString();
|
|
$containerPathClean = str($containerPath)->ltrim('/')->toString();
|
|
|
|
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
|
|
return $options === 'ro';
|
|
}
|
|
}
|
|
} elseif (is_array($volume)) {
|
|
// Long-form syntax: { type: bind, source: ..., target: ..., read_only: true }
|
|
$containerPath = data_get($volume, 'target');
|
|
$readOnly = data_get($volume, 'read_only', false);
|
|
|
|
// Match based on mount_path
|
|
// Remove leading slash from mount_path if present for comparison
|
|
$mountPath = str($this->mount_path)->ltrim('/')->toString();
|
|
$containerPathClean = str($containerPath)->ltrim('/')->toString();
|
|
|
|
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
|
|
return $readOnly === true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
} catch (\Throwable $e) {
|
|
|
|
return false;
|
|
}
|
|
}
|
|
}
|