Made the restore more robust in the event of failures with individual files/directories.

This commit is contained in:
Kenneth Skovhede
2026-01-27 17:06:52 +01:00
parent 655d6cbe65
commit d4eace8544
3 changed files with 255 additions and 13 deletions
@@ -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;
}
}
}
}
@@ -36,6 +36,7 @@ public class FileRestoreDestinationProvider(string mountedPath, bool allowRestor
/// <inheritdoc />
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
/// <inheritdoc />
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
/// <inheritdoc />
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
/// <inheritdoc />
public Task<bool> FileExists(string path, CancellationToken cancel)
=> Task.FromResult(SystemIO.IO_OS.FileExists(path));
{
VerifyPath(path);
return Task.FromResult(SystemIO.IO_OS.FileExists(path));
}
/// <inheritdoc />
public Task<long> GetFileLength(string path, CancellationToken cancel)
=> Task.FromResult(SystemIO.IO_OS.FileLength(path));
{
VerifyPath(path);
return Task.FromResult(SystemIO.IO_OS.FileLength(path));
}
/// <inheritdoc />
public Task<bool> 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
/// <inheritdoc />
public Task<Stream> OpenRead(string path, CancellationToken cancel)
=> Task.FromResult<Stream>(SystemIO.IO_OS.FileOpenRead(path));
/// <inheritdoc />
{
VerifyPath(path);
return Task.FromResult<Stream>(SystemIO.IO_OS.FileOpenRead(path));
}
/// <inheritdoc />
public Task<Stream> OpenReadWrite(string path, CancellationToken cancel)
=> Task.FromResult<Stream>(SystemIO.IO_OS.FileOpenReadWrite(path));
{
VerifyPath(path);
return Task.FromResult<Stream>(SystemIO.IO_OS.FileOpenReadWrite(path));
}
/// <inheritdoc />
public Task<Stream> OpenWrite(string path, CancellationToken cancel)
@@ -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<string, string>(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<UserInformationException>(() => 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<string, string>(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<string, string>(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");
}
}
}
}