ClientRuntimeLoader: client assemblies must resolve under a trusted root #1
Loading…
Reference in a new issue
No description provided.
Delete branch "aqua/client-runtime-trusted-root"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes the pointer-file load hole in the custody map (section 13.3). Proposal and reasoning: handoff note
aqua-fensalir-pointer-load-fix.md.The real boundary is the trusted directory's ACLs. This check decides which directory may hold client code; it does nothing about who can write there.
scripts/dev-reload.ps1createsartifacts\dev-reloadwith default ACLs, and this PR changes no ACL. Anyone who can write inside the root still owns the process. Everything under the root is client code: the host build copied into each slot and the shader sources next to it are loaded or consumed by the host as well, so the root must be writable only by the developer account, not just the client assembly.Rule. Every client assembly, and every dependency or native library its load context resolves, must lie (links followed) under the trusted root, or the load is refused:
Reload(initial--client-assemblyload and pointer reload) and the load context's managed and unmanaged resolution all call one check. The pointer target gets the string rules (local, fully qualified) before any filesystem probe, and every logged path is escaped once, at the console write (exception messages carry the raw path), by code point: backslash, control, format including tag characters (U+E0001, U+E0020-E007F), astral format characters (e.g. U+1D173-1D17A), line/paragraph separators, and lone surrogates. Ordinary astral characters (emoji) are kept. A startup refusal inAquariumHostis printed the same way, with the escaped exception type name. Cost: on Windows the host now logs backslashes doubled (C:\\x). A path outside the root (as resolved or as given) is refused by string comparison before any filesystem access, so a link outside it (to a UNC share, a mapped drive) is never followed or probed. Relative and UNC paths (any/or\mix at the start) are refused.--client-trusted-root/AQUARIUM_CLIENT_TRUSTED_ROOT, default the directory of the initial assembly. It is resolved once in the constructor and pinned; a drive or volume root is refused; the effective root is logged at startup. The environment variable is taken once byTrustedRootSetting(cleared on every path, flag or env, and cached) so processes this one starts do not inherit it, and an empty or missing flag value, or a blank environment value, is refused rather than falling back to the default root.dev-reload.ps1 passes
$assemblyRoot(artifacts\dev-reload\assemblies) as the root, not the whole dev root (which also holds cache, state and logs): rebuilds land inassemblies\slotsand dev-watch writes its reload pointer underassemblies\live-slots. dev-watch passes nothing itself. dev-reload also pruneslive-slots(kept:-RetainSlotsnewest) and a -Reopen with state naming a path outside the root or a pruned file fails up front with a message; dev-watch matches the host ack by the unique slot leaf and assembly name, with backslashes doubled as the host logs them. Transition: astate.clixmlwritten before this change names an oldslotspath outside the new root, so the first reopen after upgrading is refused until one fresh dev-reload build.Tests (
ClientRuntimeLoaderTrustTests, 30): in-root load and reload; sibling directory loads under an explicit root and not under the default; initial assembly outside the root; outside,..and relative reloads; UNC in four separator mixes; volume root refused; dependency/native check (the static check the load context calls); symlinked file and directory escapes; a junction escape (a non-administrator junction refused by the OS as an untrusted mount point is reported as InvalidOperationException too); link stored by resolved path; root pinned at construction; pointer outside the root keeps the previous runtime; a UNC pointer is reported before any file probe; control characters in a pointer target are escaped in the log (skipped on Windows, where a file name cannot hold a newline); a literal backslash in a pointer target is shown as two characters, not four (escaped once);Printableescapes tag characters, musical format characters and lone surrogates by code point and keeps emoji; a startup refusal exits 1 and logs the escaped exception type; a loaded client'sAssembly.Locationequals its resolved file path; the env variable is cleared on flag and env paths and an empty flag is refused; the dev scripts keep slots and live-slots under one assemblies root that is not the dev root (this last one reads the script text, so it is brittle). Symlink tests report Skipped with a reason if the account cannot create links; the junction test is Skipped everywhere but Windows. Test cleanup retries, then prints a warning naming the directory if a locked assembly blocks the delete.Results. On Linux: loader tests 29 pass, 1 skipped (junction), 30 total. Engine test project: 86 pass, 2 fail, 1 skipped, 89 total; the 2 failures are
D3D12SharedTextureInteropTests(needd3d11.dll; identical on main34aaebf). The new tests were run against the earlier loader and fail there: backslash-once, rune escaping and host startup (against467a97f),Assembly.Location(against the in-memory loader ofd2f280d). The mutation results from the earlier revision of this PR (lexical check, env handling, escaping, ack match, live-slots pruning, each killed by a named test) were not re-run after the later commits. Nothing in this PR has run on Windows.A second reader should check
LinkTargeton junctions,\?\prefixes (refused as UNC-shaped), drive-relativeC:x(not fully qualified)..deps.jsonresolves outside the root, so removing the check inside the load context would still pass. The log line and the environment clearing are untested.--client-trusted-root. Checked: Fensalir capture scripts, Perlines capture and Mimir stay inside their own directory.Not merged.
Follow-ups (not in this PR)
8.3 short names via GetFullPath: UNVERIFIED, nobody has tested it on Windows. The root check comparesPath.GetFullPathresults (ClientRuntimeLoader.csRequireLexicallyUnderRootandResolveTrusted), which is believed not to expand 8.3 short names, so a short-name spelling of a path may not compare equal to its long form.prune live-slots: pruning exists:Remove-OldSlotsinscripts/dev-reload.ps1keeps the newest-RetainSlots(default 12) directories per root, under bothassemblies\slotsandassemblies\live-slots, anddev-watchruns dev-reload on every reload. The residuals: directories from before this change sit at the oldartifacts\dev-reload\slotsandartifacts\dev-reload\live-slotsand are never pruned (the new code only looks underassemblies\);dev-watch.ps1itself never prunes, so between dev-reload runs live slots accumulate; a failed delete (locked slot) only logs. Unrun on Windows.SynthPlayback alpha.3:SynthPlaybackTests.AquaSynthPatchCompilerCanRenderAudiblePatchForEnginePlaybackfails withMethod not found: AquaSynth.Dsl.FaustExportOptions..ctor(String, Boolean, Boolean); the checked-in AquaSynth.Core 0.1.0-alpha.3 lacks the three-argument constructor the Faust package calls (identical on main, PR does not touch AquaSynth).SynthPlayback alpha.4: AquaSynth.Faust 0.1.0-alpha.4 (checked in, depends on Core alpha.3) was built against a newer Core; the pair inpackages/must be rebuilt or re-versioned together, and the test should skip or assert on a clear message until then.Windows-only changes in this PR (junction refusal as InvalidOperationException, test cleanup tolerates locked files, newline test skipped) were not run on Windows.
Known limits
ResolveAssemblyToPathnull) returns null fromLoad(ClientRuntimeLoader.cs:339), so the runtime falls back to the default context, which probes the host directory with no root check; the shared host assembly is intentionally returned null the same way (:335).IntPtr.Zero(:345), so the default native probing runs, again without a root check.🤖 Generated with Claude Code
Read-only second read at
d44666e(no build, nothing run; findings are from reading the diff and the full loader).Not clean.
ClientRuntimeLoader.cs:58checksassemblyPaththroughResolveLinks, but:60(ClientAssemblyLoadContext, so alsoAssemblyDependencyResolver) and:65(LoadFromAssemblyPath) take the original string, and:91stores it. Check and load resolve the path separately, so a link swapped between them redirects the load.RequireTrustedshould return the resolved final path and the load should use that. A race remains for anyone who can write inside the root, but that writer can already drop a dll there.:111,ResolveLinks(trustedRoot)), not pinned at construction. Replacing the root directory with a link silently moves the boundary. Needs write access to the root's parent, so same threat class as 1.RequireTrusted.ClientAssemblyLoadContext.LoadandLoadUnmanagedDll(:213-231) load whatever.deps.jsonresolves, with no root check. Inside-the-root writers only.:29-32). If the initial assembly is itself a link to elsewhere,ResolveLinkstakes it outside the root and the initial load is refused. Fail-closed, but--client-trusted-rootbecomes mandatory there; untested.DirectoryInfo.LinkTargetcovers them, so OK in principle, but no junction test.\\?\and\\.\are refused by the\\prefix test;\??\is not fully qualified, refused./\host\share\x.dlland\/host\share\x.dllpass the\\///string test (:104-106,IsPathFullyQualifiedaccepts mixed-separator UNC) and are caught only by the root comparison. If the root itself is UNC (via the hatch) they pass. Test the mixed forms or normalise first. Mapped drives pointing at shares are not resolved.dev-reload.ps1creates$slotRootwithNew-Itemdefault ACLs (:33). The rule is only as strong as who can write the root.--client-trusted-rootorAQUARIUM_CLIENT_TRUSTED_ROOT(AquariumHost.csnewParseClientTrustedRoot) is accepted as any path, including a drive root or/, and nothing logs the effective root at startup. The env var is inherited by child processes. A flag with no value is silently ignored. Suggest: log the resolved root, and refuse or warn on a filesystem root.Tests (
ClientRuntimeLoaderTrustTests.cs), by reading, not mutation-run. Roughly 4 of 5 are real catches, with caveats::194is a positive control only; it catches over-refusal, not loosening.:205real (initial-load check).:212: the outside and..assertions are real, but the relative (:219) and///\\UNC (:220-221) assertions are redundant with the root comparison. Deleting the explicit relative/UNC check leaves them green.Assert.Throws<InvalidOperationException>also accepts any reason, including "No trusted root".:225real when links can be made, but returns at:237and reports a pass on hosts that cannot, such as Windows without symlink privilege. No junction case.:247real (removingRequireTrustedmakes the runtime change), but has no positive control that an in-root pointer target does reload, so a dead pointer path would also pass.Read-only re-read at
7ec7a0f(no build, nothing run).Landed: root resolved once and pinned (
ClientRuntimeLoader.cs:39,51); load and record use the resolved path (:73-74,84,110,146-153); volume root refused after link resolution (:40-46); effective root logged (:48); managed and native dependency resolution go through the same check (:255,261); mixed-separator UNC refused at:173; the PR body states the ACL boundary.Wrong:
AquariumHost.cs:266-268returns before the env clear at:274. With--client-trusted-rootpresent,AQUARIUM_CLIENT_TRUSTED_ROOTis never read or cleared, so children still inherit it. An empty flag value (--client-trusted-root "") also returns early, and the loader then takes the default root rather than the env value. Clear the variable unconditionally, before the flag loop.ClientRuntimeLoader.cs:139:File.Exists(candidatePath)runs on the raw pointer string beforeResolveTrustedat:146. A pointer holding\\attacker\share\x.dllmakes Windows touch the share (SMB/NTLM authentication) every 500 ms, then the UNC refusal fires. The UNC check at:173is pure string work; runResolveTrustedfirst, then test existence.:158,:175,:182interpolate the pointer-controlled string into stderr/stdout. Interior newlines forge log lines; the length is unbounded. Quote or escape it (e.g.:q-style JSON string orPathwith control chars replaced). The:48line prints the absolute root (includes the user profile name); that is fine for a local log, but notedev-reload.ps1:30-31writes stdout/stderr into$devRoot, which is now the trusted root.dev-reload.ps1:199passes$devRoot, wider than theslots/live-slotsthe loader needs. It also contains the cultcache,state.clixmland the logs, so a dll that any of those writers drops there loads. Two subdirectories cannot be one root, but consider passing the pointer's target directory only, or document that the dev root is the trust unit.AquariumHost.cs:21,:273), so nothing breaks today. A second call toParseClientTrustedRootin the same process (a test, a relaunch) gets null and silently falls to the default root.Clean by reading, but untested: case (
GetRelativePathis OrdinalIgnoreCase on Windows, Ordinal elsewhere; exotic gap only for per-directory case-sensitive folders); 8.3 short names and trailing dots/spaces (GetFullPathexpands and strips them before the containment check, and the normalised path is what gets loaded);\\?\,\\.\,\??\;/\and\/UNC. No test covers 8.3, trailing dots or\\?\. Mapped drives andsubstletters are not resolved (a root on one trusts that share).Read-only re-read at
45b0b38(no build, nothing run).Landed: env read and cleared first on every path (
AquariumHost.cs:264-265); flag without a value or with an empty one throws (:275-278); UNC string rules in any separator mix run beforeFile.Exists(ClientRuntimeLoader.cs:146,190-196); every path and exception message in the loader's logs goes throughPrintable(:45,48,160,164,184,194,224,236); dev-reload passesartifacts\dev-reload\assemblies(dev-reload.ps1:202), and slots, live-slots (dev-watch.ps1:23) sit under it while state, cache, logs and the pointer stay outside.Wrong:
dev-watch.ps1:458,463: the live-reload acknowledgement looks for the exact lineClient runtime reload applied: $liveAssemblyPath, but the host now prints the resolved path (ClientRuntimeLoader.cs:160,Printable(resolved)). Whenever the script's path differs from the resolved one (repo under a junction/symlink, 8.3 short name,..), the reload is applied and dev-watch still throws "did not acknowledge" at:479.String.Containsis also case-sensitive, so a case difference does the same. Match on a path-insensitive token, or have the script resolve its path the same way.AquariumHost.cs:261-284: "read once and cached" is not done. The env var is read per call; a second call in the same process (test, relaunch) returns null and silently falls to the default root. Also an empty or whitespaceAQUARIUM_CLIENT_TRUSTED_ROOTwith no flag returns""(:283) and the ctor takes the default root (ClientRuntimeLoader.cs:34), while an empty flag throws. Treat an empty env value as the same error.ClientRuntimeLoader.cs:147(your item 7) and:180: containment is still checked after a filesystem probe.File.Existstouches the local path first, andResolveTrustedthen callsResolveLinks, whoseDirectory.Exists/LinkTargetalso probe every parent of the unchecked path (:227-239). A symlink to a UNC share planted anywhere local, or a mapped network drive letter named in the pointer, still makes the machine connect before the root check refuses. Do the lexical containment check (GetFullPath+GetRelativePathagainst the pinned root, no FS access) before any probe, then resolve links.Printable(:199-215) escapes onlychar.IsControl(Cc). Not escaped: U+2028/2029, bidi controls U+202A-202E and U+2066-2069 (log text can be visually reordered), and the backslash itself, so a pointer containing a literal\nor\u000alooks the same as an escaped newline. Escape Cf/Zl/Zp as well and escape\.dev-reload.ps1:85,91:Remove-OldSlotsonly prunes the newassemblies\slots. The oldartifacts\dev-reload\slotsis never cleaned, so up to 12 full build outputs stay orphaned. The stalestate.clixmlrefusal on first reopen is as the PR says:Reopen-VisibleProcess(:242-248) passes the oldliveAssemblyand the host dies at load with the "outside the trusted root" message; the script does not detect it up front.Aquarium.Engine.exe, host dlls) and dev-watch copies shader sources into the running slot (dev-watch.ps1:347-348,426). Not a bypass, but the "everything under the root is client code" wording is looser than the contents.Mapped/subst drives: no bypass found (a different letter for the same directory fails closed, since
GetRelativePathreturns a rooted path), but see item 3 for the probe. A UNC-mapped root is accepted.Read-only re-read at
467a97f(no build, nothing run).Landed: (3) lexical containment against the root as resolved and as given runs before
File.Exists(ClientRuntimeLoader.cs:148-150) and beforeResolveLinks(:201-203). (1) The dev-watch regex is right for the line the host writes:appliedSuffixis<slotLeaf>\\Aquarium.Fensalir.dllwith doubled backslashes,[regex]::Escapeturns each into\\, which matches the two literal backslashesPrintableemits at:163;"$slotLeaf\"is a valid PowerShell string; the suffix is ASCII so code-page differences in the redirected log do not matter. (2)TrustedRootSettingreads and clears once viaLazyand caches; blank env, blank flag and a flag without a value throw (TrustedRootSetting.cs:25-41). (4)Printablecovers backslash, control, Cf and U+2028/2029 for BMP characters. (5)Remove-OldSlotscovers both roots andReopen-VisibleProcesschecks the recorded paths up front. (6) The PR body says everything under the root is client code.Wrong:
Printable(ClientRuntimeLoader.cs:188,206,216,252,264) and thenTryReloadFromPointerpasses the whole message throughPrintableagain (:167). On Windows every backslash in a refusal message is therefore quadrupled (C:\\\\x) while the applied line (:163) is doubled, and a literal backslash in an attacker's pointer text is no longer distinguishable. Escape once: either keep raw paths in exception messages and escape at the log site, or logerror.Messageraw for the loader's own exceptions. The control-character test (:664) does not catch this.Printableis char-based and misses astral Cf characters (:225-240).char.GetUnicodeCategory(char)returnsSurrogatefor each half of a surrogate pair, so tag characters U+E0001, U+E0020-E007F (invisible, spoofable) and the musical format controls U+1D173-1D17A pass through unescaped, as do lone surrogates. Iterate runes (EnumerateRunes,Rune.GetUnicodeCategory) and escape Cf/Zl/Zp/Cs/Cn/Co as\u{...}. The test at:759-766only has BMP cases.Path.GetFullPath(:180,202) expands 8.3 short names when the path contains~, which touches the filesystem before the containment check passes. A pointer namingC:\evil\link~1\x.dllcan make it enumerate local directories (including through a link) before refusal. Local-only, low risk; either reject~segments in the unchecked string, or comparePath.GetFullPathof the root and path after a lexical..-collapse that does no expansion.dev-reload.ps1:303,337and the normal path). During a long dev-watch session new live slots accumulate (dev-reload.ps1comment says dev-watch never prunes), solive-slotsstill grows without bound until the next full dev-reload. The pre-changeartifacts\dev-reload\slotsandlive-slotsare never cleaned either (Remove-OldSlotstouches only the new roots).Not tested: the dev-watch ack regex and
-Reopencheck are tested only by reading the script text; nothing parses the PowerShell or runs it.Read-only re-read at
d2f280d(no build, nothing run). What the in-memory load weakens, and what landed.Landed:
Printablenow works by code point (tag characters, astral Cf, lone surrogates) and exception messages carry raw paths with the console write escaping once (ClientRuntimeLoader.cs:167,AquariumHost.cs:31); startup failure is caught, logged escaped and returns 1 with the loader disposed (AquariumHost.cs:25-34).Weakened or wrong:
Assembly.Locationis now empty and the client uses it.FensalirRenderPlan.cs:35-36(ShaderPath) andFensalirFractalScene.cs:45-46(AssemblyDirectory) takePath.GetDirectoryName(typeof(...).Assembly.Location) ?? AppContext.BaseDirectory. For an in-memory assembly the directory is null, so both silently fall back to the host's base directory, not the client's slot. In a plain slot they coincide; after a dev-watch live reload the client dll sits inlive-slots\<new>but shaders and world files (Aquarium.Fensalir.csproj:18-20copies them next to the dll) are read from the host's original slot. Likely a regression for live reload (shader hot-reload copying into the running slot masks part of it, not the worlds/fractal assets). Needs a Windows run; there is no test for the fallback.LoadFromFileWithoutLock(:345-356) reads the bytes once and loads exactly those bytes, so the read-to-load gap is closed. ButFile.ReadAllBytes(path)reopens the resolved path by name afterResolveTrusted, so a link swap inside the root between check and read still wins. To close it: open aFileStream/handle, verify its final path is under the root (GetFinalPathNameByHandleon Windows,/proc/self/fdorrealpathof the fd elsewhere), then read from that handle. For the same reason, the bytes are now a natural place for the hash pin the PR says it lacks.:348-355).Path.ChangeExtension(path, ".pdb")is neither resolved nor checked:File.ExistsandReadAllBytesfollow a symlink out of the root and feed an arbitrary file to the runtime as a PDB, with its own check-to-read gap. Run it throughResolveTrusted(or skip symbols when it fails).ResolveAssemblyToPath→ResolveTrusted(:338-339). Anything the resolver returns null for falls to the default context (:335,:339returns null) and is loaded from the host's TPA/base directory, outside the root, with no check. That is as before, but it is the one route by which a dependency not indeps.jsoncomes from outside; state it in the PR body, or haveLoadrefuse non-framework names.ResolveTrustedthenLoadUnmanagedDllFromPath(:361) reopens the path, so the check-to-load gap is open for them, and a loaded native library stays locked and loaded for the process (collectible contexts do not free them), so the Windows lock the change avoids for managed code remains for natives. Names not indeps.jsonfall to default probing, and with noAssembly.Locationthe "assembly directory" probe is gone for stream-loaded dependencies..pdbsits beside the dll (embedded PDBs still work). Debugger module paths are empty; Source Link, Edit-and-Continue and .NET Hot Reload likely do not bind to in-memory modules (unverified). Any dependency that reads its ownAssembly.Location/CodeBase(plugin-style resolvers, resource probing) gets an empty string. No strong-name or hash check is lost, because none exists in .NET Core and the PR pins no hash.AssemblyLoadContextunload is unchanged (collectible context, no file handles for managed code).Read-only re-read at
739e2bdagainst467a97fandd2f280d(no build, nothing run).Clean: (1) the revert is complete:
ClientRuntimeLoader.cshasLoadFromAssemblyPath(:86,339) andLoadUnmanagedDllFromPath(:345) only; noLoadFromStream,ReadAllBytes,MemoryStreamor.pdbhandling remains, and the diff against467a97fin this file is only the Printable (code-point) and junction-refusal changes. (2) Containment is unchanged:RequireLocalFullyQualifiedandRequireLexicallyUnderRootbeforeFile.Exists(:148-150), lexical check beforeResolveLinks(:201-203), and the path loaded is the resolved, checked one (:155,162,:339,:345). (3) The test-cleanup swallow is inClientRuntimeLoaderTrustTests.Disposeonly; no product code catches or hides IO errors. (5) Scripts,TrustedRootSetting.csandAquariumHost.csenv handling are byte-identical to467a97f; the only new differences are the intended ones (startup catch and return 1, raw exception messages with one escape at the console, junction asInvalidOperationException).Wrong or weak:
prune live-slots: says old live-slot directories "are never removed", but the body (dev-reload also prunes live-slots) anddev-reload.ps1Remove-OldSlotsnow prune both roots; only the pre-changeartifacts\dev-reload\slotsandlive-slotsare orphaned and pruning only runs inside a dev-reload run. Reword to the real residual.8.3 short namesfollow-up vs the Rule paragraph: the Rule says a path outside the root is refused "by string comparison before any filesystem access".Path.GetFullPath(:180,202) is the first step of that comparison; whether it expands 8.3 names (and so touches the filesystem) on Windows when the path contains~is the point the follow-up asserts the opposite about. Unverified on Windows either way; the claim should be stated as unverified or the call replaced by a filesystem-free normaliser.467a97ftests and counts and do not mention the new startup-refusal-returns-1 test, the exactly-once backslash test, the astral/lone-surrogate Printable test or theAssembly.Locationtest. The Rule paragraph says escaped characters are "control, format (bidi, zero-width) and line/paragraph-separator"; the code now also covers tag characters, other astral Cf and lone surrogates.Loadreturns null (:335,339), so resolution falls to the default load context (framework and host TPA, and the host base directory). Native names the resolver returns null for (:345) also fall to default probing. Add the framework/default-context wording, and that natives stay loaded and file-locked for the process lifetime. The "check-to-open" wording is accurate; the pdb path no longer exists.AquariumHost.cs:28-33: the startup catch prints onlyerror.Message; a client factory orStart()exception loses its type and stack, which the earlier unhandled-exception path printed. Print the type name at least (escaped), and the stack to a log if the message path stays escaped.ClientRuntimeLoaderTrustTests,Dispose): silently leaves the work directory after 5 failed attempts. Not a product risk, but a loader that never releases the file (a lock regression) passes every test on Linux and is silent on Windows; log a warning (or assert after the retry) so a persistent leak is visible.