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 <noreply@anthropic.com>
This commit is contained in:
Aqua 2026-11-07 10:45:23 +00:00
commit d44666ec69
5 changed files with 177 additions and 4 deletions

View file

@ -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"

View file

@ -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<string> 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<string> args)
{
var values = args.ToArray();

View file

@ -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)
/// <summary>
/// Every assembly this loader runs must resolve, links followed, to a path under <paramref name="trustedRoot"/>
/// (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.
/// </summary>
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))

View file

@ -20,6 +20,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Aquarium.Engine\Aquarium.Engine.csproj" />
<ProjectReference Include="..\..\src\Aquarium.Sample.Minimal\Aquarium.Sample.Minimal.csproj" />
</ItemGroup>
</Project>

View file

@ -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<InvalidOperationException>(() => loader.Load());
}
[Fact]
public void ReloadOutsideTheRootDotDotRelativeAndUncPathsAreRefused()
{
using var loader = new ClientRuntimeLoader(Options, Copy(trusted, "a.dll"));
loader.Load();
var escaped = Copy(outside);
Assert.Throws<InvalidOperationException>(() => loader.Reload(escaped, Options));
Assert.Throws<InvalidOperationException>(() => loader.Reload(Path.Combine(trusted, "..", "outside", "client.dll"), Options));
Assert.Throws<InvalidOperationException>(() => loader.Reload("a.dll", Options));
Assert.Throws<InvalidOperationException>(() => loader.Reload("//host/share/client.dll", Options));
Assert.Throws<InvalidOperationException>(() => 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<InvalidOperationException>(() => loader.Reload(fileLink, Options));
Assert.Throws<InvalidOperationException>(() => 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);
}
}