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:
136
src/Metin2Launcher/Config/InstallDir.cs
Normal file
136
src/Metin2Launcher/Config/InstallDir.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user