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:
parent
34aaebfb94
commit
d44666ec69
5 changed files with 177 additions and 4 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
97
tests/Aquarium.Engine.Tests/ClientRuntimeLoaderTrustTests.cs
Normal file
97
tests/Aquarium.Engine.Tests/ClientRuntimeLoaderTrustTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue