Defence-in-depth against a buggy or crafted manifest that points outside the client directory. Path resolution is centralised in PathSafety.ResolveInside so the orchestration code cannot forget the check; the applier still sees already-validated absolute paths. Rejected cases: parent traversal, mixed traversal, absolute paths, sibling-root lexical escapes. Required files that fail the check abort the update; optional files are skipped and logged.
76 lines
2.1 KiB
C#
76 lines
2.1 KiB
C#
using Metin2Launcher.Apply;
|
|
using Xunit;
|
|
|
|
namespace Metin2Launcher.Tests;
|
|
|
|
public class PathSafetyTests : IDisposable
|
|
{
|
|
private readonly string _root;
|
|
|
|
public PathSafetyTests()
|
|
{
|
|
_root = Path.Combine(Path.GetTempPath(), "metin-pathsafe-" + Guid.NewGuid());
|
|
Directory.CreateDirectory(_root);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_root, recursive: true); } catch { }
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_allows_simple_relative_path()
|
|
{
|
|
var result = PathSafety.ResolveInside(_root, "pack/item.pck");
|
|
Assert.StartsWith(Path.GetFullPath(_root), result);
|
|
Assert.EndsWith("item.pck", result);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_allows_nested_relative_path()
|
|
{
|
|
var result = PathSafety.ResolveInside(_root, "assets/root/serverinfo.py");
|
|
Assert.StartsWith(Path.GetFullPath(_root), result);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_rejects_parent_traversal()
|
|
{
|
|
Assert.Throws<UnsafePathException>(
|
|
() => PathSafety.ResolveInside(_root, "../../../etc/passwd"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_rejects_mixed_traversal()
|
|
{
|
|
Assert.Throws<UnsafePathException>(
|
|
() => PathSafety.ResolveInside(_root, "assets/../../outside.txt"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_rejects_absolute_unix_path()
|
|
{
|
|
Assert.Throws<UnsafePathException>(
|
|
() => PathSafety.ResolveInside(_root, "/etc/passwd"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_rejects_empty()
|
|
{
|
|
Assert.Throws<UnsafePathException>(
|
|
() => PathSafety.ResolveInside(_root, ""));
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_rejects_sibling_root_escape()
|
|
{
|
|
// If client root is /tmp/x and attacker passes ../x2/a.txt, the resolved path
|
|
// becomes /tmp/x2/a.txt which starts with "/tmp/x" lexically but is a different
|
|
// directory. The check must use a trailing separator so "/tmp/x2/..." doesn't
|
|
// match "/tmp/x".
|
|
var sibling = Path.GetFileName(_root) + "2";
|
|
Assert.Throws<UnsafePathException>(
|
|
() => PathSafety.ResolveInside(_root, $"../{sibling}/evil.txt"));
|
|
}
|
|
}
|