From 2cbc22a1c971ed73e315473ec4077981f3ba7a75 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:55:53 +0200 Subject: [PATCH 1/5] fix(volumes): resolve env-var file volume paths and refresh on env edits Normalize `${VAR}` and `${VAR:-default}` volume sources via `resolveEnvVarDefault()`, use preview env vars during application parsing, and re-resolve persisted file-volume paths after environment-variable updates. Also surface env-var source info in the file storage UI and add unit coverage for env-var/default resolution and path safety. --- app/Livewire/Project/Service/FileStorage.php | 58 ++++++ .../Shared/EnvironmentVariable/All.php | 3 +- .../Shared/EnvironmentVariable/Show.php | 32 ++-- app/Models/LocalFileVolume.php | 168 +++++++++++++++++- bootstrap/helpers/parsers.php | 36 ++-- bootstrap/helpers/shared.php | 35 ++++ .../project/service/file-storage.blade.php | 3 +- tests/Unit/VolumeArrayFormatSecurityTest.php | 137 ++++++++++++++ 8 files changed, 442 insertions(+), 30 deletions(-) diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 844e378546..dcaa02c0da 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -17,6 +17,7 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; +use Symfony\Component\Yaml\Yaml; class FileStorage extends Component { @@ -30,6 +31,8 @@ class FileStorage extends Component public ?string $workdir = null; + public ?string $resolvedFromEnvVar = null; + public bool $permanently_delete = true; public bool $isReadOnly = false; @@ -64,6 +67,7 @@ public function mount() } $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI(); + $this->resolvedFromEnvVar = $this->detectEnvVarSource(); $this->syncData(); } @@ -86,6 +90,60 @@ public function syncData(bool $toModel = false): void } } + /** + * Check the raw docker-compose for env var references in this volume's source. + * Returns the variable name if found, null otherwise. + */ + private function detectEnvVarSource(): ?string + { + try { + $resource = $this->resource; + $dockerComposeRaw = $resource->service?->docker_compose_raw + ?? $resource->docker_compose_raw + ?? null; + + if (! $dockerComposeRaw) { + return null; + } + + $compose = Yaml::parse($dockerComposeRaw); + + if (! isset($compose['services'])) { + return null; + } + + $mountPath = $this->fileStorage->mount_path; + + // Search all services in the compose for a volume matching this mount path + foreach ($compose['services'] as $serviceVolumes) { + $volumes = data_get($serviceVolumes, 'volumes', []); + foreach ($volumes as $volume) { + $source = null; + $target = null; + + if (is_string($volume)) { + $parts = explode(':', $volume); + if (count($parts) >= 2) { + $source = $parts[0]; + $target = $parts[1]; + } + } elseif (is_array($volume)) { + $source = data_get($volume, 'source'); + $target = data_get($volume, 'target'); + } + + if ($target === $mountPath && $source && preg_match('/\$\{([a-zA-Z_][a-zA-Z0-9_]*)/', $source, $matches)) { + return $matches[1]; + } + } + } + } catch (\Throwable $e) { + // Silently fail — this is just a UI hint + } + + return null; + } + public function convertToDirectory() { try { diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index f250a860b8..76d02dfa5f 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\Application; use App\Models\EnvironmentVariable; use App\Traits\EnvironmentVariableProtection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -38,7 +39,7 @@ public function mount() $this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false); $this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false); $this->resourceClass = get_class($this->resource); - $resourceWithPreviews = [\App\Models\Application::class]; + $resourceWithPreviews = [Application::class]; $simpleDockerfile = filled(data_get($this->resource, 'dockerfile')); if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) { $this->showPreview = true; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 4e8521f27d..4c67d5466b 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -2,12 +2,17 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\Application; use App\Models\Environment; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; +use App\Models\LocalFileVolume; use App\Models\Project; +use App\Models\Server; +use App\Models\Service; use App\Models\SharedEnvironmentVariable; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\EnvironmentVariableProtection; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Computed; use Livewire\Component; @@ -80,7 +85,7 @@ class Show extends Component public function mount() { $this->syncData(); - if ($this->env->getMorphClass() === \App\Models\SharedEnvironmentVariable::class) { + if ($this->env->getMorphClass() === SharedEnvironmentVariable::class) { $this->isSharedVariable = true; } $this->parameters = get_route_parameters(); @@ -203,6 +208,13 @@ public function submit() $this->serialize(); $this->syncData(true); $this->syncData(false); + + // Re-resolve volume paths that reference env vars (e.g., ${VAR:-./path}) + $resourceable = $this->env->resourceable ?? null; + if ($resourceable) { + LocalFileVolume::reResolveVolumePaths($resourceable); + } + $this->dispatch('success', 'Environment variable updated.'); $this->dispatch('envsUpdated'); $this->dispatch('configurationChanged'); @@ -233,7 +245,7 @@ public function availableSharedVariables(): array $result['team'] = $team->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view team variables } @@ -264,12 +276,12 @@ public function availableSharedVariables(): array $result['environment'] = $environment->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view environment variables } } } - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view project variables } } @@ -279,7 +291,7 @@ public function availableSharedVariables(): array $serverUuid = data_get($this->parameters, 'server_uuid'); if ($serverUuid) { // If we have a specific server_uuid, show variables for that server - $server = \App\Models\Server::where('team_id', $team->id) + $server = Server::where('team_id', $team->id) ->where('uuid', $serverUuid) ->first(); @@ -289,7 +301,7 @@ public function availableSharedVariables(): array $result['server'] = $server->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view server variables } } @@ -297,7 +309,7 @@ public function availableSharedVariables(): array // For application environment variables, try to use the application's destination server $applicationUuid = data_get($this->parameters, 'application_uuid'); if ($applicationUuid) { - $application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id) + $application = Application::whereRelation('environment.project.team', 'id', $team->id) ->where('uuid', $applicationUuid) ->with('destination.server') ->first(); @@ -308,7 +320,7 @@ public function availableSharedVariables(): array $result['server'] = $application->destination->server->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view server variables } } @@ -316,7 +328,7 @@ public function availableSharedVariables(): array // For service environment variables, try to use the service's server $serviceUuid = data_get($this->parameters, 'service_uuid'); if ($serviceUuid) { - $service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id) + $service = Service::whereRelation('environment.project.team', 'id', $team->id) ->where('uuid', $serviceUuid) ->with('server') ->first(); @@ -327,7 +339,7 @@ public function availableSharedVariables(): array $result['server'] = $service->server->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view server variables } } diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 4b5c602c27..2665c90b76 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -6,6 +6,7 @@ use App\Jobs\ServerStorageSaveJob; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Support\Stringable; use Symfony\Component\Yaml\Yaml; class LocalFileVolume extends BaseModel @@ -43,6 +44,18 @@ protected static function booted() }); } + /** + * Resolve env var default syntax from fs_path (e.g., ${VAR:-./path} -> ./path). + * Normally fs_path is stored already resolved by the parser, but legacy records + * may still contain raw Docker Compose variable syntax. + */ + public function resolvedFsPath(): string + { + $resolved = resolveEnvVarDefault(str($this->fs_path)); + + return $resolved->value(); + } + protected function isBinary(): Attribute { return Attribute::make( @@ -57,6 +70,144 @@ public function service() return $this->morphTo('resource'); } + /** + * Re-resolve fs_path for file volumes whose raw compose source references + * environment variables. Call this after env vars are updated. + * + * Works for Application and Service resources. + */ + public static function reResolveVolumePaths($resource): void + { + try { + if ($resource instanceof Application) { + static::reResolveForApplication($resource); + } elseif ($resource instanceof Service) { + static::reResolveForService($resource); + } + } catch (\Throwable) { + // Best-effort — don't break env var saves + } + } + + private static function reResolveForApplication(Application $application): void + { + $dockerComposeRaw = $application->docker_compose_raw; + if (! $dockerComposeRaw) { + return; + } + + $compose = Yaml::parse($dockerComposeRaw); + if (! isset($compose['services'])) { + return; + } + + $envVars = $application->environment_variables()->get(['key', 'value']) + ->mapWithKeys(fn ($item) => [$item['key'] => $item['value']]); + + $mainDirectory = str(base_configuration_dir().'/applications/'.$application->uuid); + + // Build raw source map from ALL compose services (Application owns all volumes) + $rawSources = []; + foreach ($compose['services'] as $service) { + $volumes = data_get($service, 'volumes', []); + foreach ($volumes as $volume) { + $source = null; + $target = null; + if (is_string($volume)) { + $parsed = parseDockerVolumeString($volume); + $source = $parsed['source']; + $target = $parsed['target']; + } elseif (is_array($volume)) { + $source = data_get_str($volume, 'source'); + $target = data_get_str($volume, 'target'); + } + if ($source && $target) { + $targetValue = $target instanceof Stringable ? $target->value() : (string) $target; + $rawSources[$targetValue] = $source; + } + } + } + + static::updateFileVolumePaths($application, $rawSources, $envVars, $mainDirectory); + } + + private static function reResolveForService(Service $service): void + { + $dockerComposeRaw = $service->docker_compose_raw; + if (! $dockerComposeRaw) { + return; + } + + $compose = Yaml::parse($dockerComposeRaw); + if (! isset($compose['services'])) { + return; + } + + $serviceEnvVars = $service->environment_variables()->get(['key', 'value']) + ->mapWithKeys(fn ($item) => [$item['key'] => $item['value']]); + + $mainDirectory = str(base_configuration_dir().'/applications/'.$service->uuid); + + $volumeOwners = collect() + ->merge($service->applications ?? collect()) + ->merge($service->databases ?? collect()); + + foreach ($volumeOwners as $owner) { + $childEnvVars = $owner->environment_variables()->get(['key', 'value']) + ->mapWithKeys(fn ($item) => [$item['key'] => $item['value']]); + $mergedEnvVars = $serviceEnvVars->merge($childEnvVars); + + $serviceName = $owner->name ?? null; + if (! $serviceName) { + continue; + } + + $rawSources = []; + $volumes = data_get($compose, "services.{$serviceName}.volumes", []); + foreach ($volumes as $volume) { + $source = null; + $target = null; + if (is_string($volume)) { + $parsed = parseDockerVolumeString($volume); + $source = $parsed['source']; + $target = $parsed['target']; + } elseif (is_array($volume)) { + $source = data_get_str($volume, 'source'); + $target = data_get_str($volume, 'target'); + } + if ($source && $target) { + $targetValue = $target instanceof Stringable ? $target->value() : (string) $target; + $rawSources[$targetValue] = $source; + } + } + + static::updateFileVolumePaths($owner, $rawSources, $mergedEnvVars, $mainDirectory); + } + } + + private static function updateFileVolumePaths($resource, array $rawSources, $envVars, Stringable $mainDirectory): void + { + foreach ($resource->fileStorages()->get() as $fileVolume) { + $rawSource = $rawSources[$fileVolume->mount_path] ?? null; + if (! $rawSource) { + continue; + } + + $sourceStr = $rawSource instanceof Stringable ? $rawSource : str($rawSource); + if (! str_contains($sourceStr->value(), '${')) { + continue; + } + + $resolved = resolveEnvVarDefault($sourceStr, $envVars); + if (sourceIsLocal($resolved)) { + $resolved = replaceLocalSource($resolved, $mainDirectory); + if ($fileVolume->fs_path !== $resolved->value()) { + $fileVolume->update(['fs_path' => $resolved->value()]); + } + } + } + } + public function loadStorageOnServer() { $this->load(['service']); @@ -69,7 +220,7 @@ public function loadStorageOnServer() $server = $this->resource->destination->server; } $commands = collect([]); - $path = data_get_str($this, 'fs_path'); + $path = str($this->resolvedFsPath()); if ($path->startsWith('.')) { $path = $path->after('.'); $path = $workdir.$path; @@ -104,7 +255,7 @@ public function deleteStorageOnServer() $server = $this->resource->destination->server; } $commands = collect([]); - $path = data_get_str($this, 'fs_path'); + $path = str($this->resolvedFsPath()); if ($path->startsWith('.')) { $path = $path->after('.'); $path = $workdir.$path; @@ -142,9 +293,12 @@ public function saveStorageOnServer() } $commands = collect([]); + // Resolve env var syntax (e.g., ${VAR:-./path} -> ./path) for legacy records + $fsPath = $this->resolvedFsPath(); + // Validate fs_path early before any shell interpolation - validateShellSafePath($this->fs_path, 'storage path'); - $escapedFsPath = escapeshellarg($this->fs_path); + validateShellSafePath($fsPath, 'storage path'); + $escapedFsPath = escapeshellarg($fsPath); $escapedWorkdir = escapeshellarg($workdir); if ($this->is_directory) { @@ -152,14 +306,14 @@ public function saveStorageOnServer() $commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true"); $commands->push("cd {$escapedWorkdir}"); } - if (str($this->fs_path)->startsWith('.') || str($this->fs_path)->startsWith('/') || str($this->fs_path)->startsWith('~')) { - $parent_dir = str($this->fs_path)->beforeLast('/'); + if (str($fsPath)->startsWith('.') || str($fsPath)->startsWith('/') || str($fsPath)->startsWith('~')) { + $parent_dir = str($fsPath)->beforeLast('/'); if ($parent_dir != '') { $escapedParentDir = escapeshellarg($parent_dir); $commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true"); } } - $path = data_get_str($this, 'fs_path'); + $path = str($fsPath); $content = data_get($this, 'content'); if ($path->startsWith('.')) { $path = $path->after('.'); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 123cf906a7..ec328a3e42 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -289,24 +289,20 @@ function parseDockerVolumeString(string $volumeString): array // Handle environment variable expansion in source // Example: ${VOLUME_DB_PATH:-db} should extract default value if present + // Only extract the default value here — full env var resolution happens in the parsers if ($source && preg_match('/^\$\{([^}]+)\}$/', $source, $matches)) { $varContent = $matches[1]; - - // Check if there's a default value with :- if (strpos($varContent, ':-') !== false) { $parts = explode(':-', $varContent, 2); $varName = $parts[0]; - $defaultValue = isset($parts[1]) ? $parts[1] : ''; - - // If there's a non-empty default value, use it for source + $defaultValue = $parts[1] ?? ''; if ($defaultValue !== '') { $source = $defaultValue; } else { - // Empty default value, keep the variable reference for env resolution + // Empty default — clean up to simple variable reference $source = '${'.$varName.'}'; } } - // Otherwise keep the variable as-is for later expansion (no default value) } // Validate source path for command injection attempts @@ -435,6 +431,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $allEnvironments = $allEnvironments->mapWithKeys(function ($item) { return [$item['key'] => $item['value']]; }); + // For preview deployments, merge preview env vars (they override normal ones) + if ($isPullRequest) { + $previewEnvironments = $resource->environment_variables_preview()->get(['key', 'value']) + ->mapWithKeys(fn ($item) => [$item['key'] => $item['value']]); + $allEnvironments = $allEnvironments->merge($previewEnvironments); + } // filter and add magic environments foreach ($environment as $key => $value) { // Get all SERVICE_ variables from keys and values @@ -703,6 +705,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $parsed = parseDockerVolumeString($volume); $source = $parsed['source']; $target = $parsed['target']; + // Resolve env vars in source (e.g., ${VAR} -> value from Coolify env vars) + $source = resolveEnvVarDefault($source, $allEnvironments); // Mode is available in $parsed['mode'] if needed $foundConfig = $fileStorages->whereMountPath($target)->first(); if (sourceIsLocal($source)) { @@ -727,15 +731,19 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $content = data_get($volume, 'content'); $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null); + // Resolve environment variable defaults (e.g., ${VAR:-./path} -> ./path) + if ($source !== null && ! empty($source->value())) { + $source = resolveEnvVarDefault($source, $allEnvironments); + } + // Validate source and target for command injection (array/long syntax) if ($source !== null && ! empty($source->value())) { $sourceValue = $source->value(); // Allow environment variable references and env vars with path concatenation $isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue); - $isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $sourceValue); $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceValue); - if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) { + if (! $isSimpleEnvVar && ! $isEnvVarWithPath) { try { validateShellSafePath($sourceValue, 'volume source'); } catch (Exception $e) { @@ -2090,6 +2098,8 @@ function serviceParser(Service $resource): Collection $parsed = parseDockerVolumeString($volume); $source = $parsed['source']; $target = $parsed['target']; + // Resolve env vars in source (e.g., ${VAR} -> value from Coolify env vars) + $source = resolveEnvVarDefault($source, $allEnvironments); // Mode is available in $parsed['mode'] if needed $foundConfig = $fileStorages->whereMountPath($target)->first(); if (sourceIsLocal($source)) { @@ -2114,15 +2124,19 @@ function serviceParser(Service $resource): Collection $content = data_get($volume, 'content'); $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null); + // Resolve environment variable defaults (e.g., ${VAR:-./path} -> ./path) + if ($source !== null && ! empty($source->value())) { + $source = resolveEnvVarDefault($source, $allEnvironments); + } + // Validate source and target for command injection (array/long syntax) if ($source !== null && ! empty($source->value())) { $sourceValue = $source->value(); // Allow environment variable references and env vars with path concatenation $isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue); - $isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $sourceValue); $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceValue); - if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) { + if (! $isSimpleEnvVar && ! $isEnvVarWithPath) { try { validateShellSafePath($sourceValue, 'volume source'); } catch (Exception $e) { diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index cd773f6a9f..6f896e11c8 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1234,6 +1234,41 @@ function getTopLevelNetworks(Service|Application $resource) return $topLevelNetworks->keys(); } } +function resolveEnvVarDefault(Stringable $source, ?Collection $environmentVariables = null): Stringable +{ + $value = $source->value(); + if (preg_match('/^\$\{([^}]+)\}$/', $value, $matches)) { + $varContent = $matches[1]; + + // Extract variable name and optional default from ${VAR:-default} or ${VAR} + $varName = $varContent; + $defaultValue = null; + if (strpos($varContent, ':-') !== false) { + $parts = explode(':-', $varContent, 2); + $varName = $parts[0]; + $defaultValue = $parts[1] ?? ''; + } + + // 1. If env var is set in Coolify, use its value + if ($environmentVariables !== null && $environmentVariables->has($varName)) { + $envValue = $environmentVariables->get($varName); + if ($envValue !== null && $envValue !== '') { + return str($envValue); + } + } + + // 2. If a non-empty default exists, use it + if ($defaultValue !== null && $defaultValue !== '') { + return str($defaultValue); + } + + // 3. No env var and no default — fall back to current directory (coolify data dir) + return str('.'); + } + + return $source; +} + function sourceIsLocal(Stringable $source) { if ($source->startsWith('./') || $source->startsWith('/') || $source->startsWith('~') || $source->startsWith('..') || $source->startsWith('~/') || $source->startsWith('../')) { diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php index 4bd88d761e..4c34ae849c 100644 --- a/resources/views/livewire/project/service/file-storage.blade.php +++ b/resources/views/livewire/project/service/file-storage.blade.php @@ -11,7 +11,8 @@ @endif