From c6d8215674cb330a9e5967b36ef10720d86d3e6b Mon Sep 17 00:00:00 2001 From: Sebastian Meier zu Biesen Date: Thu, 25 Sep 2025 17:33:19 +0200 Subject: [PATCH] Zipslip fix for restore and steamcmd install. --- src/managers/backupmgr/restore.go | 35 ++++++++++++++++++++++++------- src/steamcmd/steamcmd-helper.go | 34 +++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/managers/backupmgr/restore.go b/src/managers/backupmgr/restore.go index 2d3bcd9f..1a07a80b 100644 --- a/src/managers/backupmgr/restore.go +++ b/src/managers/backupmgr/restore.go @@ -83,25 +83,43 @@ func (m *BackupManager) RestoreBackup(index int) error { } defer r.Close() + // --- Safe extraction ------------------------------------------------- for _, f := range r.File { - path := filepath.Join(tempDir, f.Name) + // Sanitize the entry name – strip any leading / or .. components. + entryName := filepath.Clean(f.Name) + + // Skip empty names or names that contain '..' after cleaning. + if entryName == "." || entryName == ".." || strings.Contains(entryName, "..") { + // This entry would escape the target directory; reject it. + logger.Backup.Warn(fmt.Sprintf("Skipping potentially unsafe zip entry %q", f.Name)) + continue + } + + destPath := filepath.Join(tempDir, entryName) + // Ensure the destination is still inside tempDir. + if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(tempDir)+string(os.PathSeparator)) { + logger.Backup.Warn(fmt.Sprintf("Skipping zip entry that would escape extraction dir: %q", f.Name)) + continue + } + if f.FileInfo().IsDir() { - if err := os.MkdirAll(path, f.Mode()); err != nil { + if err := os.MkdirAll(destPath, f.Mode()); err != nil { m.revertRestore(restoredFiles) - return fmt.Errorf("failed to create directory %s: %w", path, err) + return fmt.Errorf("failed to create directory %s: %w", destPath, err) } continue } - if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { + // Create any missing parent directories. + if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil { m.revertRestore(restoredFiles) - return fmt.Errorf("failed to create parent directory for %s: %w", path, err) + return fmt.Errorf("failed to create parent directory for %s: %w", destPath, err) } - outFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) if err != nil { m.revertRestore(restoredFiles) - return fmt.Errorf("failed to create file %s: %w", path, err) + return fmt.Errorf("failed to create file %s: %w", destPath, err) } rc, err := f.Open() @@ -115,11 +133,12 @@ func (m *BackupManager) RestoreBackup(index int) error { rc.Close() outFile.Close() m.revertRestore(restoredFiles) - return fmt.Errorf("failed to extract file %s: %w", path, err) + return fmt.Errorf("failed to extract file %s: %w", destPath, err) } rc.Close() outFile.Close() } + // -------------------------------------------------------------------- // Update world_meta.xml DateTime with current Windows file time using regex now := time.Now() diff --git a/src/steamcmd/steamcmd-helper.go b/src/steamcmd/steamcmd-helper.go index 23e2b977..617aeacb 100644 --- a/src/steamcmd/steamcmd-helper.go +++ b/src/steamcmd/steamcmd-helper.go @@ -20,6 +20,30 @@ import ( "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" ) +// isRelSymlink ensures `link` resolves to a path within `root`. +// This way we can avoid directory traversal attacks via symlinks. +// isSymlinkInsideRoot checks that a symlink named `name` with target `link` +// can be safely created under `root` without escaping it. +func isSymlinkInsideRoot(name, link, root string) bool { + // 1. The symlink file itself must stay inside `root`. + targetPath := filepath.Join(root, name) + if !strings.HasPrefix(filepath.Clean(targetPath), root) { + return false + } + + // 2. Resolve the link *relative to the symlink’s directory*. + // Do NOT call EvalSymlinks – we only care about the *path*. + linkDir := filepath.Dir(targetPath) // dir where the symlink will live + abs := filepath.Clean(filepath.Join(linkDir, link)) // e.g. /tmp/extract/../etc/passwd → /etc/passwd + + // 3. Ensure the absolute target is still under `root`. + rel, err := filepath.Rel(root, abs) + if err != nil { + return false + } + return !strings.HasPrefix(rel, "..") && !strings.HasPrefix(abs, string(os.PathSeparator)) +} + // createSteamCMDDirectory creates the SteamCMD directory. func createSteamCMDDirectory(steamCMDDir string) error { if err := os.MkdirAll(steamCMDDir, os.ModePerm); err != nil { @@ -169,8 +193,16 @@ func untar(dest string, r io.Reader) error { return fmt.Errorf("failed to write file %s: %v", target, err) } case tar.TypeSymlink: + // `header.Name` = path to symlink (relative to dest) + // `header.Linkname` = symlink target (relative or absolute) + if !isSymlinkInsideRoot(header.Name, header.Linkname, dest) { + logger.Install.Warn(fmt.Sprintf("Skipping unsafe symlink %s → %s", header.Name, header.Linkname)) + return fmt.Errorf("symlink %s → %s points outside extraction root", header.Name, header.Linkname) + } + + // If we reach here, the symlink is safe if err := os.Symlink(header.Linkname, target); err != nil { - return fmt.Errorf("failed to create symlink %s: %v", target, err) + return fmt.Errorf("failed to create symlink %s → %s: %w", target, header.Linkname, err) } default: return fmt.Errorf("unknown type: %v in %s", header.Typeflag, header.Name)