Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion Knossos.NET/Classes/KnUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -656,14 +656,44 @@ public static bool IsSubPath(string basePath, string candidatePath)
{
var fullBase = Path.GetFullPath(basePath).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
var fullTarget = Path.GetFullPath(Path.Combine(basePath, candidatePath));
return fullTarget.StartsWith(fullBase, StringComparison.OrdinalIgnoreCase);
//Windows is uniformly case-insensitive; Linux/macOS filesystems CAN be case-sensitive
//(always on Linux, optional on APFS), so use Ordinal there to avoid false-positives on
//traversal attempts that exploit case-only differences with sibling directories.
var comparison = IsWindows ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
return fullTarget.StartsWith(fullBase, comparison);
}
catch
{
return false;
}
}

/// <summary>
/// True if a string is safe to use as a single filesystem path component (no separators,
/// no traversal sequences, no null bytes, no drive letters, no NTFS-stripped trailing
/// whitespace or dots, not "." or ".."). Used to validate JSON-supplied fields like
/// mod.id / mod.version that get concatenated into install paths.
/// </summary>
public static bool IsSafePathComponent(string? component)
{
if (string.IsNullOrEmpty(component))
return false;
if (component == "." || component == "..")
return false;
if (component.Contains('\0'))
return false;
if (component.Contains('/') || component.Contains('\\'))
return false;
if (component.Length >= 2 && component[1] == ':')
return false;
//NTFS silently strips trailing dots/spaces — reject so ".. " or ".." don't round-trip.
if (component.Trim() != component)
return false;
if (component.EndsWith('.'))
return false;
return true;
}

/// <summary>
/// Gets the complete size of all files in a folder and subdirectories in bytes
/// </summary>
Expand Down
20 changes: 20 additions & 0 deletions Knossos.NET/ViewModels/Templates/Tasks/InstallBuild.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,26 @@ private async Task<bool> InstallVCRedist(bool is86 = false) {
-Main progress max value is calculated as follows: ( Number of files to download * 2 ) + 1
(Download, Decompression, Download banner/tile images)
*/
//Reject build metadata with traversal sequences in path-component fields. Without this guard a
//malicious Nebula response (id="..\\..\\..", version="../etc"...) would poison modPath itself,
//causing the IsSubPath checks on file.dest / file.filename below to validate against the poisoned base.
if (!KnUtils.IsSafePathComponent(modJson.id) ||
!KnUtils.IsSafePathComponent(modJson.version))
{
Log.Add(Log.LogSeverity.Error, "TaskItemViewModel.InstallBuild()", "Refusing to install: build has unsafe id/version: id=" + modJson.id + " version=" + modJson.version);
CancelTaskCommand();
throw new TaskCanceledException();
}
foreach (var pkg in modJson.packages)
{
if (pkg.folder != null && !KnUtils.IsSafePathComponent(pkg.folder))
{
Log.Add(Log.LogSeverity.Error, "TaskItemViewModel.InstallBuild()", "Refusing to install: build " + modJson.id + " has unsafe package folder: " + pkg.folder);
CancelTaskCommand();
throw new TaskCanceledException();
}
}

List<ModFile> files = new List<ModFile>();
string modFolder = modJson.id + "-" + modJson.version;
modPath = Knossos.GetKnossosLibraryPath() + Path.DirectorySeparatorChar + "bin" + Path.DirectorySeparatorChar + modFolder;
Expand Down
21 changes: 21 additions & 0 deletions Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,27 @@ public async Task<bool> InstallMod(Mod mod, CancellationTokenSource cancelSource
-If devmode and file is a vp it needs to be decompressed +1 to max tasks
*/

//Reject mod metadata with traversal sequences in path-component fields. Without this guard a
//malicious Nebula response (id="..\\..\\..", version="../etc"...) would poison modPath itself,
//causing the IsSubPath checks on file.dest / file.filename below to validate against the poisoned base.
if (!KnUtils.IsSafePathComponent(mod.id) ||
!KnUtils.IsSafePathComponent(mod.version) ||
(mod.parent != null && !KnUtils.IsSafePathComponent(mod.parent)))
{
Log.Add(Log.LogSeverity.Error, "TaskItemViewModel.InstallMod()", "Refusing to install: mod has unsafe id/version/parent: id=" + mod.id + " version=" + mod.version + " parent=" + mod.parent);
CancelTaskCommand();
throw new TaskCanceledException();
}
foreach (var pkg in mod.packages)
{
if (pkg.folder != null && !KnUtils.IsSafePathComponent(pkg.folder))
{
Log.Add(Log.LogSeverity.Error, "TaskItemViewModel.InstallMod()", "Refusing to install: mod " + mod.id + " has unsafe package folder: " + pkg.folder);
CancelTaskCommand();
throw new TaskCanceledException();
}
}

List<ModFile> files = new List<ModFile>();
string modFolder = mod.id + "-" + mod.version;
string rootPack = string.Empty;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,19 @@ public async Task<bool> TryToCopyFilesFromOldVersions(Mod mod, List<Mod> oldVers
{
if (f.filename != null && (!oldVer.devMode || (oldVer.devMode && oldPkg.folder != null)))
{
//Validate the old-side relative path before reading. A malicious mod.json on disk
//could have a traversal in f.filename that leaks file existence via File.Exists and
//timing via GetFileHash even before we attempt to copy.
var oldRelative = oldVer.devMode
? Path.Combine(oldPkg.folder ?? string.Empty, f.filename)
: f.filename;
if (string.IsNullOrEmpty(oldRelative) || !KnUtils.IsSubPath(oldVer.fullPath, oldRelative))
{
Log.Add(Log.LogSeverity.Warning, "TaskItemViewModel.TryToCopyFilesFromOldVersions()", "Old version " + oldVer + " has unsafe file path, cannot use as source: " + oldRelative);
copySrcList.Clear();
copyDstList.Clear();
break;
}
var oldPath = oldVer.devMode ? Path.Combine(oldVer.fullPath, oldPkg.folder!, f.filename) : Path.Combine(oldVer.fullPath, f.filename);
if (File.Exists(oldPath))
{
Expand Down Expand Up @@ -141,6 +154,18 @@ public async Task<bool> TryToCopyFilesFromOldVersions(Mod mod, List<Mod> oldVers
}
}

//Paired check on the write side: the new mod's package.folder is API-derived
//and would not have been validated yet if mod was loaded from a poisoned source.
var newRelative = mod.devMode
? Path.Combine(package.folder ?? string.Empty, f.filename)
: f.filename;
if (!KnUtils.IsSubPath(mod.fullPath, newRelative))
{
Log.Add(Log.LogSeverity.Warning, "TaskItemViewModel.TryToCopyFilesFromOldVersions()", "New mod has unsafe destination path, cannot copy from old version: " + newRelative);
copySrcList.Clear();
copyDstList.Clear();
break;
}
copySrcList.Add(oldPath);
var newPath = mod.devMode ? Path.Combine(mod.fullPath, package.folder!, f.filename) : Path.Combine(mod.fullPath, f.filename);
copyDstList.Add(newPath);
Expand Down