SECURITY: resolve install dir from exe location, refuse home + system dirs

Defense in depth on top of the ledger-based prune fix. Even though the
ledger makes prune non-destructive for files the launcher never wrote,
pointing the launcher at your home dir is still a terrible UX — it
downloads 1.4 GB of client assets into ~/ and leaves them interleaved
with your real files. And a future bug could always re-enable a
recursive operation; defence in depth means we refuse at the source.

Changes:

- new InstallDir.Resolve() helper:
  * METIN2_INSTALL_DIR env var takes precedence (used by dev/CI so
    `dotnet run` from bin/Release still lets us target a sandbox)
  * otherwise derives the install dir from
    Process.GetCurrentProcess().MainModule.FileName — i.e. the dir
    the .exe lives in, not the shell's CWD
  * falls back to Directory.GetCurrentDirectory() as a last resort
    (then still runs the safety gate)
  * throws UnsafeInstallDirException if the resolved path matches a
    known-dangerous target: user home ($HOME, ~/Desktop, ~/Documents,
    ~/Downloads, ~/Music, ~/Pictures, ~/Videos and the czech
    plocha/Dokumenty/Stažené variants), filesystem roots (/, /bin,
    /etc, /usr, /var, /tmp, /home, /root, ...), or Windows drive
    roots / Windows / Program Files / Users

- Program.cs now calls InstallDir.Resolve() instead of
  Directory.GetCurrentDirectory(). On rejection it prints the exact
  path and the suggested remedy (create a dedicated folder or set
  METIN2_INSTALL_DIR) and exits 4.

- After resolving, the launcher SetCurrentDirectory(clientRoot) so
  downstream code (orchestrator, game process spawning, log file
  path) that uses CWD keeps working transparently.

Repro of the original footgun, now refused:

    cd ~ && wine ~/Games/Metin2/Metin2Launcher.exe
    # old: _clientRoot = ~/ → prune walks ~/ → deletes everything
    # new: _clientRoot = ~/Games/Metin2 (exe dir), prune ledger-scoped

    METIN2_INSTALL_DIR=/home/jann ./Metin2Launcher
    # refusing to use /home/jann as install directory ...
    # exit 4

- Why no --install-dir CLI flag: the old --install-dir was silently
  ignored (CLAUDE memory noted it as broken) and re-adding it without
  the same safety gate would re-open the hole. Env var is enough for
  dev/CI; production wants exe-relative.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jan Nedbal
2026-04-15 15:34:48 +02:00
parent db1f2f435b
commit 73446a0a60
2 changed files with 154 additions and 1 deletions

View File

