From d44666ec69f3ff103197654bf7259dbee659e4bd Mon Sep 17 00:00:00 2001 From: Aqua Date: Sat, 7 Nov 2026 10:45:23 +0000 Subject: [PATCH] ClientRuntimeLoader: client assemblies must resolve under a trusted root The reload pointer, and the initial --client-assembly, named any path and ran whatever it held in the engine process. Reload now refuses relative and UNC paths and anything that, links followed, lies outside the trusted root (--client-trusted-root or AQUARIUM_CLIENT_TRUSTED_ROOT; default: the directory of the initial assembly). dev-reload passes its slot directory as the root. Co-Authored-By: Claude Sonnet 5.5 --- scripts/dev-reload.ps1 | 3 +- src/Aquarium.Engine/AquariumHost.cs | 16 ++- .../Runtime/ClientRuntimeLoader.cs | 64 +++++++++++- .../Aquarium.Engine.Tests.csproj | 1 + .../ClientRuntimeLoaderTrustTests.cs | 97 +++++++++++++++++++ 5 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 tests/Aquarium.Engine.Tests/ClientRuntimeLoaderTrustTests.cs diff --git a/scripts/dev-reload.ps1 b/scripts/dev-reload.ps1 index 26a9520..391b40f 100644 --- a/scripts/dev-reload.ps1 +++ b/scripts/dev-reload.ps1 @@ -195,7 +195,8 @@ function Start-AquariumProcess { "--cache", $cultCachePath, "--shader-source", (Join-Path $SlotPath "Render\Shaders\D3D12HeightField.hlsl"), "--client-assembly", $LiveAssemblyPath, - "--client-reload-pointer", $liveReloadPointerPath + "--client-reload-pointer", $liveReloadPointerPath, + "--client-trusted-root", $slotRoot ) if ($RunHeadless) { $arguments += "--headless" diff --git a/src/Aquarium.Engine/AquariumHost.cs b/src/Aquarium.Engine/AquariumHost.cs index 7ba0c72..4f8af38 100644 --- a/src/Aquarium.Engine/AquariumHost.cs +++ b/src/Aquarium.Engine/AquariumHost.cs @@ -18,7 +18,7 @@ public static class AquariumHost ParseFieldReservoirMode(args), ParseFieldReservoirScale(args), ParseFieldReservoirSpatialReuseBudget(args)); - using var runtimeLoader = new ClientRuntimeLoader(runtimeOptions, ParseClientAssemblyPath(args), ParseClientReloadPointerPath(args)); + using var runtimeLoader = new ClientRuntimeLoader(runtimeOptions, ParseClientAssemblyPath(args), ParseClientReloadPointerPath(args), ParseClientTrustedRoot(args)); var runtime = runtimeLoader.Load(); if (runtimeOptions.RenderDebugModeOverride is { } renderDebugModeOverride) { @@ -258,6 +258,20 @@ public static class AquariumHost ?? Environment.GetEnvironmentVariable("AQUARIUM_LIVE_ASSEMBLY"); } + private static string? ParseClientTrustedRoot(IReadOnlyCollection args) + { + var values = args.ToArray(); + for (var index = 0; index < values.Length - 1; index++) + { + if (string.Equals(values[index], "--client-trusted-root", StringComparison.OrdinalIgnoreCase)) + { + return values[index + 1]; + } + } + + return Environment.GetEnvironmentVariable("AQUARIUM_CLIENT_TRUSTED_ROOT"); + } + private static string? ParseClientReloadPointerPath(IReadOnlyCollection args) { var values = args.ToArray(); diff --git a/src/Aquarium.Engine/Runtime/ClientRuntimeLoader.cs b/src/Aquarium.Engine/Runtime/ClientRuntimeLoader.cs index 950e974..0874af0 100644 --- a/src/Aquarium.Engine/Runtime/ClientRuntimeLoader.cs +++ b/src/Aquarium.Engine/Runtime/ClientRuntimeLoader.cs @@ -8,16 +8,29 @@ public sealed class ClientRuntimeLoader : IDisposable { private readonly string? clientAssemblyPath; private readonly string? clientReloadPointerPath; + private readonly string? trustedRoot; private readonly Stopwatch reloadClock = Stopwatch.StartNew(); private readonly AquariumRuntimeOptions options; private ReloadedRuntime? current; private TimeSpan lastReloadCheck; private string? currentAssemblyPath; - public ClientRuntimeLoader(AquariumRuntimeOptions options, string? clientAssemblyPath = null, string? clientReloadPointerPath = null) + /// + /// Every assembly this loader runs must resolve, links followed, to a path under + /// (default: the directory of the initial assembly). A relative initial path is taken against the working directory; + /// a relative or UNC reload target is refused. + /// + public ClientRuntimeLoader( + AquariumRuntimeOptions options, + string? clientAssemblyPath = null, + string? clientReloadPointerPath = null, + string? trustedRoot = null) { - this.clientAssemblyPath = clientAssemblyPath; + this.clientAssemblyPath = string.IsNullOrWhiteSpace(clientAssemblyPath) ? null : Path.GetFullPath(clientAssemblyPath); this.clientReloadPointerPath = clientReloadPointerPath; + this.trustedRoot = string.IsNullOrWhiteSpace(trustedRoot) + ? this.clientAssemblyPath is null ? null : Path.GetDirectoryName(this.clientAssemblyPath) + : Path.GetFullPath(trustedRoot); this.options = options; } @@ -42,6 +55,7 @@ public sealed class ClientRuntimeLoader : IDisposable public void Reload(string assemblyPath, AquariumRuntimeOptions options) { + RequireTrusted(assemblyPath); var previous = current; var loadContext = new ClientAssemblyLoadContext(assemblyPath); IAquariumRuntime? nextRuntime = null; @@ -122,6 +136,52 @@ public sealed class ClientRuntimeLoader : IDisposable } } + private void RequireTrusted(string assemblyPath) + { + if (trustedRoot is null) + { + throw new InvalidOperationException("No trusted root for client runtime assemblies is set."); + } + + if (!Path.IsPathFullyQualified(assemblyPath) + || assemblyPath.StartsWith(@"\\", StringComparison.Ordinal) + || assemblyPath.StartsWith("//", StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Client runtime assembly path must be local and fully qualified: {assemblyPath}"); + } + + var relative = Path.GetRelativePath(ResolveLinks(trustedRoot), ResolveLinks(Path.GetFullPath(assemblyPath))); + if (relative == ".." || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || Path.IsPathRooted(relative)) + { + throw new InvalidOperationException($"Client runtime assembly is outside the trusted root '{trustedRoot}': {assemblyPath}"); + } + } + + // The path with every symlink or junction on it replaced by its final target. + private static string ResolveLinks(string path, int depth = 0) + { + if (depth > 32) + { + throw new InvalidOperationException($"Too many links resolving {path}."); + } + + for (var probe = path; probe is not null; probe = Path.GetDirectoryName(probe)) + { + FileSystemInfo info = Directory.Exists(probe) ? new DirectoryInfo(probe) : new FileInfo(probe); + if (info.LinkTarget is null) + { + continue; + } + + var target = info.ResolveLinkTarget(returnFinalTarget: true)?.FullName + ?? throw new InvalidOperationException($"Link does not resolve: {probe}"); + var rest = Path.GetRelativePath(probe, path); + return ResolveLinks(rest == "." ? target : Path.GetFullPath(Path.Combine(target, rest)), depth + 1); + } + + return path; + } + private string RequireClientAssemblyPath() { if (!string.IsNullOrWhiteSpace(clientAssemblyPath)) diff --git a/tests/Aquarium.Engine.Tests/Aquarium.Engine.Tests.csproj b/tests/Aquarium.Engine.Tests/Aquarium.Engine.Tests.csproj index 63d3d04..7937bdd 100644 --- a/tests/Aquarium.Engine.Tests/Aquarium.Engine.Tests.csproj +++ b/tests/Aquarium.Engine.Tests/Aquarium.Engine.Tests.csproj @@ -20,6 +20,7 @@ + diff --git a/tests/Aquarium.Engine.Tests/ClientRuntimeLoaderTrustTests.cs b/tests/Aquarium.Engine.Tests/ClientRuntimeLoaderTrustTests.cs new file mode 100644 index 0000000..0ef0a7b --- /dev/null +++ b/tests/Aquarium.Engine.Tests/ClientRuntimeLoaderTrustTests.cs @@ -0,0 +1,97 @@ +using Aquarium.Engine; +using Aquarium.Engine.Input; +using Aquarium.Engine.Runtime; + +namespace Aquarium.Engine.Tests; + +public sealed class ClientRuntimeLoaderTrustTests : IDisposable +{ + private static readonly AquariumRuntimeOptions Options = new(true, null); + private readonly string work = Path.Combine(Path.GetTempPath(), "aquarium-trust-" + Guid.NewGuid().ToString("N")); + private readonly string trusted; + private readonly string outside; + + public ClientRuntimeLoaderTrustTests() + { + trusted = Directory.CreateDirectory(Path.Combine(work, "trusted")).FullName; + outside = Directory.CreateDirectory(Path.Combine(work, "outside")).FullName; + } + + public void Dispose() => Directory.Delete(work, recursive: true); + + // A real client assembly: the minimal sample, copied so its location is ours to choose. + private static string Copy(string directory, string name = "client.dll") + { + var path = Path.Combine(directory, name); + File.Copy(typeof(Aquarium.Sample.Minimal.MinimalRuntime).Assembly.Location, path); + return path; + } + + [Fact] + public void AssemblyInsideTheTrustedRootLoadsAndReloads() + { + var first = Copy(trusted, "a.dll"); + var second = Copy(Directory.CreateDirectory(Path.Combine(trusted, "slot2")).FullName); + using var loader = new ClientRuntimeLoader(Options, first); + loader.Load(); + loader.Reload(second, Options); + Assert.NotNull(loader.Runtime); + } + + [Fact] + public void InitialAssemblyOutsideTheExplicitRootIsRefused() + { + using var loader = new ClientRuntimeLoader(Options, Copy(outside), trustedRoot: trusted); + Assert.Throws(() => loader.Load()); + } + + [Fact] + public void ReloadOutsideTheRootDotDotRelativeAndUncPathsAreRefused() + { + using var loader = new ClientRuntimeLoader(Options, Copy(trusted, "a.dll")); + loader.Load(); + var escaped = Copy(outside); + Assert.Throws(() => loader.Reload(escaped, Options)); + Assert.Throws(() => loader.Reload(Path.Combine(trusted, "..", "outside", "client.dll"), Options)); + Assert.Throws(() => loader.Reload("a.dll", Options)); + Assert.Throws(() => loader.Reload("//host/share/client.dll", Options)); + Assert.Throws(() => loader.Reload(@"\\host\share\client.dll", Options)); + } + + [Fact] + public void SymlinkedFileOrDirectoryPointingOutsideTheRootIsRefused() + { + var target = Copy(outside); + var fileLink = Path.Combine(trusted, "link.dll"); + var dirLink = Path.Combine(trusted, "dirlink"); + try + { + File.CreateSymbolicLink(fileLink, target); + Directory.CreateSymbolicLink(dirLink, outside); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + return; // this host cannot create links + } + + using var loader = new ClientRuntimeLoader(Options, Copy(trusted, "a.dll")); + loader.Load(); + Assert.Throws(() => loader.Reload(fileLink, Options)); + Assert.Throws(() => loader.Reload(Path.Combine(dirLink, "client.dll"), Options)); + } + + [Fact] + public void PointerNamingAnAssemblyOutsideTheRootKeepsThePreviousRuntime() + { + var pointer = Path.Combine(work, "pointer.txt"); + File.WriteAllText(pointer, Copy(outside)); + using var loader = new ClientRuntimeLoader(Options, Copy(trusted, "a.dll"), pointer); + loader.Load(); + var before = loader.Runtime; + + Thread.Sleep(600); // the loader polls the pointer at most every 500 ms + loader.Update(0.01f, new InputState()); + + Assert.Same(before, loader.Runtime); + } +}