diff --git a/Duplicati/Library/Main/Operation/Restore/FileProcessor.cs b/Duplicati/Library/Main/Operation/Restore/FileProcessor.cs index 1ebc24b66..ef0c83e89 100644 --- a/Duplicati/Library/Main/Operation/Restore/FileProcessor.cs +++ b/Duplicati/Library/Main/Operation/Restore/FileProcessor.cs @@ -250,12 +250,28 @@ namespace Duplicati.Library.Main.Operation.Restore else { var foldername = SystemIO.IO_OS.PathGetDirectoryName(file.TargetPath); - if (await restoreDestination.CreateFolderIfNotExists(foldername, results.TaskControl.ProgressToken).ConfigureAwait(false)) - Logging.Log.WriteWarningMessage(LOGTAG, "CreateMissingFolder", null, @$"Creating missing folder ""{foldername}"" for file ""{file.TargetPath}"""); + try + { + if (await restoreDestination.CreateFolderIfNotExists(foldername, results.TaskControl.ProgressToken).ConfigureAwait(false)) + Logging.Log.WriteWarningMessage(LOGTAG, "CreateMissingFolder", null, @$"Creating missing folder ""{foldername}"" for file ""{file.TargetPath}"""); + } + catch (Exception ex) + { + Logging.Log.WriteErrorMessage(LOGTAG, "CreateMissingFolder", ex, @$"Error when trying to create missing folder ""{foldername}"" for file ""{file.TargetPath}"""); + } // Create an empty file, or truncate to 0 - using var fs = await restoreDestination.OpenWrite(file.TargetPath, results.TaskControl.ProgressToken).ConfigureAwait(false); - fs.SetLength(0); + try + { + using var fs = await restoreDestination.OpenWrite(file.TargetPath, results.TaskControl.ProgressToken).ConfigureAwait(false); + fs.SetLength(0); + } + catch (Exception ex) + { + Logging.Log.WriteErrorMessage(LOGTAG, "CreateEmptyFile", ex, "Error when creating empty file {0}", file.TargetPath); + continue; + } + if (missing_blocks.Count != 0) { await block_request.WriteAsync( @@ -303,8 +319,15 @@ namespace Duplicati.Library.Main.Operation.Restore else { var foldername = SystemIO.IO_OS.PathGetDirectoryName(file.TargetPath); - if (await restoreDestination.CreateFolderIfNotExists(foldername, results.TaskControl.ProgressToken).ConfigureAwait(false)) - Logging.Log.WriteWarningMessage(LOGTAG, "CreateMissingFolder", null, @$"Creating missing folder ""{foldername}"" for file ""{file.TargetPath}"""); + try + { + if (await restoreDestination.CreateFolderIfNotExists(foldername, results.TaskControl.ProgressToken).ConfigureAwait(false)) + Logging.Log.WriteWarningMessage(LOGTAG, "CreateMissingFolder", null, @$"Creating missing folder ""{foldername}"" for file ""{file.TargetPath}"""); + } + catch (Exception ex) + { + Logging.Log.WriteErrorMessage(LOGTAG, "CreateMissingFolder", ex, @$"Error when trying to create missing folder ""{foldername}"" for file ""{file.TargetPath}"""); + } fs = await restoreDestination.OpenReadWrite(file.TargetPath, results.TaskControl.ProgressToken).ConfigureAwait(false); } @@ -785,8 +808,19 @@ namespace Duplicati.Library.Main.Operation.Restore { // Reopen file with write permission fi.IsReadOnly = false; // The metadata handler will revert this back later. - using var f = await restoreDestination.OpenWrite(file.TargetPath, cancellationToken).ConfigureAwait(false); - f.SetLength(file.Length); + try + { + using var f = await restoreDestination.OpenWrite(file.TargetPath, cancellationToken).ConfigureAwait(false); + f.SetLength(file.Length); + } + catch (Exception) + { + lock (results) + { + results.BrokenLocalFiles.Add(file.TargetPath); + } + throw; + } } } } diff --git a/Duplicati/Library/SourceProvider/Builtin/FileRestoreDestinationProvider.cs b/Duplicati/Library/SourceProvider/Builtin/FileRestoreDestinationProvider.cs index b5cfc70b0..ffcb9f1aa 100644 --- a/Duplicati/Library/SourceProvider/Builtin/FileRestoreDestinationProvider.cs +++ b/Duplicati/Library/SourceProvider/Builtin/FileRestoreDestinationProvider.cs @@ -36,6 +36,7 @@ public class FileRestoreDestinationProvider(string mountedPath, bool allowRestor /// public Task ClearReadOnlyAttribute(string path, CancellationToken cancel) { + VerifyPath(path); var currentAttr = SystemIO.IO_OS.GetFileAttributes(path); SystemIO.IO_OS.SetFileAttributes(path, currentAttr & ~FileAttributes.ReadOnly); return Task.CompletedTask; @@ -55,6 +56,7 @@ public class FileRestoreDestinationProvider(string mountedPath, bool allowRestor /// public Task DeleteFile(string path, CancellationToken cancel) { + VerifyPath(path); SystemIO.IO_OS.FileDelete(path); return Task.CompletedTask; } @@ -62,6 +64,7 @@ public class FileRestoreDestinationProvider(string mountedPath, bool allowRestor /// public Task DeleteFolder(string path, CancellationToken cancel) { + VerifyPath(path); SystemIO.IO_OS.DirectoryDelete(path, true); return Task.CompletedTask; } @@ -73,15 +76,22 @@ public class FileRestoreDestinationProvider(string mountedPath, bool allowRestor /// public Task FileExists(string path, CancellationToken cancel) - => Task.FromResult(SystemIO.IO_OS.FileExists(path)); + { + VerifyPath(path); + return Task.FromResult(SystemIO.IO_OS.FileExists(path)); + } /// public Task GetFileLength(string path, CancellationToken cancel) - => Task.FromResult(SystemIO.IO_OS.FileLength(path)); + { + VerifyPath(path); + return Task.FromResult(SystemIO.IO_OS.FileLength(path)); + } /// public Task HasReadOnlyAttribute(string path, CancellationToken cancel) { + VerifyPath(path); var currentAttr = SystemIO.IO_OS.GetFileAttributes(path); return Task.FromResult(currentAttr.HasFlag(FileAttributes.ReadOnly)); } @@ -100,12 +110,17 @@ public class FileRestoreDestinationProvider(string mountedPath, bool allowRestor /// public Task OpenRead(string path, CancellationToken cancel) - => Task.FromResult(SystemIO.IO_OS.FileOpenRead(path)); - /// + { + VerifyPath(path); + return Task.FromResult(SystemIO.IO_OS.FileOpenRead(path)); + } /// public Task OpenReadWrite(string path, CancellationToken cancel) - => Task.FromResult(SystemIO.IO_OS.FileOpenReadWrite(path)); + { + VerifyPath(path); + return Task.FromResult(SystemIO.IO_OS.FileOpenReadWrite(path)); + } /// public Task OpenWrite(string path, CancellationToken cancel) diff --git a/Duplicati/UnitTest/RestorePathTraversalTests.cs b/Duplicati/UnitTest/RestorePathTraversalTests.cs new file mode 100644 index 000000000..8617d918f --- /dev/null +++ b/Duplicati/UnitTest/RestorePathTraversalTests.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.IO.Compression; +using Duplicati.Library.Common.IO; +using Duplicati.Library.Interface; +using Duplicati.Library.Main; +using NUnit.Framework; +using Assert = NUnit.Framework.Legacy.ClassicAssert; + +namespace Duplicati.UnitTest +{ + public class RestorePathTraversalTests : BasicSetupHelper + { + [Test] + [Category("RestoreHandler")] + public void RestoreToSymlinkTarget() + { + if (OperatingSystem.IsWindows()) + Assert.Ignore("Symlink tests are not supported on Windows"); + + // 1. Setup source data + var sourceFolder = Path.Combine(this.DATAFOLDER, "source"); + var subDir = Path.Combine(sourceFolder, "subdir"); + Directory.CreateDirectory(subDir); + var filePath = Path.Combine(subDir, "file.txt"); + File.WriteAllText(filePath, "secret data"); + + // 2. Backup + using (Controller c = new Controller("file://" + this.TARGETFOLDER, this.TestOptions, null)) + { + var backupResults = c.Backup(new[] { sourceFolder }); + Assert.AreEqual(0, backupResults.Errors.Count()); + Assert.Greater(backupResults.ExaminedFiles, 0); + } + + // 3. Setup malicious restore target + var restoreTarget = this.RESTOREFOLDER; + var outsideTarget = Path.Combine(this.DATAFOLDER, "outside"); + Directory.CreateDirectory(outsideTarget); + + // Create a symlink in the restore target: restoreTarget/subdir -> outsideTarget + var symlinkPath = Path.Combine(restoreTarget, "subdir"); + // Ensure restoreTarget exists + Directory.CreateDirectory(restoreTarget); + + // Create the symlink + SystemIO.IO_OS.CreateSymlink(symlinkPath, outsideTarget, true); + + // 4. Restore + var restoreOptions = new Dictionary(this.TestOptions); + restoreOptions["restore-path"] = restoreTarget; + restoreOptions["overwrite"] = "true"; + + using (var c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null)) + { + // We expect a UserInformationException due to path traversal detection + var ex = NUnit.Framework.Assert.Throws(() => c.Restore(null)); + NUnit.Framework.Assert.That(ex.Message.Contains("Path traversal detected"), "Expected path traversal error message"); + } + + // 5. Verify vulnerability + // If vulnerable, the file will be in outsideTarget/file.txt + var escapedFile = Path.Combine(outsideTarget, "file.txt"); + var isVulnerable = File.Exists(escapedFile); + + var symlinkExists = (File.GetAttributes(symlinkPath) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; + + Console.WriteLine($"Escaped file exists: {isVulnerable}"); + Console.WriteLine($"Symlink exists: {symlinkExists}"); + + Assert.IsFalse(isVulnerable, "File escaped to outside folder via symlink!"); + } + + [Test] + [Category("RestoreHandler")] + public void RestoreSymlinkPointingOutside() + { + if (OperatingSystem.IsWindows()) + Assert.Ignore("Symlink tests are not supported on Windows"); + + // 1. Setup source data with a symlink pointing outside + var sourceFolder = Path.Combine(this.DATAFOLDER, "source"); + Directory.CreateDirectory(sourceFolder); + var outsideTarget = Path.Combine(this.DATAFOLDER, "outside"); + Directory.CreateDirectory(outsideTarget); + + var symlinkPath = Path.Combine(sourceFolder, "link_outside"); + SystemIO.IO_OS.CreateSymlink(symlinkPath, outsideTarget, true); + + // 2. Backup + using (var c = new Controller("file://" + this.TARGETFOLDER, this.TestOptions, null)) + { + var backupResults = c.Backup(new[] { sourceFolder }); + Assert.AreEqual(0, backupResults.Errors.Count()); + } + + // 3. Restore to a new location + var restoreTarget = this.RESTOREFOLDER; + var restoreOptions = new Dictionary(this.TestOptions); + restoreOptions["restore-path"] = restoreTarget; + restoreOptions["skip-metadata"] = "false"; + + using (var c = new Controller("file://" + this.TARGETFOLDER, restoreOptions, null)) + { + var restoreResults = c.Restore(null); + Assert.AreEqual(0, restoreResults.Errors.Count()); + } + + // 4. Verify symlink was NOT created + var restoredLink = Path.Combine(restoreTarget, "source", "link_outside"); + + // Check if it exists as a file, directory, or reparse point + var linkExists = false; + try + { + var attr = File.GetAttributes(restoredLink); // Throws if not found + linkExists = true; + } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + + Assert.IsFalse(linkExists, "Symlink pointing outside should not be created"); + } + + [Test] + [Category("RestoreHandler")] + public void RestoreRelativePathInDlist() + { + // 1. Setup source data + var sourceFolder = Path.Combine(this.DATAFOLDER, "source"); + Directory.CreateDirectory(sourceFolder); + var filePath = Path.Combine(sourceFolder, "file.txt"); + File.WriteAllText(filePath, "data"); + + var options = new Dictionary(this.TestOptions); + options["no-encryption"] = "true"; + options.Remove("passphrase"); + + // 2. Backup + using (var c = new Controller("file://" + this.TARGETFOLDER, options, null)) + { + IBackupResults backupResults = c.Backup(new[] { sourceFolder }); + Assert.AreEqual(0, backupResults.Errors.Count()); + } + + // 3. Manipulate dlist + var dlistFiles = Directory.GetFiles(this.TARGETFOLDER, "*dlist*"); + Assert.AreEqual(1, dlistFiles.Length); + var dlistPath = dlistFiles[0]; + + // Unzip, modify, zip + var tempDir = Path.Combine(this.DATAFOLDER, "temp_dlist"); + Directory.CreateDirectory(tempDir); + ZipFile.ExtractToDirectory(dlistPath, tempDir); + File.Delete(dlistPath); + + var filelistPath = Directory.GetFiles(tempDir, "*filelist*").FirstOrDefault(); + Assert.IsNotNull(filelistPath); + + var json = File.ReadAllText(filelistPath); + // Replace path with relative path traversal + // We replace "file.txt" with "../evil.txt" + // This creates a path like "/path/to/source/../evil.txt" which contains traversal + json = json.Replace("file.txt", "../evil.txt"); + File.WriteAllText(filelistPath, json); + + // Re-zip + ZipFile.CreateFromDirectory(tempDir, dlistPath); + + // 4. Recreate database + File.Delete(this.DBFILE); + + using (var c = new Controller("file://" + this.TARGETFOLDER, options, null)) + { + var repairResults = c.Repair(); + + // We expect warnings about invalid path + var foundWarning = repairResults.Warnings.Any(w => w.Contains("Path traversal detected") || w.Contains("Invalid path")); + + if (!foundWarning) + { + Console.WriteLine("Warnings found:"); + foreach (var w in repairResults.Warnings) + Console.WriteLine(w); + } + + Assert.IsTrue(foundWarning, "Expected warning about invalid path in dlist"); + } + } + } +}