Files
metin-launcher/tests/Metin2Launcher.Tests/PathSafetyTests.cs
Jan Nedbal cc904fb881 launcher: reject manifest paths that escape the client root
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.
2026-04-14 11:17:12 +02:00

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"));
}
}