mirror of
https://github.com/tiennm99/coolify.git
synced 2026-05-02 08:20:55 +00:00
feat: streamline S3 restore with single-step flow and improved UI consistency
Major architectural improvements: - Merged download and restore into single atomic operation - Eliminated separate S3DownloadFinished event (redundant) - Files now transfer directly: S3 → helper container → server → database container - Removed download progress tracking in favor of unified restore progress UI/UX improvements: - Unified restore method selection with visual cards - Consistent "File Information" display between local and S3 restore - Single slide-over for all restore operations (removed separate S3 download monitor) - Better visual feedback with loading states Security enhancements: - Added isSafeTmpPath() helper for path traversal protection - URL decode validation to catch encoded attacks - Canonical path resolution to prevent symlink attacks - Comprehensive path validation in all cleanup events Cleanup improvements: - S3RestoreJobFinished now handles all cleanup (helper container + all temp files) - RestoreJobFinished uses new isSafeTmpPath() validation - CoolifyTask dispatches cleanup events even on job failure - All cleanup uses non-throwing commands (2>/dev/null || true) Other improvements: - S3 storage policy authorization on Show component - Storage Form properly syncs is_usable state after test - Removed debug code and improved error handling - Better command organization and documentation 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3155,9 +3155,14 @@ function generateDockerComposeServiceName(mixed $services, int $pullRequestId =
|
||||
return $collection;
|
||||
}
|
||||
|
||||
function formatBytes(int $bytes, int $precision = 2): string
|
||||
function formatBytes(?int $bytes, int $precision = 2): string
|
||||
{
|
||||
if ($bytes === 0) {
|
||||
if ($bytes === null || $bytes === 0) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
// Handle negative numbers
|
||||
if ($bytes < 0) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
@@ -3170,3 +3175,94 @@ function formatBytes(int $bytes, int $precision = 2): string
|
||||
|
||||
return round($value, $precision).' '.$units[$exponent];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a file path is safely within the /tmp/ directory.
|
||||
* Protects against path traversal attacks 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.
|
||||
*/
|
||||
function isSafeTmpPath(?string $path): bool
|
||||
{
|
||||
if (blank($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// URL decode to catch encoded traversal attempts
|
||||
$decodedPath = urldecode($path);
|
||||
|
||||
// Minimum length check - /tmp/x is 6 chars
|
||||
if (strlen($decodedPath) < 6) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must start with /tmp/
|
||||
if (! str($decodedPath)->startsWith('/tmp/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Quick check for obvious traversal attempts
|
||||
if (str($decodedPath)->contains('..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for null bytes (directory traversal technique)
|
||||
if (str($decodedPath)->contains("\0")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove any trailing slashes for consistent validation
|
||||
$normalizedPath = rtrim($decodedPath, '/');
|
||||
|
||||
// Normalize the path by removing redundant separators and resolving . and ..
|
||||
// We'll do this manually since realpath() requires the path to exist
|
||||
$parts = explode('/', $normalizedPath);
|
||||
$resolvedParts = [];
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if ($part === '' || $part === '.') {
|
||||
// Skip empty parts (from //) and current directory references
|
||||
continue;
|
||||
} elseif ($part === '..') {
|
||||
// Parent directory - this should have been caught earlier but double-check
|
||||
return false;
|
||||
} else {
|
||||
$resolvedParts[] = $part;
|
||||
}
|
||||
}
|
||||
|
||||
$resolvedPath = '/'.implode('/', $resolvedParts);
|
||||
|
||||
// Final check: resolved path must start with /tmp/
|
||||
// And must have at least one component after /tmp/
|
||||
if (! str($resolvedPath)->startsWith('/tmp/') || $resolvedPath === '/tmp') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolve the canonical /tmp path (handles symlinks like /tmp -> /private/tmp on macOS)
|
||||
$canonicalTmpPath = realpath('/tmp');
|
||||
if ($canonicalTmpPath === false) {
|
||||
// If /tmp doesn't exist, something is very wrong, but allow non-existing paths
|
||||
$canonicalTmpPath = '/tmp';
|
||||
}
|
||||
|
||||
// If the directory exists, resolve it via realpath to catch symlink attacks
|
||||
if (file_exists($resolvedPath) || is_dir(dirname($resolvedPath))) {
|
||||
// For existing paths, resolve to absolute path to catch symlinks
|
||||
$dirPath = dirname($resolvedPath);
|
||||
if (is_dir($dirPath)) {
|
||||
$realDir = realpath($dirPath);
|
||||
if ($realDir === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the real directory is within /tmp (or its canonical path)
|
||||
if (! str($realDir)->startsWith('/tmp') && ! str($realDir)->startsWith($canonicalTmpPath)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user