Improve storage mount path handling

This commit is contained in:
Andras Bacsai
2026-07-02 14:54:38 +02:00
parent 39ae16de42
commit a06c1a7bf5
17 changed files with 979 additions and 55 deletions
+196 -5
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).
*
* 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.