diff --git a/Directory.Packages.props b/Directory.Packages.props index 3e7aeed1b6..f86b9f16f1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,7 @@ + diff --git a/XcpNgCenter.Shell/XcpNgCenter.Shell.csproj b/XcpNgCenter.Shell/XcpNgCenter.Shell.csproj index c9639c81f1..695f0b9ebf 100644 --- a/XcpNgCenter.Shell/XcpNgCenter.Shell.csproj +++ b/XcpNgCenter.Shell/XcpNgCenter.Shell.csproj @@ -29,5 +29,7 @@ + + diff --git a/XcpNgCenter.Shell/packages.lock.json b/XcpNgCenter.Shell/packages.lock.json index 0afb2ee16a..780dc9aef9 100644 --- a/XcpNgCenter.Shell/packages.lock.json +++ b/XcpNgCenter.Shell/packages.lock.json @@ -41,6 +41,15 @@ "resolved": "8.4.0", "contentHash": "tqVU8yc/ADO9oiTRyTnwhFN68hCwvkliMierptWOudIAvWY1mWCh5VFh+guwHJmpMwfg0J0rY+yyd5Oy7ty9Uw==" }, + "Tmds.DBus.Protocol": { + "type": "Direct", + "requested": "[0.21.3, )", + "resolved": "0.21.3", + "contentHash": "hDwB8WsQoyALQKqIbwzS68UKdlnafDm4T/DkO/JrA/YIneP/rKv96SxYPVXeh3FP4i/SXfShrYftKLtciJAIlw==", + "dependencies": { + "System.IO.Pipelines": "8.0.0" + } + }, "Avalonia.Angle.Windows.Natives": { "type": "Transitive", "resolved": "2.1.22045.20230930", @@ -228,14 +237,6 @@ "resolved": "5.0.0", "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" }, - "Tmds.DBus.Protocol": { - "type": "Transitive", - "resolved": "0.20.0", - "contentHash": "2gkt2kuYPhDKd8gtl34jZSJOnn4nRJfFngCDcTZT/uySbK++ua0YQx2418l9Rn1Y4dE5XNq6zG9ZsE5ltLlNNw==", - "dependencies": { - "System.IO.Pipelines": "8.0.0" - } - }, "commandlib": { "type": "Project" }, @@ -257,7 +258,7 @@ "xenmodel": { "type": "Project", "dependencies": { - "CommandLib": "[0.0.0.26202, )", + "CommandLib": "[2026.8.3, )", "DiscUtils.Iso9660": "[0.16.13, )", "DiscUtils.Vhd": "[0.16.13, )", "DiscUtils.Vhdx": "[0.16.13, )", @@ -265,8 +266,8 @@ "DiscUtils.Wim": "[0.16.13, )", "Newtonsoft.Json": "[13.0.4, )", "System.Drawing.Common": "[8.0.0, )", - "XenCenterLib": "[0.0.0.26202, )", - "XenOvfApi": "[0.0.0.26202, )", + "XenCenterLib": "[2026.8.3, )", + "XenOvfApi": "[2026.8.3, )", "lzfse-net": "[1.0.15, )", "lzo.net": "[0.0.6, )" } @@ -275,7 +276,7 @@ "type": "Project", "dependencies": { "System.Management": "[8.0.0, )", - "XenCenterLib": "[0.0.0.26202, )" + "XenCenterLib": "[2026.8.3, )" } }, "DiscUtils.Iso9660": { @@ -390,96 +391,6 @@ "resolved": "8.0.0", "contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg==" } - }, - "net8.0/linux-x64": { - "Avalonia.Angle.Windows.Natives": { - "type": "Transitive", - "resolved": "2.1.22045.20230930", - "contentHash": "Bo3qOhKC1b84BIhiogndMdAzB3UrrESKK7hS769f5HWeoMw/pcd42US5KFYW2JJ4ZSTrXnP8mXwLTMzh+S+9Lg==" - }, - "Avalonia.Native": { - "type": "Transitive", - "resolved": "11.2.5", - "contentHash": "zubOZRBqOETn9uque9pEmUERIasSRL/Va4HQ+dtd+uA+2ThVL9Jo+yp3+NeV2qwrLUcAA/uNd9C+WmG4qB6MvA==", - "dependencies": { - "Avalonia": "11.2.5" - } - }, - "HarfBuzzSharp.NativeAssets.Linux": { - "type": "Transitive", - "resolved": "7.3.0.3", - "contentHash": "hkcHeTfOyIeJuPtO/QfoqkDvV/MXebZYaA/Bn/S+nXsjH3Wt9oQ6okH2kklYO+1UUdBSJFd67bi9IrpQXI2mPw==", - "dependencies": { - "HarfBuzzSharp": "7.3.0.3" - } - }, - "HarfBuzzSharp.NativeAssets.macOS": { - "type": "Transitive", - "resolved": "7.3.0.3", - "contentHash": "UAwIYnkbBTzBJv1Id8FijY/i8QiIepRemSXufU8fyzwWhYJdx4+ajG8yQUie5HW/uusbVLFSr26muSlJOFDgSw==" - }, - "HarfBuzzSharp.NativeAssets.Win32": { - "type": "Transitive", - "resolved": "7.3.0.3", - "contentHash": "RPxRXD16KtSs8Yxr2RK9Qs7AwyN9MlpqZIYs0AvfaJwl7RAtVhC0+u2f2SKwX0uMYYd3O98Z+OBA1sj6aWVKQA==" - }, - "Microsoft.Win32.SystemEvents": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "9opKRyOKMCi2xJ7Bj7kxtZ1r9vbzosMvRrdEhVhDz8j8MoBGgB+WmC94yH839NPH+BclAjtQ/pyagvi/8gDLkw==" - }, - "SkiaSharp.NativeAssets.Linux": { - "type": "Transitive", - "resolved": "2.88.9", - "contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==", - "dependencies": { - "SkiaSharp": "2.88.9" - } - }, - "SkiaSharp.NativeAssets.macOS": { - "type": "Transitive", - "resolved": "2.88.9", - "contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA==" - }, - "SkiaSharp.NativeAssets.Win32": { - "type": "Transitive", - "resolved": "2.88.9", - "contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w==" - }, - "System.Diagnostics.EventLog": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "fdYxcRjQqTTacKId/2IECojlDSFvp7LP5N78+0z/xH7v/Tuw5ZAxu23Y6PTCRinqyu2ePx+Gn1098NC6jM6d+A==" - }, - "System.Security.AccessControl": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "5.0.0", - "System.Security.Principal.Windows": "5.0.0" - } - }, - "System.Security.Principal.Windows": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" - }, - "lzfse-net": { - "type": "CentralTransitive", - "requested": "[1.0.15, )", - "resolved": "1.0.15", - "contentHash": "O5AhPNxnNhI9Ena8b+VJ1hhgNac8rRmd1rnYopSEWHetU/1Z5xOFPuWT4nxpy2u5Qk7evS5kFj1dRDhcArBtpg==" - }, - "System.Management": { - "type": "CentralTransitive", - "requested": "[8.0.0, )", - "resolved": "8.0.0", - "contentHash": "jrK22i5LRzxZCfGb+tGmke2VH7oE0DvcDlJ1HAKYU8cPmD8XnpUT0bYn2Gy98GEhGjtfbR/sxKTVb+dE770pfA==", - "dependencies": { - "System.CodeDom": "8.0.0" - } - } } } } \ No newline at end of file diff --git a/XenCenterLib.Tests/ArchivePathTests.cs b/XenCenterLib.Tests/ArchivePathTests.cs new file mode 100644 index 0000000000..a0e5478a9e --- /dev/null +++ b/XenCenterLib.Tests/ArchivePathTests.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text; +using XenCenterLib.Archive; +using Xunit; + +namespace XenCenterLib.Tests +{ + public class ArchivePathTests + { + [Fact] + public void GetSafeExtractPath_NullOrEmptyDestination_ThrowsArgumentNullException() + { + Assert.Throws(() => ArchivePath.GetSafeExtractPath(null, "file.txt")); + Assert.Throws(() => ArchivePath.GetSafeExtractPath("", "file.txt")); + } + + [Fact] + public void GetSafeExtractPath_NullOrEmptyEntryName_ThrowsInvalidDataException() + { + Assert.Throws(() => ArchivePath.GetSafeExtractPath(Path.GetTempPath(), null)); + Assert.Throws(() => ArchivePath.GetSafeExtractPath(Path.GetTempPath(), "")); + } + + [Theory] + [InlineData("../evil.txt")] + [InlineData("..\\evil.txt")] + [InlineData("safe/../../evil.txt")] + [InlineData("/tmp/evil.txt")] + public void GetSafeExtractPath_RejectsTraversalOrRootedPaths(string entryName) + { + var destination = Path.Combine(Path.GetTempPath(), "xcpng-archive-safe-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(destination); + try + { + Assert.Throws(() => ArchivePath.GetSafeExtractPath(destination, entryName)); + } + finally + { + Directory.Delete(destination, true); + } + } + + [Fact] + public void GetSafeExtractPath_AllowsNestedRelativePaths() + { + var destination = Path.Combine(Path.GetTempPath(), "xcpng-archive-safe-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(destination); + try + { + var resolved = ArchivePath.GetSafeExtractPath(destination, "nested/file.txt"); + Assert.StartsWith(Path.GetFullPath(destination), Path.GetFullPath(resolved)); + Assert.EndsWith(Path.Combine("nested", "file.txt"), resolved); + } + finally + { + Directory.Delete(destination, true); + } + } + + [Fact] + public void ExtractAllContents_RejectsZipSlipEntries() + { + var destination = Path.Combine(Path.GetTempPath(), "xcpng-archive-extract-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(destination); + + try + { + using (var zipStream = new MemoryStream()) + { + using (var zip = new ZipArchive(zipStream, ZipArchiveMode.Create, true)) + { + var entry = zip.CreateEntry("../evil.txt"); + using (var writer = new StreamWriter(entry.Open(), Encoding.UTF8)) + writer.Write("should-not-extract"); + } + + zipStream.Position = 0; + using (var iterator = new ZipArchiveIterator(zipStream)) + { + Assert.Throws(() => iterator.ExtractAllContents(destination)); + } + } + + Assert.Empty(Directory.GetFiles(destination, "*", SearchOption.AllDirectories)); + } + finally + { + if (Directory.Exists(destination)) + Directory.Delete(destination, true); + } + } + + [Fact] + public void ExtractAllContents_WritesSafeNestedEntries() + { + var destination = Path.Combine(Path.GetTempPath(), "xcpng-archive-extract-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(destination); + + try + { + using (var zipStream = new MemoryStream()) + { + using (var zip = new ZipArchive(zipStream, ZipArchiveMode.Create, true)) + { + var entry = zip.CreateEntry("nested/ok.txt"); + using (var writer = new StreamWriter(entry.Open(), Encoding.UTF8)) + writer.Write("ok"); + } + + zipStream.Position = 0; + using (var iterator = new ZipArchiveIterator(zipStream)) + iterator.ExtractAllContents(destination); + } + + var extracted = Path.Combine(destination, "nested", "ok.txt"); + Assert.True(File.Exists(extracted)); + Assert.Equal("ok", File.ReadAllText(extracted)); + } + finally + { + if (Directory.Exists(destination)) + Directory.Delete(destination, true); + } + } + } +} diff --git a/XenCenterLib.Tests/XenCenterLib.Tests.csproj b/XenCenterLib.Tests/XenCenterLib.Tests.csproj index 969ee53d97..ebe779d7fc 100644 --- a/XenCenterLib.Tests/XenCenterLib.Tests.csproj +++ b/XenCenterLib.Tests/XenCenterLib.Tests.csproj @@ -16,4 +16,7 @@ + + + diff --git a/XenCenterLib/Archive/ArchiveIterator.cs b/XenCenterLib/Archive/ArchiveIterator.cs index 91eb87f485..e611bc0069 100644 --- a/XenCenterLib/Archive/ArchiveIterator.cs +++ b/XenCenterLib/Archive/ArchiveIterator.cs @@ -48,16 +48,15 @@ public abstract class ArchiveIterator : IDisposable public void ExtractAllContents(string pathToExtractTo, Action cancellingDelegate = null) { if (string.IsNullOrEmpty(pathToExtractTo)) - throw new ArgumentNullException(); + throw new ArgumentNullException(nameof(pathToExtractTo)); while (HasNext()) { - //make the path Windows friendly var fileName = CurrentFileName(); var isDirectory = IsDirectory(); - var sanitizedName = fileName.Replace('/', Path.DirectorySeparatorChar); - var conflatedPath = Path.Combine(pathToExtractTo, sanitizedName); + // Reject Zip Slip / path traversal before creating any filesystem paths. + var conflatedPath = ArchivePath.GetSafeExtractPath(pathToExtractTo, fileName); var dir = isDirectory ? conflatedPath : Path.GetDirectoryName(conflatedPath); dir = StringUtility.ToLongWindowsPath(dir, true); diff --git a/XenCenterLib/Archive/ArchivePath.cs b/XenCenterLib/Archive/ArchivePath.cs new file mode 100644 index 0000000000..2726b05833 --- /dev/null +++ b/XenCenterLib/Archive/ArchivePath.cs @@ -0,0 +1,87 @@ +/* Copyright (c) Cloud Software Group, Inc. + * + * Redistribution and use in source and binary forms, + * with or without modification, are permitted provided + * that the following conditions are met: + * + * * Redistributions of source code must retain the above + * copyright notice, this list of conditions and the + * following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the + * following disclaimer in the documentation and/or other + * materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + */ + +using System; +using System.IO; + +namespace XenCenterLib.Archive +{ + /// + /// Helpers to keep archive extraction inside a destination root (Zip Slip). + /// + public static class ArchivePath + { + /// + /// Resolves under , + /// rejecting rooted paths and .. traversal outside the destination. + /// + /// Destination is null or empty. + /// Entry name is empty, rooted, or escapes the destination. + public static string GetSafeExtractPath(string destinationDirectory, string entryName) + { + if (string.IsNullOrEmpty(destinationDirectory)) + throw new ArgumentNullException(nameof(destinationDirectory)); + + if (string.IsNullOrEmpty(entryName)) + throw new InvalidDataException("Archive entry name is empty."); + + var relative = entryName + .Replace('/', Path.DirectorySeparatorChar) + .Replace('\\', Path.DirectorySeparatorChar); + + if (Path.IsPathRooted(relative)) + throw new InvalidDataException($"Archive entry '{entryName}' has a rooted path."); + + var destinationFull = Path.GetFullPath(destinationDirectory) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var destinationPrefix = destinationFull + Path.DirectorySeparatorChar; + + var combined = Path.GetFullPath(Path.Combine(destinationFull, relative)); + if (!IsUnderDestination(combined, destinationFull, destinationPrefix)) + throw new InvalidDataException($"Archive entry '{entryName}' would extract outside the destination directory."); + + return combined; + } + + private static bool IsUnderDestination(string candidateFull, string destinationFull, string destinationPrefix) + { + var comparison = PathComparison; + return candidateFull.StartsWith(destinationPrefix, comparison) + || string.Equals(candidateFull, destinationFull, comparison); + } + + private static StringComparison PathComparison => +#if NET6_0_OR_GREATER + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; +#else + StringComparison.OrdinalIgnoreCase; +#endif + } +} diff --git a/XenModel/Actions/Updates/DownloadUnzipUpdateAction.cs b/XenModel/Actions/Updates/DownloadUnzipUpdateAction.cs index 5a7b3acc60..6f958dc042 100644 --- a/XenModel/Actions/Updates/DownloadUnzipUpdateAction.cs +++ b/XenModel/Actions/Updates/DownloadUnzipUpdateAction.cs @@ -91,7 +91,15 @@ protected string ExtractFile(string zippedFilePath, bool deleteOriginal) if (updateFileSuffixes.Any(item => item.ToLowerInvariant() == currentExtension.ToLowerInvariant())) { - string path = Path.Combine(Path.GetTempPath(), iterator.CurrentFileName()); + // Use the leaf name only so archive paths cannot escape the temp directory (Zip Slip). + string leafName = Path.GetFileName(iterator.CurrentFileName()); + if (string.IsNullOrEmpty(leafName)) + continue; + + // Unique extract directory so identical leaf names / concurrent updates do not collide. + string extractDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(extractDir); + string path = Path.Combine(extractDir, leafName); log.InfoFormat( "Found '{0}' in the downloaded archive when looking for a '{1}' file. Extracting...",