This PR has a large blast radius because it takes the final step and bumps up the Controller to be fully async. We have historically done a piece-by-piece update, so all operations were already async but the controller interface was kept synchronous. With this update, the controller is now fully async and all tests are updated. Most places where the new C# compiler warns about function names not ending in `Async` were also adressed, giving a massive refactor change. Functionally, no changes are done.
195 lines
8.0 KiB
C#
195 lines
8.0 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.IO.Compression;
|
|
using System.Threading.Tasks;
|
|
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 async Task RestoreToSymlinkTargetAsync()
|
|
{
|
|
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 (var c = new Controller("file://" + this.TARGETFOLDER, this.TestOptions, null))
|
|
{
|
|
var backupResults = await c.BackupAsync(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.ThrowsAsync<UserInformationException>(() => c.RestoreAsync(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 async Task RestoreSymlinkPointingOutsideAsync()
|
|
{
|
|
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 = await c.BackupAsync(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 = await c.RestoreAsync(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 async Task RestoreRelativePathInDlistAsync()
|
|
{
|
|
// 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 = await c.BackupAsync(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 = await c.RepairAsync();
|
|
|
|
// 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");
|
|
}
|
|
}
|
|
}
|
|
}
|