mirror of
https://github.com/tiennm99/coolify.git
synced 2026-09-02 14:20:13 +00:00
Improve storage mount path handling
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user