diff --git a/Duplicati/CommandLine/RecoveryTool/Restore.cs b/Duplicati/CommandLine/RecoveryTool/Restore.cs index 0c6f95844..0bdf5d9df 100644 --- a/Duplicati/CommandLine/RecoveryTool/Restore.cs +++ b/Duplicati/CommandLine/RecoveryTool/Restore.cs @@ -105,6 +105,7 @@ namespace Duplicati.CommandLine.RecoveryTool var useIndexMap = !Library.Utility.Utility.ParseBoolOption(options, "reduce-memory-use"); var disableFileVerify = Library.Utility.Utility.ParseBoolOption(options, "disable-file-verify"); + var allowPathTraversal = Library.Utility.Utility.ParseBoolOption(options, "allow-restore-outside-target-directory"); var startedCount = 0L; var restoredCount = 0L; @@ -153,6 +154,10 @@ namespace Duplicati.CommandLine.RecoveryTool try { var targetfile = MapToRestorePath(f.Path, largestprefix, targetpath, sourceDirsep); + + if (!allowPathTraversal && !string.IsNullOrWhiteSpace(targetpath) && !Util.IsPathInsideTarget(targetfile, targetpath)) + throw new UserInformationException($"Path traversal detected: {targetfile} resolves outside {targetpath}", "RestorePathTraversal"); + if (!systemIO.DirectoryExists(systemIO.PathGetDirectoryName(targetfile))) systemIO.DirectoryCreate(systemIO.PathGetDirectoryName(targetfile)); diff --git a/Duplicati/UnitTest/RecoveryToolPathTraversalTests.cs b/Duplicati/UnitTest/RecoveryToolPathTraversalTests.cs new file mode 100644 index 000000000..fd492ee89 --- /dev/null +++ b/Duplicati/UnitTest/RecoveryToolPathTraversalTests.cs @@ -0,0 +1,119 @@ +// Copyright (C) 2026, The Duplicati Team +// https://duplicati.com, hello@duplicati.com +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.IO.Compression; +using System.Threading.Tasks; +using Duplicati.Library.Interface; +using Duplicati.Library.Main; +using NUnit.Framework; +using Assert = NUnit.Framework.Legacy.ClassicAssert; + +namespace Duplicati.UnitTest +{ + public class RecoveryToolPathTraversalTests : BasicSetupHelper + { + private string originalCurrentDirectory; + + [SetUp] + public void SetUp() + { + this.originalCurrentDirectory = Directory.GetCurrentDirectory(); + if (File.Exists("/tmp/evil.txt")) + File.Delete("/tmp/evil.txt"); + } + + [TearDown] + public void TearDown() + { + if (this.originalCurrentDirectory != null) + { + Directory.SetCurrentDirectory(this.originalCurrentDirectory); + } + if (File.Exists("/tmp/evil.txt")) + File.Delete("/tmp/evil.txt"); + } + + [Test] + [Category("RecoveryTool")] + public async Task RecoveryToolRestoreRelativePathInDlistAsync() + { + // 1. Setup source data + var sourceFolder = Path.Combine(this.DATAFOLDER, "source"); + Directory.CreateDirectory(sourceFolder); + var subDir = Path.Combine(sourceFolder, "subdir"); + Directory.CreateDirectory(subDir); + File.WriteAllText(Path.Combine(subDir, "file1.txt"), "data1"); + File.WriteAllText(Path.Combine(subDir, "file2.txt"), "data2"); + + 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 = await c.BackupAsync(new[] { sourceFolder }); + Assert.AreEqual(0, backupResults.Errors.Count()); + } + + // 3. Manipulate dlist + var dlistFiles = Directory.GetFiles(this.TARGETFOLDER, "*dlist*"); + var dlistPath = dlistFiles[0]; + + 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(); + var json = File.ReadAllText(filelistPath); + // Replace ONLY ONE file with traversal, keep the other + json = json.Replace("subdir/file2.txt", "subdir/../../../../../../../../../../../tmp/evil.txt"); + File.WriteAllText(filelistPath, json); + + ZipFile.CreateFromDirectory(tempDir, dlistPath); + + // 4. Download and Index + var downloadFolder = Path.Combine(this.RESTOREFOLDER, "downloadedFiles"); + Directory.CreateDirectory(downloadFolder); + CommandLine.RecoveryTool.Program.Main(new[] { "download", "file://" + this.TARGETFOLDER, downloadFolder }); + CommandLine.RecoveryTool.Program.Main(new[] { "index", downloadFolder }); + + // 5. Restore with RecoveryTool + var restoreFolder = Path.Combine(this.RESTOREFOLDER, "restoredFiles"); + Directory.CreateDirectory(restoreFolder); + + Console.WriteLine("====== BEGIN RESTORE ======"); + var status = CommandLine.RecoveryTool.Program.Main(new[] { "restore", downloadFolder, $"--targetpath={restoreFolder}" }); + Console.WriteLine("====== END RESTORE ======"); + + var evilPath = "/tmp/evil.txt"; + var exists = File.Exists(evilPath); + Console.WriteLine("Evil path exists: " + exists); + + Assert.AreNotEqual(0, status, "Expected restore to fail due to path traversal"); + } + } +}