Improve storage mount path handling

This commit is contained in:
Andras Bacsai 2026-07-02 14:37:39 +02:00
parent 39ae16de42
commit a06c1a7bf5
17 changed files with 979 additions and 55 deletions

View file

@ -4279,10 +4279,11 @@ public function create_storage(Request $request): JsonResponse
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable', 'content' => 'string|nullable',
'is_directory' => 'boolean', 'is_directory' => 'boolean',
'is_host_file' => 'boolean',
'fs_path' => 'string', '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); $extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) { if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors(); $errors = $validator->errors();
@ -4306,7 +4307,7 @@ public function create_storage(Request $request): JsonResponse
], 422); ], 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)) { if (! empty($typeSpecificInvalidFields)) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
@ -4337,6 +4338,14 @@ public function create_storage(Request $request): JsonResponse
} }
$isDirectory = $request->boolean('is_directory', false); $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 ($isDirectory) {
if (! $request->fs_path) { if (! $request->fs_path) {
@ -4359,12 +4368,50 @@ public function create_storage(Request $request): JsonResponse
'resource_id' => $application->id, 'resource_id' => $application->id,
'resource_type' => get_class($application), '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 { } else {
$mountPath = str($request->mount_path)->trim()->start('/')->value(); try {
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
validateShellSafePath($mountPath, 'file storage path'); $fsPath = confineFileMountPath(application_configuration_dir().'/'.$application->uuid, $mountPath, 'file storage path');
} catch (\Throwable $e) {
$fsPath = application_configuration_dir().'/'.$application->uuid.$mountPath; return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([ $storage = LocalFileVolume::create([
'fs_path' => $fsPath, 'fs_path' => $fsPath,

View file

@ -3696,10 +3696,11 @@ public function create_storage(Request $request): JsonResponse
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable', 'content' => 'string|nullable',
'is_directory' => 'boolean', 'is_directory' => 'boolean',
'is_host_file' => 'boolean',
'fs_path' => 'string', '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); $extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) { if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors(); $errors = $validator->errors();
@ -3723,7 +3724,7 @@ public function create_storage(Request $request): JsonResponse
], 422); ], 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)) { if (! empty($typeSpecificInvalidFields)) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
@ -3754,6 +3755,14 @@ public function create_storage(Request $request): JsonResponse
} }
$isDirectory = $request->boolean('is_directory', false); $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 ($isDirectory) {
if (! $request->fs_path) { if (! $request->fs_path) {
@ -3776,12 +3785,50 @@ public function create_storage(Request $request): JsonResponse
'resource_id' => $database->id, 'resource_id' => $database->id,
'resource_type' => get_class($database), '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 { } else {
$mountPath = str($request->mount_path)->trim()->start('/')->value(); try {
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
validateShellSafePath($mountPath, 'file storage path'); $fsPath = confineFileMountPath(database_configuration_dir().'/'.$database->uuid, $mountPath, 'file storage path');
} catch (\Throwable $e) {
$fsPath = database_configuration_dir().'/'.$database->uuid.$mountPath; return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([ $storage = LocalFileVolume::create([
'fs_path' => $fsPath, 'fs_path' => $fsPath,

View file

@ -2115,10 +2115,11 @@ public function create_storage(Request $request): JsonResponse
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable', 'content' => 'string|nullable',
'is_directory' => 'boolean', 'is_directory' => 'boolean',
'is_host_file' => 'boolean',
'fs_path' => 'string', '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); $extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) { if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors(); $errors = $validator->errors();
@ -2150,7 +2151,7 @@ public function create_storage(Request $request): JsonResponse
], 422); ], 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)) { if (! empty($typeSpecificInvalidFields)) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
@ -2181,6 +2182,14 @@ public function create_storage(Request $request): JsonResponse
} }
$isDirectory = $request->boolean('is_directory', false); $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 ($isDirectory) {
if (! $request->fs_path) { if (! $request->fs_path) {
@ -2203,12 +2212,50 @@ public function create_storage(Request $request): JsonResponse
'resource_id' => $subResource->id, 'resource_id' => $subResource->id,
'resource_type' => get_class($subResource), '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 { } else {
$mountPath = str($request->mount_path)->trim()->start('/')->value(); try {
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
validateShellSafePath($mountPath, 'file storage path'); $fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path');
} catch (\Throwable $e) {
$fsPath = service_configuration_dir().'/'.$service->uuid.$mountPath; return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([ $storage = LocalFileVolume::create([
'fs_path' => $fsPath, 'fs_path' => $fsPath,

View file

@ -1031,7 +1031,7 @@ private function write_deployment_configurations()
); );
} }
foreach ($this->application->fileStorages as $fileStorage) { 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(); $fileStorage->saveStorageOnServer();
} }
} }

View file

@ -94,6 +94,10 @@ public function convertToDirectory()
try { try {
$this->authorize('update', $this->resource); $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->deleteStorageOnServer();
$this->fileStorage->is_directory = true; $this->fileStorage->is_directory = true;
$this->fileStorage->content = null; $this->fileStorage->content = null;
@ -112,6 +116,10 @@ public function loadStorageOnServer()
try { try {
$this->authorize('update', $this->resource); $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->fileStorage->loadStorageOnServer();
$this->syncData(); $this->syncData();
$this->dispatch('success', 'File storage loaded from server.'); $this->dispatch('success', 'File storage loaded from server.');
@ -127,6 +135,10 @@ public function convertToFile()
try { try {
$this->authorize('update', $this->resource); $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->deleteStorageOnServer();
$this->fileStorage->is_directory = false; $this->fileStorage->is_directory = false;
$this->fileStorage->content = null; $this->fileStorage->content = null;
@ -154,8 +166,10 @@ public function delete($password, $selectedActions = [])
$message = 'File deleted.'; $message = 'File deleted.';
if ($this->fileStorage->is_directory) { if ($this->fileStorage->is_directory) {
$message = 'Directory deleted.'; $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.'; $message = 'Directory deleted from the server.';
$this->fileStorage->deleteStorageOnServer(); $this->fileStorage->deleteStorageOnServer();
} }
@ -174,6 +188,12 @@ public function submit()
{ {
$this->authorize('update', $this->resource); $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) { if ($this->fileStorage->is_too_large) {
$this->dispatch('error', 'File on server is too large to edit from the UI.'); $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 public function instantSave(): void
{ {
$this->authorize('update', $this->resource); $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) { if ($this->fileStorage->is_too_large) {
$this->dispatch('error', 'File on server is too large to edit from the UI.'); $this->dispatch('error', 'File on server is too large to edit from the UI.');
@ -223,6 +249,9 @@ public function render()
'fileDeletionCheckboxes' => [ 'fileDeletionCheckboxes' => [
['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'], ['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.'],
],
]); ]);
} }
} }

View file

@ -29,6 +29,10 @@ class Storage extends Component
public ?string $file_storage_content = null; 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_source = '';
public string $file_storage_directory_destination = ''; public string $file_storage_directory_destination = '';
@ -146,19 +150,9 @@ public function submitFileStorage()
'file_storage_content' => 'nullable|string', 'file_storage_content' => 'nullable|string',
]); ]);
$this->file_storage_path = trim($this->file_storage_path); $this->file_storage_path = validateFileMountPath($this->file_storage_path, 'file storage path');
$this->file_storage_path = str($this->file_storage_path)->start('/')->value();
// Validate path to prevent command injection $fs_path = confineFileMountPath($this->fileStorageHostPath(), $this->file_storage_path, 'file storage path');
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!');
}
LocalFileVolume::create([ LocalFileVolume::create([
'fs_path' => $fs_path, '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() public function submitFileStorageDirectory()
{ {
try { try {
@ -222,6 +248,8 @@ public function clearForm()
$this->file_storage_path = ''; $this->file_storage_path = '';
$this->file_storage_content = null; $this->file_storage_content = null;
$this->file_storage_directory_destination = ''; $this->file_storage_directory_destination = '';
$this->host_file_storage_source = '';
$this->host_file_storage_destination = '';
if (str($this->resource->getMorphClass())->contains('Standalone')) { if (str($this->resource->getMorphClass())->contains('Standalone')) {
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}"; $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() public function render()
{ {
return view('livewire.project.service.storage'); return view('livewire.project.service.storage');

View file

@ -21,6 +21,7 @@ class LocalFileVolume extends BaseModel
// 'mount_path' => 'encrypted', // 'mount_path' => 'encrypted',
'content' => 'encrypted', 'content' => 'encrypted',
'is_directory' => 'boolean', 'is_directory' => 'boolean',
'is_host_file' => 'boolean',
'is_preview_suffix_enabled' => 'boolean', 'is_preview_suffix_enabled' => 'boolean',
]; ];
@ -33,6 +34,7 @@ class LocalFileVolume extends BaseModel
'resource_type', 'resource_type',
'resource_id', 'resource_id',
'is_directory', 'is_directory',
'is_host_file',
'chown', 'chown',
'chmod', 'chmod',
'is_based_on_git', 'is_based_on_git',
@ -44,6 +46,10 @@ class LocalFileVolume extends BaseModel
protected static function booted() protected static function booted()
{ {
static::created(function (LocalFileVolume $fileVolume) { static::created(function (LocalFileVolume $fileVolume) {
if ($fileVolume->is_host_file) {
return;
}
$fileVolume->load(['service']); $fileVolume->load(['service']);
dispatch(new ServerStorageSaveJob($fileVolume)); dispatch(new ServerStorageSaveJob($fileVolume));
}); });
@ -70,6 +76,10 @@ public function service()
public function loadStorageOnServer() public function loadStorageOnServer()
{ {
if ($this->is_host_file) {
return;
}
$this->load(['service']); $this->load(['service']);
$isService = data_get($this->resource, 'service'); $isService = data_get($this->resource, 'service');
if ($isService) { if ($isService) {
@ -124,6 +134,10 @@ protected function remoteFileExceedsLimit(string $escapedPath, $server): bool
public function deleteStorageOnServer() public function deleteStorageOnServer()
{ {
if ($this->is_host_file) {
return;
}
$this->load(['service']); $this->load(['service']);
$isService = data_get($this->resource, 'service'); $isService = data_get($this->resource, 'service');
if ($isService) { if ($isService) {
@ -161,6 +175,10 @@ public function deleteStorageOnServer()
public function saveStorageOnServer() public function saveStorageOnServer()
{ {
if ($this->is_host_file) {
return;
}
$this->load(['service']); $this->load(['service']);
$isService = data_get($this->resource, 'service'); $isService = data_get($this->resource, 'service');
if ($isService) { if ($isService) {
@ -171,26 +189,26 @@ public function saveStorageOnServer()
$server = $this->resource->destination->server; $server = $this->resource->destination->server;
} }
$commands = collect([]); $commands = collect([]);
// Validate fs_path early before any shell interpolation
validateShellSafePath($this->fs_path, 'storage path');
$escapedFsPath = escapeshellarg($this->fs_path);
$escapedWorkdir = escapeshellarg($workdir); $escapedWorkdir = escapeshellarg($workdir);
if ($this->is_directory) { 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 {$escapedFsPath} > /dev/null 2>&1 || true");
$commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true"); $commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true");
$commands->push("cd {$escapedWorkdir}"); $commands->push("cd {$escapedWorkdir}");
} }
if (str($this->fs_path)->startsWith('.') || str($this->fs_path)->startsWith('/') || str($this->fs_path)->startsWith('~')) { $path = data_get_str($this, 'fs_path');
$parent_dir = str($this->fs_path)->beforeLast('/'); $content = data_get($this, 'content');
$pathForParentDirectory = str($this->fs_path);
if ($pathForParentDirectory->startsWith('.') || $pathForParentDirectory->startsWith('/') || $pathForParentDirectory->startsWith('~')) {
$parent_dir = $pathForParentDirectory->beforeLast('/');
if ($parent_dir != '') { if ($parent_dir != '') {
$escapedParentDir = escapeshellarg($parent_dir); $escapedParentDir = escapeshellarg($parent_dir);
$commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true"); $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('.')) { if ($path->startsWith('.')) {
$path = $path->after('.'); $path = $path->after('.');
$path = $workdir.$path; $path = $workdir.$path;

View file

@ -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). * 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 * sequences, and null bytes, in addition to all shell metacharacters blocked by
* validateShellSafePath(). Intended for user-supplied filenames such as PostgreSQL * validateShellSafePath(). Intended for user-supplied filenames such as PostgreSQL
* init script names that are later written to a specific directory on the host. * 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') * @param string $context Descriptive name for error messages (e.g., 'init script filename')
* @return string The validated input (unchanged if valid) * @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 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, '..')) { if (str_contains($input, '..')) {
throw new Exception( 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; 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. * 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. * 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/. * and verifying it stays within /tmp/.
* *
* Note: On macOS, /tmp is often a symlink to /private/tmp, which is handled. * Note: On macOS, /tmp is often a symlink to /private/tmp, which is handled.

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (Schema::hasColumn('local_file_volumes', 'is_host_file')) {
return;
}
Schema::table('local_file_volumes', function (Blueprint $table) {
$table->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');
});
}
};

View file

@ -433,6 +433,7 @@ CREATE TABLE IF NOT EXISTS "local_file_volumes" (
"created_at" TEXT, "created_at" TEXT,
"updated_at" TEXT, "updated_at" TEXT,
"is_directory" INTEGER DEFAULT false NOT NULL, "is_directory" INTEGER DEFAULT false NOT NULL,
"is_host_file" INTEGER DEFAULT false NOT NULL,
"chown" TEXT, "chown" TEXT,
"chmod" TEXT, "chmod" TEXT,
"is_based_on_git" INTEGER DEFAULT false NOT NULL "is_based_on_git" INTEGER DEFAULT false NOT NULL

View file

@ -4,6 +4,10 @@
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning"> <div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
File on server exceeds 5 MB and cannot be edited from the UI. Edit it directly on the server. File on server exceeds 5 MB and cannot be edited from the UI. Edit it directly on the server.
</div> </div>
@elseif ($fileStorage->is_host_file)
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
This host file mount is bind-only. Coolify will not create, edit, load, chmod, or delete the source file.
</div>
@elseif ($isReadOnly) @elseif ($isReadOnly)
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning"> <div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
@if ($fileStorage->is_directory) @if ($fileStorage->is_directory)
@ -32,7 +36,14 @@
@if (!$isReadOnly) @if (!$isReadOnly)
@can('update', $resource) @can('update', $resource)
<div class="flex gap-2"> <div class="flex gap-2">
@if ($fileStorage->is_directory) @if ($fileStorage->is_host_file)
<x-modal-confirmation :ignoreWire="false" title="Confirm Host File Mount Removal?"
buttonTitle="Delete" isErrorButton submitAction="delete" :checkboxes="$hostFileDeletionCheckboxes"
:actions="['Only the mount configuration will be removed. The host file will not be deleted.']"
confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" />
@elseif ($fileStorage->is_directory)
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Conversion to File?" <x-modal-confirmation :ignoreWire="false" title="Confirm Directory Conversion to File?"
buttonTitle="Convert to file" submitAction="convertToFile" :actions="[ buttonTitle="Convert to file" submitAction="convertToFile" :actions="[
'All files in this directory will be permanently deleted and an empty file will be created in its place.', 'All files in this directory will be permanently deleted and an empty file will be created in its place.',
@ -68,7 +79,7 @@
@endif @endif
</div> </div>
@endcan @endcan
@if (!$fileStorage->is_directory) @if (!$fileStorage->is_directory && !$fileStorage->is_host_file)
@can('update', $resource) @can('update', $resource)
@if (data_get($resource, 'settings.is_preserve_repository_enabled')) @if (data_get($resource, 'settings.is_preserve_repository_enabled'))
<div class="w-full sm:w-96"> <div class="w-full sm:w-96">
@ -99,7 +110,7 @@
@endif @endif
@else @else
{{-- Read-only view --}} {{-- Read-only view --}}
@if (!$fileStorage->is_directory) @if (!$fileStorage->is_directory && !$fileStorage->is_host_file)
@can('update', $resource) @can('update', $resource)
<div class="flex gap-2"> <div class="flex gap-2">
<x-forms.button type="button" wire:click="loadStorageOnServer">Load from <x-forms.button type="button" wire:click="loadStorageOnServer">Load from

View file

@ -22,11 +22,13 @@
dropdownOpen: false, dropdownOpen: false,
volumeModalOpen: false, volumeModalOpen: false,
fileModalOpen: false, fileModalOpen: false,
hostFileModalOpen: false,
directoryModalOpen: false directoryModalOpen: false
}" }"
@close-storage-modal.window=" @close-storage-modal.window="
if ($event.detail === 'volume') volumeModalOpen = false; if ($event.detail === 'volume') volumeModalOpen = false;
if ($event.detail === 'file') fileModalOpen = false; if ($event.detail === 'file') fileModalOpen = false;
if ($event.detail === 'host-file') hostFileModalOpen = false;
if ($event.detail === 'directory') directoryModalOpen = false; if ($event.detail === 'directory') directoryModalOpen = false;
"> ">
<div class="relative" @click.outside="dropdownOpen = false"> <div class="relative" @click.outside="dropdownOpen = false">
@ -62,6 +64,15 @@ class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:b
</svg> </svg>
File Mount File Mount
</a> </a>
<a class="dropdown-item"
@click="hostFileModalOpen = true; dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Host File Mount
</a>
<a class="dropdown-item" <a class="dropdown-item"
@click="directoryModalOpen = true; dropdownOpen = false"> @click="directoryModalOpen = true; dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor" <svg class="size-4" fill="none" stroke="currentColor"
@ -188,14 +199,28 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
} }
})"> })">
<form class="flex flex-col w-full gap-2 rounded-sm" <form class="flex flex-col w-full gap-2 rounded-sm"
x-data="{
hostPath: @js($this->fileStorageHostPath()),
filePath: @entangle('file_storage_path'),
previewPath() {
const path = (this.filePath || '').trim();
return this.hostPath + (path === '' ? '/' : (path.startsWith('/') ? path : `/${path}`));
},
}"
wire:submit='submitFileStorage'> wire:submit='submitFileStorage'>
<div class="flex flex-col"> <div class="flex flex-col">
<div>Actual file mounted from the host system to the container.</div> <div>This file will be created on the host, then mounted into the container.</div>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="p-2 text-xs rounded-sm bg-neutral-100 dark:bg-coolgray-200">
<div class="mb-1 font-medium">Host file path</div>
<code class="break-all" x-text="previewPath()">{{ $this->fileStoragePreviewPath() }}</code>
</div>
<x-forms.input canGate="update" :canResource="$resource" <x-forms.input canGate="update" :canResource="$resource"
placeholder="/etc/nginx/nginx.conf" id="file_storage_path" placeholder="/etc/nginx/nginx.conf" id="file_storage_path"
label="Destination Path" required label="Destination Path" required
x-on:input="filePath = $event.target.value"
helper="File location inside the container" /> helper="File location inside the container" />
<x-forms.textarea canGate="update" :canResource="$resource" label="Content" <x-forms.textarea canGate="update" :canResource="$resource" label="Content"
id="file_storage_content"></x-forms.textarea> id="file_storage_content"></x-forms.textarea>
@ -209,6 +234,67 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
</div> </div>
</template> </template>
{{-- Host File Modal --}}
<template x-teleport="body">
<div x-show="hostFileModalOpen" @keydown.window.escape="hostFileModalOpen=false"
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
<div x-show="hostFileModalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" @click="hostFileModalOpen=false"
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="hostFileModalOpen" x-trap.inert.noscroll="hostFileModalOpen"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w-[36rem] max-w-fit bg-white border-neutral-200 dark:bg-base px-6 dark:border-coolgray-300">
<div class="flex items-center justify-between pb-3">
<h3 class="text-2xl font-bold">Add Host File Mount</h3>
<button @click="hostFileModalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="relative flex items-center justify-center w-auto"
x-init="$watch('hostFileModalOpen', value => {
if (value) {
$nextTick(() => {
const input = $el.querySelector('input');
input?.focus();
})
}
})">
<form class="flex flex-col w-full gap-2 rounded-sm"
wire:submit='submitHostFileStorage'>
<div class="flex flex-col">
<div>Bind an existing host file into the container. Coolify will not create, edit, load, chmod, or delete the source file.</div>
</div>
<div class="flex flex-col gap-2">
<x-forms.input canGate="update" :canResource="$resource"
placeholder="/etc/nginx/nginx.conf"
id="host_file_storage_source" label="Host File Path" required
helper="Existing file on the host system." />
<x-forms.input canGate="update" :canResource="$resource"
placeholder="/etc/nginx/nginx.conf"
id="host_file_storage_destination" label="Destination Path"
required helper="File location inside the container." />
<x-forms.button canGate="update" :canResource="$resource" type="submit">
Add
</x-forms.button>
</div>
</form>
</div>
</div>
</div>
</template>
{{-- Directory Modal --}} {{-- Directory Modal --}}
<template x-teleport="body"> <template x-teleport="body">
<div x-show="directoryModalOpen" @keydown.window.escape="directoryModalOpen=false" <div x-show="directoryModalOpen" @keydown.window.escape="directoryModalOpen=false"

View file

@ -2361,7 +2361,7 @@
"category": "automation", "category": "automation",
"logo": "svgs/inngest.png", "logo": "svgs/inngest.png",
"minversion": "0.0.0", "minversion": "0.0.0",
"template_last_updated_at": "2026-06-10T13:46:21+05:30", "template_last_updated_at": "2026-07-02T13:25:47+02:00",
"port": "8288" "port": "8288"
}, },
"invoice-ninja": { "invoice-ninja": {

View file

@ -2361,7 +2361,7 @@
"category": "automation", "category": "automation",
"logo": "svgs/inngest.png", "logo": "svgs/inngest.png",
"minversion": "0.0.0", "minversion": "0.0.0",
"template_last_updated_at": "2026-06-10T13:46:21+05:30", "template_last_updated_at": "2026-07-02T13:25:47+02:00",
"port": "8288" "port": "8288"
}, },
"invoice-ninja": { "invoice-ninja": {

View file

@ -0,0 +1,123 @@
<?php
use App\Jobs\ServerStorageSaveJob;
use App\Livewire\Project\Service\Storage;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\Project;
use App\Models\Server;
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\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
Bus::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first()
?? Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Test App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
});
test('livewire file storage rejects parent segments and does not create a local file volume', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->set('file_storage_path', '/../../../../../../etc/example.conf')
->set('file_storage_content', 'owned')
->call('submitFileStorage')
->assertDispatched('error');
expect(LocalFileVolume::query()->count())->toBe(0);
});
test('file mount modal shows the calculated host file path above the destination input', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->assertSeeText('This file will be created on the host, then mounted into the container.')
->assertSeeText('Host file path')
->assertSeeText($this->application->workdir().'/')
->set('file_storage_path', '/etc/nginx/nginx.conf')
->assertSeeText($this->application->workdir().'/etc/nginx/nginx.conf')
->assertDontSeeText('Actual file mounted from the host system to the container.');
});
test('livewire file storage stores safe file mounts under the application configuration root', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->set('file_storage_path', '/etc/nginx/nginx.conf')
->set('file_storage_content', 'server {}')
->call('submitFileStorage')
->assertDispatched('success');
$volume = LocalFileVolume::query()->sole();
expect($volume->mount_path)->toBe('/etc/nginx/nginx.conf')
->and($volume->fs_path)->toBe(application_configuration_dir().'/'.$this->application->uuid.'/etc/nginx/nginx.conf')
->and($volume->is_directory)->toBeFalse();
});
test('livewire host file storage stores an existing host file path without managed content', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->set('host_file_storage_source', '/etc/nginx/nginx.conf')
->set('host_file_storage_destination', '/etc/nginx/nginx.conf')
->call('submitHostFileStorage')
->assertDispatched('success');
$volume = LocalFileVolume::query()->sole();
expect($volume->fs_path)->toBe('/etc/nginx/nginx.conf')
->and($volume->mount_path)->toBe('/etc/nginx/nginx.conf')
->and($volume->content)->toBeNull()
->and($volume->is_host_file)->toBeTrue()
->and($volume->is_directory)->toBeFalse();
Bus::assertNotDispatched(ServerStorageSaveJob::class);
});

View file

@ -1,5 +1,6 @@
<?php <?php
use App\Jobs\ServerStorageSaveJob;
use App\Models\Application; use App\Models\Application;
use App\Models\Environment; use App\Models\Environment;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
@ -7,6 +8,8 @@
use App\Models\LocalPersistentVolume; use App\Models\LocalPersistentVolume;
use App\Models\Project; use App\Models\Project;
use App\Models\Server; use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker; use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use App\Models\Team; use App\Models\Team;
@ -18,8 +21,9 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function () { beforeEach(function () {
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
Bus::fake(); Bus::fake();
InstanceSettings::updateOrCreate(['id' => 0]); InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create(); $this->team = Team::factory()->create();
$this->user = User::factory()->create(); $this->user = User::factory()->create();
@ -61,6 +65,24 @@ function createTestDatabase($context): StandalonePostgresql
]); ]);
} }
function createTestServiceApplication($context): array
{
$service = Service::factory()->create([
'environment_id' => $context->environment->id,
'destination_id' => $context->destination->id,
'destination_type' => $context->destination->getMorphClass(),
]);
$serviceApplication = ServiceApplication::create([
'uuid' => (string) Str::uuid(),
'name' => 'test-service-app',
'service_id' => $service->id,
'image' => 'nginx:alpine',
]);
return [$service, $serviceApplication];
}
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// Application Storage Endpoints // Application Storage Endpoints
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
@ -140,6 +162,56 @@ function createTestDatabase($context): StandalonePostgresql
expect($vol)->not->toBeNull(); expect($vol)->not->toBeNull();
expect($vol->mount_path)->toBe('/app/config.json'); expect($vol->mount_path)->toBe('/app/config.json');
expect($vol->is_directory)->toBeFalse(); expect($vol->is_directory)->toBeFalse();
expect($vol->fs_path)->toBe(application_configuration_dir().'/'.$app->uuid.'/app/config.json');
});
test('creates bind only host file storage for application', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'file',
'is_host_file' => true,
'fs_path' => '/etc/nginx/nginx.conf',
'mount_path' => '/etc/nginx/nginx.conf',
]);
$response->assertStatus(201);
$vol = LocalFileVolume::where('resource_id', $app->id)
->where('resource_type', get_class($app))
->first();
expect($vol)->not->toBeNull();
expect($vol->fs_path)->toBe('/etc/nginx/nginx.conf');
expect($vol->mount_path)->toBe('/etc/nginx/nginx.conf');
expect($vol->content)->toBeNull();
expect($vol->is_host_file)->toBeTrue();
expect($vol->is_directory)->toBeFalse();
Bus::assertNotDispatched(ServerStorageSaveJob::class);
});
test('rejects file storage paths with parent segments', function () {
$app = createTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
'type' => 'file',
'mount_path' => '/../../../../../../etc/example.conf',
'content' => 'owned',
]);
$response->assertStatus(422);
$response->assertJsonPath('message', 'Validation failed.');
expect(LocalFileVolume::where('resource_id', $app->id)
->where('resource_type', get_class($app))
->exists())->toBeFalse();
}); });
test('rejects persistent storage without name', function () { test('rejects persistent storage without name', function () {
@ -331,6 +403,92 @@ function createTestDatabase($context): StandalonePostgresql
expect($vol)->not->toBeNull(); expect($vol)->not->toBeNull();
expect($vol->mount_path)->toBe('/extra'); expect($vol->mount_path)->toBe('/extra');
}); });
test('creates a file storage for a database under the database configuration root', function () {
$db = createTestDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$db->uuid}/storages", [
'type' => 'file',
'mount_path' => '/postgres/postgresql.conf',
'content' => 'listen_addresses = "*"',
]);
$response->assertStatus(201);
$vol = LocalFileVolume::where('resource_id', $db->id)
->where('resource_type', get_class($db))
->first();
expect($vol)->not->toBeNull();
expect($vol->fs_path)->toBe(database_configuration_dir().'/'.$db->uuid.'/postgres/postgresql.conf');
});
test('rejects file storage paths with parent segments for a database', function () {
$db = createTestDatabase($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$db->uuid}/storages", [
'type' => 'file',
'mount_path' => '/postgres/../../../etc/shadow',
'content' => 'owned',
]);
$response->assertStatus(422);
expect(LocalFileVolume::where('resource_id', $db->id)
->where('resource_type', get_class($db))
->exists())->toBeFalse();
});
});
describe('POST /api/v1/services/{uuid}/storages', function () {
test('creates a file storage for a service resource under the service configuration root', function () {
[$service, $serviceApplication] = createTestServiceApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/services/{$service->uuid}/storages", [
'type' => 'file',
'resource_uuid' => $serviceApplication->uuid,
'mount_path' => '/etc/nginx/nginx.conf',
'content' => 'server {}',
]);
$response->assertStatus(201);
$vol = LocalFileVolume::where('resource_id', $serviceApplication->id)
->where('resource_type', get_class($serviceApplication))
->first();
expect($vol)->not->toBeNull();
expect($vol->fs_path)->toBe(service_configuration_dir().'/'.$service->uuid.'/etc/nginx/nginx.conf');
});
test('rejects file storage paths with parent segments for a service resource', function () {
[$service, $serviceApplication] = createTestServiceApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/services/{$service->uuid}/storages", [
'type' => 'file',
'resource_uuid' => $serviceApplication->uuid,
'mount_path' => '/../../../../../../root/.ssh/authorized_keys',
'content' => 'owned',
]);
$response->assertStatus(422);
expect(LocalFileVolume::where('resource_id', $serviceApplication->id)
->where('resource_type', get_class($serviceApplication))
->exists())->toBeFalse();
});
}); });
describe('PATCH /api/v1/databases/{uuid}/storages', function () { describe('PATCH /api/v1/databases/{uuid}/storages', function () {

View file

@ -141,3 +141,77 @@
expect(fn () => validateShellSafePath('./data', 'storage path')) expect(fn () => validateShellSafePath('./data', 'storage path'))
->not->toThrow(Exception::class); ->not->toThrow(Exception::class);
}); });
test('file mount path validator rejects parent segments and unsafe separators', function (string $path) {
expect(fn () => validateFileMountPath($path, 'file storage path'))
->toThrow(Exception::class);
})->with([
'parent segment to etc' => ['/../../etc/passwd'],
'embedded parent segment' => ['/foo/../bar'],
'parent segment' => ['/..'],
'double slash before parent segment' => ['/foo//../bar'],
'current directory segment' => ['/foo/./bar'],
'backslash parent segment' => ['\\..\\etc\\passwd'],
'null byte' => ["/app/config\0/../../etc/passwd"],
]);
test('file mount path validator accepts safe absolute container file paths', function (string $path, string $expected) {
expect(validateFileMountPath($path, 'file storage path'))->toBe($expected);
})->with([
'nginx config' => ['/etc/nginx/nginx.conf', '/etc/nginx/nginx.conf'],
'app env filename' => ['/app/.env', '/app/.env'],
'relative input becomes absolute' => ['config/app.yaml', '/config/app.yaml'],
'duplicate slashes collapse' => ['/opt//app///config.json', '/opt/app/config.json'],
]);
test('host file mount path validator accepts absolute host file paths', function () {
expect(validateHostFileMountPath('/etc/nginx/nginx.conf', 'host file path'))
->toBe('/etc/nginx/nginx.conf');
});
test('host file mount path validator rejects ambiguous or directory paths', function (string $path) {
expect(fn () => validateHostFileMountPath($path, 'host file path'))
->toThrow(Exception::class);
})->with([
'relative path' => ['etc/nginx/nginx.conf'],
'root directory' => ['/'],
'trailing slash' => ['/etc/nginx/'],
'parent segment' => ['/etc/../shadow'],
'current segment' => ['/etc/./nginx.conf'],
'backslash' => ['\\etc\\nginx.conf'],
]);
test('confined path resolver keeps file mounts inside their resource configuration root', function () {
expect(confineFileMountPath('/data/coolify/applications/app-uuid', '/etc/nginx/nginx.conf', 'file storage path'))
->toBe('/data/coolify/applications/app-uuid/etc/nginx/nginx.conf');
expect(confineFileMountPath('/data/coolify/databases/db-uuid/', 'postgres/postgresql.conf', 'file storage path'))
->toBe('/data/coolify/databases/db-uuid/postgres/postgresql.conf');
expect(confineFileMountPath('/data/coolify/services/service-uuid', '/config.yaml', 'file storage path'))
->toBe('/data/coolify/services/service-uuid/config.yaml');
});
test('confined path resolver rejects paths that escape the resource configuration root', function (string $base, string $path) {
expect(fn () => confineFileMountPath($base, $path, 'file storage path'))
->toThrow(Exception::class);
})->with([
'application parent segment' => ['/data/coolify/applications/app-uuid', '/../../etc/passwd'],
'database parent segment' => ['/data/coolify/databases/db-uuid', '/postgres/../../../etc/shadow'],
'service dot segment' => ['/data/coolify/services/service-uuid', '/./config.yaml'],
]);
test('local file volume write sink keeps saved managed file paths for compatibility', function () {
$source = file_get_contents(__DIR__.'/../../app/Models/LocalFileVolume.php');
expect($source)->not->toContain('confinePathToBase($workdir, $path->value(), \'storage path\')')
->and($source)->toContain('tee {$escapedPath}');
});
test('host file mounts are bind-only and skipped by server storage writes', function () {
$source = file_get_contents(__DIR__.'/../../app/Models/LocalFileVolume.php');
expect($source)->toContain('if ($this->is_host_file) {')
->and($source)->toContain('return;')
->and($source)->toContain('tee {$escapedPath}');
});