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");
+ }
+ }
+ }
+}