Made the restore more robust in the event of failures with individual files/directories.
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user