From a06c1a7bf5e30eb779d8e7bce01b6e4b1f78b624 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:37:39 +0200 Subject: [PATCH] Improve storage mount path handling --- .../Api/ApplicationsController.php | 61 +++++- .../Controllers/Api/DatabasesController.php | 61 +++++- .../Controllers/Api/ServicesController.php | 61 +++++- app/Jobs/ApplicationDeploymentJob.php | 2 +- app/Livewire/Project/Service/FileStorage.php | 31 ++- app/Livewire/Project/Service/Storage.php | 80 +++++-- app/Models/LocalFileVolume.php | 34 ++- bootstrap/helpers/shared.php | 201 +++++++++++++++++- ..._host_file_to_local_file_volumes_table.php | 36 ++++ database/schema/testing-schema.sql | 1 + .../project/service/file-storage.blade.php | 17 +- .../project/service/storage.blade.php | 88 +++++++- templates/service-templates-latest.json | 2 +- templates/service-templates.json | 2 +- tests/Feature/FileStorageMountPathTest.php | 123 +++++++++++ tests/Feature/StorageApiTest.php | 160 +++++++++++++- tests/Unit/FileStorageSecurityTest.php | 74 +++++++ 17 files changed, 979 insertions(+), 55 deletions(-) create mode 100644 database/migrations/2026_07_02_112425_add_is_host_file_to_local_file_volumes_table.php create mode 100644 tests/Feature/FileStorageMountPathTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index bdb6f8d90..9570026c1 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -4279,10 +4279,11 @@ public function create_storage(Request $request): JsonResponse 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', 'is_directory' => 'boolean', + 'is_host_file' => 'boolean', 'fs_path' => 'string', ]); - $allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path']; + $allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path']; $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); @@ -4306,7 +4307,7 @@ public function create_storage(Request $request): JsonResponse ], 422); } - $typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all())); + $typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all())); if (! empty($typeSpecificInvalidFields)) { return response()->json([ 'message' => 'Validation failed.', @@ -4337,6 +4338,14 @@ public function create_storage(Request $request): JsonResponse } $isDirectory = $request->boolean('is_directory', false); + $isHostFile = $request->boolean('is_host_file', false); + + if ($isDirectory && $isHostFile) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'], + ], 422); + } if ($isDirectory) { if (! $request->fs_path) { @@ -4359,12 +4368,50 @@ public function create_storage(Request $request): JsonResponse 'resource_id' => $application->id, 'resource_type' => get_class($application), ]); + } elseif ($isHostFile) { + if (! $request->fs_path) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'], + ], 422); + } + + if ($request->filled('content')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['content' => 'Content is not valid for host file mounts.'], + ], 422); + } + + try { + $fsPath = validateHostFileMountPath($request->fs_path, 'host file source path'); + $mountPath = validateFileMountPath($request->mount_path, 'host file destination path'); + } catch (\Throwable $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['mount_path' => $e->getMessage()], + ], 422); + } + + $storage = LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => $mountPath, + 'content' => null, + 'is_directory' => false, + 'is_host_file' => true, + 'resource_id' => $application->id, + 'resource_type' => get_class($application), + ]); } else { - $mountPath = str($request->mount_path)->trim()->start('/')->value(); - - validateShellSafePath($mountPath, 'file storage path'); - - $fsPath = application_configuration_dir().'/'.$application->uuid.$mountPath; + try { + $mountPath = validateFileMountPath($request->mount_path, 'file storage path'); + $fsPath = confineFileMountPath(application_configuration_dir().'/'.$application->uuid, $mountPath, 'file storage path'); + } catch (\Throwable $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['mount_path' => $e->getMessage()], + ], 422); + } $storage = LocalFileVolume::create([ 'fs_path' => $fsPath, diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 9a463e8d3..912f81728 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -3696,10 +3696,11 @@ public function create_storage(Request $request): JsonResponse 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', 'is_directory' => 'boolean', + 'is_host_file' => 'boolean', 'fs_path' => 'string', ]); - $allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path']; + $allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path']; $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); @@ -3723,7 +3724,7 @@ public function create_storage(Request $request): JsonResponse ], 422); } - $typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all())); + $typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all())); if (! empty($typeSpecificInvalidFields)) { return response()->json([ 'message' => 'Validation failed.', @@ -3754,6 +3755,14 @@ public function create_storage(Request $request): JsonResponse } $isDirectory = $request->boolean('is_directory', false); + $isHostFile = $request->boolean('is_host_file', false); + + if ($isDirectory && $isHostFile) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'], + ], 422); + } if ($isDirectory) { if (! $request->fs_path) { @@ -3776,12 +3785,50 @@ public function create_storage(Request $request): JsonResponse 'resource_id' => $database->id, 'resource_type' => get_class($database), ]); + } elseif ($isHostFile) { + if (! $request->fs_path) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'], + ], 422); + } + + if ($request->filled('content')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['content' => 'Content is not valid for host file mounts.'], + ], 422); + } + + try { + $fsPath = validateHostFileMountPath($request->fs_path, 'host file source path'); + $mountPath = validateFileMountPath($request->mount_path, 'host file destination path'); + } catch (\Throwable $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['mount_path' => $e->getMessage()], + ], 422); + } + + $storage = LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => $mountPath, + 'content' => null, + 'is_directory' => false, + 'is_host_file' => true, + 'resource_id' => $database->id, + 'resource_type' => get_class($database), + ]); } else { - $mountPath = str($request->mount_path)->trim()->start('/')->value(); - - validateShellSafePath($mountPath, 'file storage path'); - - $fsPath = database_configuration_dir().'/'.$database->uuid.$mountPath; + try { + $mountPath = validateFileMountPath($request->mount_path, 'file storage path'); + $fsPath = confineFileMountPath(database_configuration_dir().'/'.$database->uuid, $mountPath, 'file storage path'); + } catch (\Throwable $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['mount_path' => $e->getMessage()], + ], 422); + } $storage = LocalFileVolume::create([ 'fs_path' => $fsPath, diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 32137b866..6c121bcf8 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -2115,10 +2115,11 @@ public function create_storage(Request $request): JsonResponse 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', 'is_directory' => 'boolean', + 'is_host_file' => 'boolean', 'fs_path' => 'string', ]); - $allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path']; + $allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path']; $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); @@ -2150,7 +2151,7 @@ public function create_storage(Request $request): JsonResponse ], 422); } - $typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all())); + $typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all())); if (! empty($typeSpecificInvalidFields)) { return response()->json([ 'message' => 'Validation failed.', @@ -2181,6 +2182,14 @@ public function create_storage(Request $request): JsonResponse } $isDirectory = $request->boolean('is_directory', false); + $isHostFile = $request->boolean('is_host_file', false); + + if ($isDirectory && $isHostFile) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'], + ], 422); + } if ($isDirectory) { if (! $request->fs_path) { @@ -2203,12 +2212,50 @@ public function create_storage(Request $request): JsonResponse 'resource_id' => $subResource->id, 'resource_type' => get_class($subResource), ]); + } elseif ($isHostFile) { + if (! $request->fs_path) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'], + ], 422); + } + + if ($request->filled('content')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['content' => 'Content is not valid for host file mounts.'], + ], 422); + } + + try { + $fsPath = validateHostFileMountPath($request->fs_path, 'host file source path'); + $mountPath = validateFileMountPath($request->mount_path, 'host file destination path'); + } catch (\Throwable $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['mount_path' => $e->getMessage()], + ], 422); + } + + $storage = LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => $mountPath, + 'content' => null, + 'is_directory' => false, + 'is_host_file' => true, + 'resource_id' => $subResource->id, + 'resource_type' => get_class($subResource), + ]); } else { - $mountPath = str($request->mount_path)->trim()->start('/')->value(); - - validateShellSafePath($mountPath, 'file storage path'); - - $fsPath = service_configuration_dir().'/'.$service->uuid.$mountPath; + try { + $mountPath = validateFileMountPath($request->mount_path, 'file storage path'); + $fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path'); + } catch (\Throwable $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['mount_path' => $e->getMessage()], + ], 422); + } $storage = LocalFileVolume::create([ 'fs_path' => $fsPath, diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index b7283550c..545735cf6 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1031,7 +1031,7 @@ private function write_deployment_configurations() ); } foreach ($this->application->fileStorages as $fileStorage) { - if (! $fileStorage->is_based_on_git && ! $fileStorage->is_directory) { + if (! $fileStorage->is_host_file && ! $fileStorage->is_based_on_git && ! $fileStorage->is_directory) { $fileStorage->saveStorageOnServer(); } } diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 877142d8b..e869ca91b 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -94,6 +94,10 @@ public function convertToDirectory() try { $this->authorize('update', $this->resource); + if ($this->fileStorage->is_host_file) { + throw new \Exception('Host file mounts are bind-only and cannot be converted.'); + } + $this->fileStorage->deleteStorageOnServer(); $this->fileStorage->is_directory = true; $this->fileStorage->content = null; @@ -112,6 +116,10 @@ public function loadStorageOnServer() try { $this->authorize('update', $this->resource); + if ($this->fileStorage->is_host_file) { + throw new \Exception('Host file mounts are bind-only and cannot be loaded from the server.'); + } + $this->fileStorage->loadStorageOnServer(); $this->syncData(); $this->dispatch('success', 'File storage loaded from server.'); @@ -127,6 +135,10 @@ public function convertToFile() try { $this->authorize('update', $this->resource); + if ($this->fileStorage->is_host_file) { + throw new \Exception('Host file mounts are bind-only and cannot be converted.'); + } + $this->fileStorage->deleteStorageOnServer(); $this->fileStorage->is_directory = false; $this->fileStorage->content = null; @@ -154,8 +166,10 @@ public function delete($password, $selectedActions = []) $message = 'File deleted.'; if ($this->fileStorage->is_directory) { $message = 'Directory deleted.'; + } elseif ($this->fileStorage->is_host_file) { + $message = 'Host file mount removed.'; } - if ($this->permanently_delete) { + if ($this->permanently_delete && ! $this->fileStorage->is_host_file) { $message = 'Directory deleted from the server.'; $this->fileStorage->deleteStorageOnServer(); } @@ -174,6 +188,12 @@ public function submit() { $this->authorize('update', $this->resource); + if ($this->fileStorage->is_host_file) { + $this->dispatch('error', 'Host file mounts are bind-only and cannot be edited from the UI.'); + + return; + } + if ($this->fileStorage->is_too_large) { $this->dispatch('error', 'File on server is too large to edit from the UI.'); @@ -205,6 +225,12 @@ public function submit() public function instantSave(): void { $this->authorize('update', $this->resource); + if ($this->fileStorage->is_host_file) { + $this->dispatch('error', 'Host file mounts are bind-only and cannot be edited from the UI.'); + + return; + } + if ($this->fileStorage->is_too_large) { $this->dispatch('error', 'File on server is too large to edit from the UI.'); @@ -223,6 +249,9 @@ public function render() 'fileDeletionCheckboxes' => [ ['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'], ], + 'hostFileDeletionCheckboxes' => [ + ['id' => 'permanently_delete', 'label' => 'Only the mount configuration will be removed. The host file will not be deleted.'], + ], ]); } } diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 30655691a..9b097f2e1 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -29,6 +29,10 @@ class Storage extends Component public ?string $file_storage_content = null; + public string $host_file_storage_source = ''; + + public string $host_file_storage_destination = ''; + public string $file_storage_directory_source = ''; public string $file_storage_directory_destination = ''; @@ -146,19 +150,9 @@ public function submitFileStorage() 'file_storage_content' => 'nullable|string', ]); - $this->file_storage_path = trim($this->file_storage_path); - $this->file_storage_path = str($this->file_storage_path)->start('/')->value(); + $this->file_storage_path = validateFileMountPath($this->file_storage_path, 'file storage path'); - // Validate path to prevent command injection - validateShellSafePath($this->file_storage_path, 'file storage path'); - - if ($this->resource->getMorphClass() === Application::class) { - $fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path; - } elseif (str($this->resource->getMorphClass())->contains('Standalone')) { - $fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path; - } else { - throw new \Exception('No valid resource type for file mount storage type!'); - } + $fs_path = confineFileMountPath($this->fileStorageHostPath(), $this->file_storage_path, 'file storage path'); LocalFileVolume::create([ 'fs_path' => $fs_path, @@ -178,6 +172,38 @@ public function submitFileStorage() } } + public function submitHostFileStorage() + { + try { + $this->authorize('update', $this->resource); + + $this->validate([ + 'host_file_storage_source' => 'required|string', + 'host_file_storage_destination' => 'required|string', + ]); + + $this->host_file_storage_source = validateHostFileMountPath($this->host_file_storage_source, 'host file source path'); + $this->host_file_storage_destination = validateFileMountPath($this->host_file_storage_destination, 'host file destination path'); + + LocalFileVolume::create([ + 'fs_path' => $this->host_file_storage_source, + 'mount_path' => $this->host_file_storage_destination, + 'content' => null, + 'is_directory' => false, + 'is_host_file' => true, + 'resource_id' => $this->resource->id, + 'resource_type' => get_class($this->resource), + ]); + + $this->dispatch('success', 'Host file mount added successfully'); + $this->dispatch('closeStorageModal', 'host-file'); + $this->clearForm(); + $this->refreshStorages(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function submitFileStorageDirectory() { try { @@ -222,6 +248,8 @@ public function clearForm() $this->file_storage_path = ''; $this->file_storage_content = null; $this->file_storage_directory_destination = ''; + $this->host_file_storage_source = ''; + $this->host_file_storage_destination = ''; if (str($this->resource->getMorphClass())->contains('Standalone')) { $this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}"; @@ -230,6 +258,34 @@ public function clearForm() } } + public function fileStorageHostPath(): string + { + if (method_exists($this->resource, 'workdir')) { + return $this->resource->workdir(); + } + + if ($this->resource->getMorphClass() === Application::class) { + return application_configuration_dir().'/'.$this->resource->uuid; + } + + if (str($this->resource->getMorphClass())->contains('Standalone')) { + return database_configuration_dir().'/'.$this->resource->uuid; + } + + throw new \Exception('No valid resource type for file mount storage type!'); + } + + public function fileStoragePreviewPath(): string + { + $path = str($this->file_storage_path)->trim(); + + if ($path->isEmpty()) { + return $this->fileStorageHostPath().'/'; + } + + return $this->fileStorageHostPath().$path->start('/')->value(); + } + public function render() { return view('livewire.project.service.storage'); diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 627750232..b521ede3d 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -21,6 +21,7 @@ class LocalFileVolume extends BaseModel // 'mount_path' => 'encrypted', 'content' => 'encrypted', 'is_directory' => 'boolean', + 'is_host_file' => 'boolean', 'is_preview_suffix_enabled' => 'boolean', ]; @@ -33,6 +34,7 @@ class LocalFileVolume extends BaseModel 'resource_type', 'resource_id', 'is_directory', + 'is_host_file', 'chown', 'chmod', 'is_based_on_git', @@ -44,6 +46,10 @@ class LocalFileVolume extends BaseModel protected static function booted() { static::created(function (LocalFileVolume $fileVolume) { + if ($fileVolume->is_host_file) { + return; + } + $fileVolume->load(['service']); dispatch(new ServerStorageSaveJob($fileVolume)); }); @@ -70,6 +76,10 @@ public function service() public function loadStorageOnServer() { + if ($this->is_host_file) { + return; + } + $this->load(['service']); $isService = data_get($this->resource, 'service'); if ($isService) { @@ -124,6 +134,10 @@ protected function remoteFileExceedsLimit(string $escapedPath, $server): bool public function deleteStorageOnServer() { + if ($this->is_host_file) { + return; + } + $this->load(['service']); $isService = data_get($this->resource, 'service'); if ($isService) { @@ -161,6 +175,10 @@ public function deleteStorageOnServer() public function saveStorageOnServer() { + if ($this->is_host_file) { + return; + } + $this->load(['service']); $isService = data_get($this->resource, 'service'); if ($isService) { @@ -171,26 +189,26 @@ public function saveStorageOnServer() $server = $this->resource->destination->server; } $commands = collect([]); - - // Validate fs_path early before any shell interpolation - validateShellSafePath($this->fs_path, 'storage path'); - $escapedFsPath = escapeshellarg($this->fs_path); $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}"); } - if (str($this->fs_path)->startsWith('.') || str($this->fs_path)->startsWith('/') || str($this->fs_path)->startsWith('~')) { - $parent_dir = str($this->fs_path)->beforeLast('/'); + $path = data_get_str($this, 'fs_path'); + $content = data_get($this, 'content'); + $pathForParentDirectory = 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"); } } - $path = data_get_str($this, 'fs_path'); - $content = data_get($this, 'content'); if ($path->startsWith('.')) { $path = $path->after('.'); $path = $workdir.$path; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 7b113e7b8..ab47c067a 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -166,7 +166,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string /** * Validate that a filename is safe for use as a plain file name (no path components). * - * Prevents path traversal attacks by rejecting directory separators, traversal + * Prevents unsafe parent directory paths by rejecting directory separators, parent directory * sequences, and null bytes, in addition to all shell metacharacters blocked by * validateShellSafePath(). Intended for user-supplied filenames such as PostgreSQL * init script names that are later written to a specific directory on the host. @@ -175,7 +175,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string * @param string $context Descriptive name for error messages (e.g., 'init script filename') * @return string The validated input (unchanged if valid) * - * @throws Exception If dangerous characters or path traversal sequences are detected + * @throws Exception If dangerous characters or parent directory sequences are detected */ function validateFilenameSafe(string $input, string $context = 'filename'): string { @@ -198,10 +198,10 @@ function validateFilenameSafe(string $input, string $context = 'filename'): stri ); } - // Reject path traversal sequences (catches encoded or unusual forms) + // Reject parent directory sequences (catches encoded or unusual forms) if (str_contains($input, '..')) { throw new Exception( - "Invalid {$context}: path traversal sequence ('..') is not allowed." + "Invalid {$context}: parent directory sequence ('..') is not allowed." ); } @@ -230,6 +230,197 @@ function validateFilenameSafe(string $input, string $context = 'filename'): stri return $input; } +/** + * Validate and normalize a user supplied file mount path. + * + * File mount paths are container paths supplied by tenants. They may look like + * absolute paths (for example /etc/nginx/nginx.conf), but are later joined to a + * Coolify-managed configuration directory on the host. Therefore shell safety is + * not enough: every path segment must also be unable to traverse out of that + * managed directory. + * + * @throws Exception + */ +function validateFileMountPath(string $input, string $context = 'file mount path'): string +{ + validateShellSafePath($input, $context); + + if (str_contains($input, "\0")) { + throw new Exception( + "Invalid {$context}: contains null byte. ". + 'Null bytes are not allowed in file mount paths for security reasons.' + ); + } + + if (str_contains($input, '\\')) { + throw new Exception( + "Invalid {$context}: backslash directory separators are not allowed." + ); + } + + $path = str($input)->trim()->start('/')->replaceMatches('#/+#', '/')->value(); + + foreach (explode('/', trim($path, '/')) as $segment) { + if ($segment === '' || ($segment !== '.' && $segment !== '..')) { + continue; + } + + throw new Exception( + "Invalid {$context}: relative path segments ('.' or '..') are not allowed." + ); + } + + return $path; +} + +/** + * Validate a host file path used as a bind-only source. + * + * Unlike managed file mounts, this path is not re-based under the Coolify + * configuration directory and must never be written by Coolify. It still needs + * to be shell-safe because other storage code may pass paths through remote + * shell commands. + * + * @throws Exception + */ +function validateHostFileMountPath(string $input, string $context = 'host file path'): string +{ + validateShellSafePath($input, $context); + + if (str_contains($input, "\0")) { + throw new Exception("Invalid {$context}: contains null byte."); + } + + if (str_contains($input, '\\')) { + throw new Exception("Invalid {$context}: backslash directory separators are not allowed."); + } + + $path = str($input)->trim()->replaceMatches('#/+#', '/')->value(); + + if ($path === '' || ! str_starts_with($path, '/')) { + throw new Exception("Invalid {$context}: must be an absolute path."); + } + + if ($path === '/' || str_ends_with($path, '/')) { + throw new Exception("Invalid {$context}: must point to a file, not a directory."); + } + + foreach (explode('/', trim($path, '/')) as $segment) { + if ($segment === '' || ($segment !== '.' && $segment !== '..')) { + continue; + } + + throw new Exception("Invalid {$context}: relative path segments ('.' or '..') are not allowed."); + } + + return normalizeUnixPath($path); +} + +/** + * Resolve a tenant file mount path under a Coolify-managed base directory. + * + * This performs lexical normalization only; the target file does not need to + * exist yet. The normalized result must remain inside the given base directory. + * + * @throws Exception + */ +function confineFileMountPath(string $baseDirectory, string $path, string $context = 'file mount path'): string +{ + $baseDirectory = normalizeUnixPath($baseDirectory); + $mountPath = validateFileMountPath($path, $context); + $resolvedPath = normalizeUnixPath($baseDirectory.'/'.$mountPath); + + if ($resolvedPath !== $baseDirectory && ! str_starts_with($resolvedPath, $baseDirectory.'/')) { + throw new Exception( + "Invalid {$context}: resolved path must stay inside the resource configuration directory." + ); + } + + return $resolvedPath; +} + +/** + * Normalize an existing host path and assert it remains inside a base directory. + * + * Dot-relative paths are resolved against the base directory for legacy + * LocalFileVolume rows. Absolute paths must already point inside the base. + * + * @throws Exception + */ +function confinePathToBase(string $baseDirectory, string $path, string $context = 'path'): string +{ + $baseDirectory = normalizeUnixPath($baseDirectory); + $path = trim($path); + + if (str_starts_with($path, '.')) { + $path = $baseDirectory.'/'.str($path)->after('.')->value(); + } elseif (! str_starts_with($path, '/')) { + $path = $baseDirectory.'/'.$path; + } + + $resolvedPath = normalizeUnixPath($path); + + if ($resolvedPath !== $baseDirectory && ! str_starts_with($resolvedPath, $baseDirectory.'/')) { + throw new Exception( + "Invalid {$context}: resolved path must stay inside the resource configuration directory." + ); + } + + return $resolvedPath; +} + +/** + * Normalize a Unix path lexically without consulting the remote filesystem. + * + * @throws Exception + */ +function normalizeUnixPath(string $path): string +{ + validateShellSafePath($path, 'path'); + + if (str_contains($path, "\0")) { + throw new Exception('Invalid path: contains null byte.'); + } + + if (str_contains($path, '\\')) { + throw new Exception('Invalid path: backslash directory separators are not allowed.'); + } + + $isAbsolute = str_starts_with($path, '/'); + $segments = []; + + foreach (explode('/', $path) as $segment) { + if ($segment === '' || $segment === '.') { + continue; + } + + if ($segment === '..') { + if ($segments === [] || end($segments) === '..') { + if ($isAbsolute) { + throw new Exception('Invalid path: resolved path escapes the base directory.'); + } + $segments[] = $segment; + + continue; + } + + array_pop($segments); + + continue; + } + + $segments[] = $segment; + } + + $normalized = implode('/', $segments); + + if ($isAbsolute) { + return $normalized === '' ? '/' : '/'.$normalized; + } + + return $normalized === '' ? '.' : $normalized; +} + /** * Validate that a databases_to_backup input string is safe from command injection. * @@ -3819,7 +4010,7 @@ function formatBytes(?int $bytes, int $precision = 2): string /** * Validates that a file path is safely within the /tmp/ directory. - * Protects against path traversal attacks by resolving the real path + * Protects against unsafe parent directory paths by resolving the real path * and verifying it stays within /tmp/. * * Note: On macOS, /tmp is often a symlink to /private/tmp, which is handled. diff --git a/database/migrations/2026_07_02_112425_add_is_host_file_to_local_file_volumes_table.php b/database/migrations/2026_07_02_112425_add_is_host_file_to_local_file_volumes_table.php new file mode 100644 index 000000000..6de618632 --- /dev/null +++ b/database/migrations/2026_07_02_112425_add_is_host_file_to_local_file_volumes_table.php @@ -0,0 +1,36 @@ +boolean('is_host_file')->default(false)->after('is_directory'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasColumn('local_file_volumes', 'is_host_file')) { + return; + } + + Schema::table('local_file_volumes', function (Blueprint $table) { + $table->dropColumn('is_host_file'); + }); + } +}; diff --git a/database/schema/testing-schema.sql b/database/schema/testing-schema.sql index edbc35db4..61c9b8e41 100644 --- a/database/schema/testing-schema.sql +++ b/database/schema/testing-schema.sql @@ -433,6 +433,7 @@ CREATE TABLE IF NOT EXISTS "local_file_volumes" ( "created_at" TEXT, "updated_at" TEXT, "is_directory" INTEGER DEFAULT false NOT NULL, + "is_host_file" INTEGER DEFAULT false NOT NULL, "chown" TEXT, "chmod" TEXT, "is_based_on_git" INTEGER DEFAULT false NOT NULL diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php index 2a14d9350..e47d290b8 100644 --- a/resources/views/livewire/project/service/file-storage.blade.php +++ b/resources/views/livewire/project/service/file-storage.blade.php @@ -4,6 +4,10 @@
File on server exceeds 5 MB and cannot be edited from the UI. Edit it directly on the server.
+ @elseif ($fileStorage->is_host_file) +
+ This host file mount is bind-only. Coolify will not create, edit, load, chmod, or delete the source file. +
@elseif ($isReadOnly)
@if ($fileStorage->is_directory) @@ -32,7 +36,14 @@ @if (!$isReadOnly) @can('update', $resource)
- @if ($fileStorage->is_directory) + @if ($fileStorage->is_host_file) + + @elseif ($fileStorage->is_directory) @@ -99,7 +110,7 @@ @endif @else {{-- Read-only view --}} - @if (!$fileStorage->is_directory) + @if (!$fileStorage->is_directory && !$fileStorage->is_host_file) @can('update', $resource)
Load from diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index 9e32cd22d..2f5971842 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -22,11 +22,13 @@ dropdownOpen: false, volumeModalOpen: false, fileModalOpen: false, + hostFileModalOpen: false, directoryModalOpen: false }" @close-storage-modal.window=" if ($event.detail === 'volume') volumeModalOpen = false; if ($event.detail === 'file') fileModalOpen = false; + if ($event.detail === 'host-file') hostFileModalOpen = false; if ($event.detail === 'directory') directoryModalOpen = false; ">