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:
Andras Bacsai
2025-11-17 10:05:18 +01:00
parent a660dd8c83
commit 94560ea6c7
19 changed files with 1298 additions and 421 deletions
+98 -2
View File
@@ -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;
}