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...",