From e20a3ed09bf06269527e6267b0b03545312af261 Mon Sep 17 00:00:00 2001 From: Goober5000 Date: Wed, 6 May 2026 00:11:12 -0400 Subject: [PATCH 1/2] Validate API-supplied paths to prevent directory traversal file.dest and file.filename values from the Nebula API were used directly in filesystem operations (directory creation, download target paths, decompression destinations) with no validation, allowing a compromised server or MITM to write files outside the mod library. Adds IsSubPath() to KnUtils using Path.GetFullPath containment to reject absolute paths and "../" traversal sequences. InstallMod and InstallBuild now validate both fields before use and cancel the task if either escapes the mod directory. Also adds belt-and-suspenders entry path validation to the SharpCompress extraction loops and fixes the WriteSymbolicLink lambda to reject symlink targets that resolve outside the destination directory. Co-Authored-By: Claude Sonnet 4.6 --- Knossos.NET/Classes/KnUtils.cs | 47 ++++++++++++++++++- .../Templates/Tasks/InstallBuild.cs | 15 +++++- .../ViewModels/Templates/Tasks/InstallMod.cs | 15 +++++- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/Knossos.NET/Classes/KnUtils.cs b/Knossos.NET/Classes/KnUtils.cs index 175c047e..bbe372de 100644 --- a/Knossos.NET/Classes/KnUtils.cs +++ b/Knossos.NET/Classes/KnUtils.cs @@ -629,6 +629,28 @@ public static string CmdLineBuilder(string cmdline, string[]? args) return cmdline; } + /// + /// Returns true if candidatePath resolves to a location within basePath. + /// Rejects absolute paths, null bytes, and path traversal sequences such as "..". + /// + public static bool IsSubPath(string basePath, string candidatePath) + { + if (string.IsNullOrEmpty(candidatePath)) + return true; + if (Path.IsPathRooted(candidatePath)) + return false; + try + { + var fullBase = Path.GetFullPath(basePath).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; + var fullTarget = Path.GetFullPath(Path.Combine(basePath, candidatePath)); + return fullTarget.StartsWith(fullBase, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + /// /// Gets the complete size of all files in a folder and subdirectories in bytes /// @@ -1009,7 +1031,24 @@ private static bool DecompressFileSharpCompress(string compressedFilePath, strin { if (!reader.Entry.IsDirectory) { - reader.WriteEntryToDirectory(destFolderPath, new ExtractionOptions() { ExtractFullPath = extractFullPath, Overwrite = true, WriteSymbolicLink = (source, target) => { File.CreateSymbolicLink(source, target); } }); + var entryKey = reader.Entry.Key?.Replace('\\', '/').Replace('/', Path.DirectorySeparatorChar); + if (entryKey != null && extractFullPath && !IsSubPath(destFolderPath, entryKey)) + { + Log.Add(Log.LogSeverity.Warning, "KnUtils.DecompressFileSharpCompress()", $"Skipping potentially dangerous archive entry: {reader.Entry.Key}"); + continue; + } + reader.WriteEntryToDirectory(destFolderPath, new ExtractionOptions() { ExtractFullPath = extractFullPath, Overwrite = true, WriteSymbolicLink = (source, target) => + { + var resolvedTarget = Path.IsPathRooted(target) + ? target + : Path.GetFullPath(Path.Combine(Path.GetDirectoryName(source)!, target)); + if (!IsSubPath(destFolderPath, Path.GetRelativePath(destFolderPath, resolvedTarget))) + { + Log.Add(Log.LogSeverity.Warning, "KnUtils.DecompressFileSharpCompress()", $"Skipping symlink escaping destination: {source} -> {target}"); + return; + } + File.CreateSymbolicLink(source, target); + }}); } if (cancellationTokenSource!.IsCancellationRequested) { @@ -1028,6 +1067,12 @@ private static bool DecompressFileSharpCompress(string compressedFilePath, strin { if (!reader.Entry.IsDirectory) { + var entryKey = reader.Entry.Key?.Replace('\\', '/').Replace('/', Path.DirectorySeparatorChar); + if (entryKey != null && extractFullPath && !IsSubPath(destFolderPath, entryKey)) + { + Log.Add(Log.LogSeverity.Warning, "KnUtils.DecompressFileSharpCompress()", $"Skipping potentially dangerous archive entry: {reader.Entry.Key}"); + continue; + } reader.WriteEntryToDirectory(destFolderPath, new ExtractionOptions() { ExtractFullPath = extractFullPath, Overwrite = true }); } if (cancellationTokenSource!.IsCancellationRequested) diff --git a/Knossos.NET/ViewModels/Templates/Tasks/InstallBuild.cs b/Knossos.NET/ViewModels/Templates/Tasks/InstallBuild.cs index 9bf3aa3c..fea63edf 100644 --- a/Knossos.NET/ViewModels/Templates/Tasks/InstallBuild.cs +++ b/Knossos.NET/ViewModels/Templates/Tasks/InstallBuild.cs @@ -231,8 +231,13 @@ private async Task InstallVCRedist(bool is86 = false) { { if (file.dest != null && file.dest.Trim() != string.Empty) { - var path = file.dest; - Directory.CreateDirectory(modPath + Path.DirectorySeparatorChar + path); + if (!KnUtils.IsSubPath(modPath, file.dest)) + { + Log.Add(Log.LogSeverity.Error, "TaskItemViewModel.InstallBuild()", $"Unsafe dest path in build '{build.id}': {file.dest}"); + CancelTaskCommand(); + return false; + } + Directory.CreateDirectory(modPath + Path.DirectorySeparatorChar + file.dest); } } @@ -277,6 +282,12 @@ private async Task InstallVCRedist(bool is86 = false) { } Info = "Tasks: " + ProgressCurrent + "/" + ProgressBarMax; + if (!KnUtils.IsSubPath(modPath, file.filename)) + { + Log.Add(Log.LogSeverity.Error, "TaskItemViewModel.InstallBuild()", $"Unsafe filename in build '{build.id}': {file.filename}"); + CancelTaskCommand(); + throw new TaskCanceledException(); + } var fileFullPath = modPath + Path.DirectorySeparatorChar + file.filename; var result = await fileTask.DownloadFile(file.urls!, fileFullPath, "Downloading " + file.filename, false, null, cancellationTokenSource); diff --git a/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs b/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs index 180a35e7..2bc32a39 100644 --- a/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs +++ b/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs @@ -263,8 +263,13 @@ public async Task InstallMod(Mod mod, CancellationTokenSource cancelSource { if (file.dest != null && file.dest.Trim() != string.Empty) { - var path = file.dest; - Directory.CreateDirectory(modPath + Path.DirectorySeparatorChar + path); + if (!KnUtils.IsSubPath(modPath, file.dest)) + { + Log.Add(Log.LogSeverity.Error, "TaskItemViewModel.InstallMod()", $"Unsafe dest path in mod '{mod.id}': {file.dest}"); + CancelTaskCommand(); + return false; + } + Directory.CreateDirectory(modPath + Path.DirectorySeparatorChar + file.dest); } } @@ -346,6 +351,12 @@ public async Task InstallMod(Mod mod, CancellationTokenSource cancelSource { file.dest = string.Empty; } + if (!KnUtils.IsSubPath(modPath, file.filename)) + { + Log.Add(Log.LogSeverity.Error, "TaskItemViewModel.InstallMod()", $"Unsafe filename in mod '{mod.id}': {file.filename}"); + CancelTaskCommand(); + throw new TaskCanceledException(); + } var fileFullPath = modPath + Path.DirectorySeparatorChar + file.filename; var result = await fileTask.DownloadFile(file.urls!, fileFullPath, "Downloading " + file.filename, false, null, cancellationTokenSource); From 1d8e1796849ccdf382e13d707d1f6cbae6634fa0 Mon Sep 17 00:00:00 2001 From: wookieejedi Date: Thu, 7 May 2026 10:52:26 -0400 Subject: [PATCH 2/2] fix issues --- Knossos.NET/ViewModels/Templates/Tasks/InstallBuild.cs | 3 +-- Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Knossos.NET/ViewModels/Templates/Tasks/InstallBuild.cs b/Knossos.NET/ViewModels/Templates/Tasks/InstallBuild.cs index fea63edf..54515fe4 100644 --- a/Knossos.NET/ViewModels/Templates/Tasks/InstallBuild.cs +++ b/Knossos.NET/ViewModels/Templates/Tasks/InstallBuild.cs @@ -235,7 +235,6 @@ private async Task InstallVCRedist(bool is86 = false) { { Log.Add(Log.LogSeverity.Error, "TaskItemViewModel.InstallBuild()", $"Unsafe dest path in build '{build.id}': {file.dest}"); CancelTaskCommand(); - return false; } Directory.CreateDirectory(modPath + Path.DirectorySeparatorChar + file.dest); } @@ -282,7 +281,7 @@ private async Task InstallVCRedist(bool is86 = false) { } Info = "Tasks: " + ProgressCurrent + "/" + ProgressBarMax; - if (!KnUtils.IsSubPath(modPath, file.filename)) + if (string.IsNullOrEmpty(file.filename) || !KnUtils.IsSubPath(modPath, file.filename)) { Log.Add(Log.LogSeverity.Error, "TaskItemViewModel.InstallBuild()", $"Unsafe filename in build '{build.id}': {file.filename}"); CancelTaskCommand(); diff --git a/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs b/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs index 2bc32a39..3e76f791 100644 --- a/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs +++ b/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs @@ -351,7 +351,7 @@ public async Task InstallMod(Mod mod, CancellationTokenSource cancelSource { file.dest = string.Empty; } - if (!KnUtils.IsSubPath(modPath, file.filename)) + if (string.IsNullOrEmpty(file.filename) || !KnUtils.IsSubPath(modPath, file.filename)) { Log.Add(Log.LogSeverity.Error, "TaskItemViewModel.InstallMod()", $"Unsafe filename in mod '{mod.id}': {file.filename}"); CancelTaskCommand();