@@ -0,0 +1,136 @@
using System.Diagnostics;
namespace Metin2Launcher.Config;
/// <summary>
/// Resolves the install directory the launcher operates on, with strict
/// safety rules so the updater can never clobber a user's home directory.
///
/// The contract, in priority order:
///
/// 1. The install directory is the directory containing the running
/// launcher executable (<c>Process.MainModule.FileName</c>). It is NOT
/// <c>Directory.GetCurrentDirectory()</c> — a user who runs
/// <c>wine /path/to/Metin2Launcher.exe</c> from their home directory
/// must not cause the launcher to treat their home as the install dir.
///
/// 2. The resolved directory is rejected if it matches a known-dangerous
/// target: the user's home directory itself, common system directories,
/// or the filesystem root. In that case the launcher refuses to run
/// with <see cref="Resolve"/> throwing <see cref="UnsafeInstallDirException"/>.
///
/// 3. If <c>METIN2_INSTALL_DIR</c> is set in the environment, that value
/// wins over the exe location. Used for local dev and CI, where the
/// launcher is run from <c>bin/Release/net8.0/</c> and we want the
/// installed client to land in a separate sandbox.
///
/// There is no CLI override. The previous <c>--install-dir</c> flag was
/// silently ignored anyway; re-adding it without thinking through the
/// safety semantics would reintroduce the same footgun.
/// </summary>
public static class InstallDir
{
public sealed class UnsafeInstallDirException : Exception
{
public UnsafeInstallDirException(string message) : base(message) { }
}
public const string EnvOverride = "METIN2_INSTALL_DIR";
public static string Resolve()
{
var candidate = ResolveCandidate();
var full = Path.GetFullPath(candidate);
AssertSafe(full);
return full;
}
private static string ResolveCandidate()
{
var env = Environment.GetEnvironmentVariable(EnvOverride);
if (!string.IsNullOrWhiteSpace(env))
return env;
var exePath = Process.GetCurrentProcess().MainModule?.FileName;
if (!string.IsNullOrEmpty(exePath))
{
var dir = Path.GetDirectoryName(exePath);
if (!string.IsNullOrEmpty(dir))
return dir;
}
// Last-resort fallback: current working directory. This is the
// legacy behaviour and is inherently unsafe, so we still run the
// safety gate below on whatever it resolves to.
return Directory.GetCurrentDirectory();
}
private static void AssertSafe(string full)
{
// Normalize trailing separator for comparison.
var normalized = full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (normalized.Length == 0)
normalized = Path.DirectorySeparatorChar.ToString();
foreach (var danger in DangerousDirs())
{
if (string.Equals(normalized, danger, StringComparison.OrdinalIgnoreCase))
throw new UnsafeInstallDirException(
$"refusing to use {full} as install directory — this is a user/system " +
$"location. Create a dedicated folder (e.g. {SuggestedExample()}) " +
$"and run the launcher from inside it, or set {EnvOverride}=<path>.");
}
}
private static string SuggestedExample()
{
if (OperatingSystem.IsWindows())
return @"C:\Games\Metin2";
var home = Environment.GetEnvironmentVariable("HOME") ?? "/home/you";
return $"{home}/Games/Metin2";
}
private static IEnumerable<string> DangerousDirs()
{
var list = new List<string>();
// User home dir and common subdirs a user would *not* want
// overwritten by a game client installer.
var home = Environment.GetEnvironmentVariable("HOME")
?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (!string.IsNullOrEmpty(home))
{
list.Add(home.TrimEnd(Path.DirectorySeparatorChar));
foreach (var sub in new[] { "Desktop", "Documents", "Downloads", "Music",
"Pictures", "Videos", "plocha", "Dokumenty",
"Stažené" })
{
list.Add(Path.Combine(home, sub).TrimEnd(Path.DirectorySeparatorChar));
}
}
// Filesystem roots and system dirs.
if (OperatingSystem.IsWindows())
{
foreach (var drive in new[] { "C:", "D:", "E:", "F:" })
{
list.Add(drive);
list.Add(drive + @"\");
list.Add(drive + @"\Windows");
list.Add(drive + @"\Program Files");
list.Add(drive + @"\Program Files (x86)");
list.Add(drive + @"\Users");
}
}
else
{
foreach (var d in new[] { "/", "/bin", "/boot", "/dev", "/etc", "/home",
"/lib", "/lib64", "/media", "/mnt", "/opt",
"/proc", "/root", "/run", "/sbin", "/srv",
"/sys", "/tmp", "/usr", "/var" })
list.Add(d);
}
return list;
}
}

View File

@@ -23,7 +23,24 @@ public static class Program
// install / firstrun / uninstall hooks without the rest of our code running.
VelopackApp.Build().SetArgs(args).Run();
var clientRoot = Directory.GetCurrentDirectory();
// Resolve the install directory via InstallDir (not CWD!) so users
// who run `wine /path/to/Metin2Launcher.exe` from any shell CWD
// still install into the dir the exe lives in. Dangerous locations
// (user home, system dirs) are refused outright.
string clientRoot;
try
{
clientRoot = InstallDir.Resolve();
}
catch (InstallDir.UnsafeInstallDirException ex)
{
Console.Error.WriteLine(ex.Message);
return 4;
}
// Set CWD so downstream code using Directory.GetCurrentDirectory()
// lands in the same place as clientRoot. Keeps invariants simple.
Directory.SetCurrentDirectory(clientRoot);
var stateDir = Path.Combine(clientRoot, LauncherConfig.StateDirName);
Directory.CreateDirectory(stateDir);
Log.SetLogFilePath(Path.Combine(stateDir, "launcher.log"));