fix(studio): substrate containment defeated by a symlinked root and a non-version path (PX0 exit SEC-1+SEC-2) - #523
Merged
KnockOutEZ merged 4 commits intoAug 29, 2026
Conversation
…ainment escapes Both shapes reach the record that backs the prompt-less substrate spawn, and both pass every containment check the module has: the symlinked root because each side resolves through the link, the nested path because it really is inside the root. Red at tip, with controls that the escapes are real and that a linked ANCESTOR of the root still reads.
… not root/<version> The record backs a prompt-less spawn, and its docstring justified that by saying the acquirer writes path as substrateRoot()/<version> and nothing else. Two shapes made that false. A root that is itself a link resolved along with the candidate, so every containment comparison agreed for a tree outside the data dir; readSubstrateRecord and acquireSubstrate now both refuse it via lstat, which is blind to a linked ancestor and so keeps the macOS /var -> /private/var shape working. And path was only required to be somewhere under the root; it must now resolve to join(root, version), with version required to be one directory name so the join cannot be '.' or a traversal. Two fixtures planted a path the acquirer cannot produce and are corrected.
…he SEC-2 arm The nested-path arm was green against a build with the equality deleted: with no real <root>/<version> on disk the refusal came from the expected location being absent, which is the weaker half of the rule. It now plants a legitimate install alongside the decoy, so both sides resolve and only the inequality refuses; the absent-location case keeps its own arm. Dropping isInside(raw.path, root) likewise changed no test — the equality dominates it and the executable check is what refuses a linked <root>/<version> — so it is removed rather than left to be trusted.
…, not on the platform The nine skipIf(win32) arms cited 'creating a symlink needs elevation', which this same file contradicts by planting an unskipped junction. The accurate rule is about link TYPE: a junction needs no elevation but is a directory link that Node normalises to an absolute target, and the arms driving acquireSubstrate have their plant re-created by cpSync with no type hint, so a junction does not survive the copy. The two record-level arms need no copy, so they now plant junctions and run unconditionally on all three shipped OSes; the executable arm links bin/ rather than bin/run to make that possible. The seven copy-driven arms ask the machine whether it can make ordinary links instead of assuming Windows cannot, so they run on a Windows runner with Developer Mode. Un-skipping them blindly would have been worse than the skip: four expect outcome 'failed', which is also what a cpSync EPERM produces. A probe arm keeps a silently-false capability answer from vacating the family.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes
wigolo-studio-run#197. PX0 exit-review SECURITY, two confirmed MED findings, both insrc/studio/substrate-acquire.ts.Both defects falsified the decision record at
readSubstrateRecord's docstring — the one that backs the prompt-less substrate spawn on the fetch path (S9's amended-D4: "the filesystem, not the text, answers it"). The docstring's premise is thatacquireSubstratewritespathassubstrateRoot()/<version>and nothing else, so a record naming anywhere else was not written by the acquirer. Two shapes made that premise false while every containment check passed.SEC-1 — a symlinked substrate root defeats containment
isInside(candidate, root)realpath-resolves BOTH sides then prefix-compares. Resolving both sides is correct — on macOS the data dir sits under/var -> /private/var, and resolving one side only would decline every legitimate record there — and it is structurally blind to the root itself being a link. Where<dataDir>/substrateis a symlink, both sides resolve into the link's target, every comparison agrees, and the spawn target lives entirely outside the data dir — so it is not what this product installed, andrm -rf ~/.wigolodoes not remove it either.lstatis exactly the missing distinction: it stats the entry rather than what the entry points at, so a linked ancestor stays invisible (the macOS shape keeps working) and the root's own link-ness does not. A junction answersisSymbolicLink()true, so the rule holds on Windows as well. Refused inreadSubstrateRecordand, for the same reason, inacquireSubstrate— a linked root does not stopinstall(), so acquisition would otherwise reportacquiredfor a component copied outside the data dir and then read back as absent on every later run.SEC-2 — a record could name any path under the root, not
root/<version>pathwas only required to be somewhere inside the root. It must now resolve torealpath(join(root, version)), withversionrequired to be a single directory name — otherwisejoin(root, '.')is the root itself and a payload dropped besiderecord.jsonreads back as an installed component, which is the same "not written by the acquirer" class one level up.Repro-first, and the arms have teeth
Tip-red arms for both shapes were committed before the fix (
8454f009→96d1841d), with controls that each escape is real: for SEC-1 that the existing containment comparison passes on it and the spawn target resolves into the attacker's directory; for SEC-2 that the path genuinely is inside the root.The first SEC-2 arm was green against a build with the equality deleted — with no real
<root>/<version>on disk the refusal came from the expected location being absent, the weaker half of the rule. It now plants a legitimate install alongside the decoy, so both sides resolve and only the inequality refuses. Mutation table, each guard deleted in turn and reverse-edited back:71 passed (71)readSubstrateRecorddropsisLinkEntry(root)1 failed | 70 passedreadSubstrateRecorddropsisSingleDirectoryName(version)1 failed | 70 passedreadSubstrateRecorddrops theroot/<version>equality1 failed | 70 passedacquireSubstratedropsisLinkEntry(root)1 failed | 70 passedreadSubstrateRecorddropsisInside(exec, root)2 failed | 69 passedif (!isInside(raw.path, root))is deleted, not kept as defence in depth: the equality strictly dominates it, and where<root>/<version>is itself a link out of the root the equality holds — both sides are the same spelling — so containment never decided that case either; the executable check does. Deleting the line changed no test. A guard no arm can kill is not a layer, it is a line a future reader will trust. (A-197-2.)Fold: cov-finding-1, the nine
skipIf(win32)containment armsTheir rationale — "creating a symlink there needs elevation" — is contradicted by the same file, which plants an unskipped junction. But un-skipping was not the correction. The accurate rule is about link type: a junction needs no elevation but is a directory link Node normalises to an absolute target, and the arms that drive
acquireSubstratehave their plant re-created bycpSync(..., verbatimSymlinks: true), which callssymlinkSync(target, dest)with no type argument — so a junction does not survive the copy.bin/rather thanbin/runto make that possible). Both new SEC arms are junction-based and run on win32 too.outcome: 'failed', which is also what acpSyncdying of EPERM produces — four arms passing while testing nothing. A probe arm keeps a silently-false capability answer from vacating the family.Demo
The exit session's two repro shapes, re-run against the built
dist/before and after. Full JSON in the closing comment.019f160c)nullnullnullroot/<version>nullversion: "."nullVerification
npm run build0 ·npx tsc --noEmit0 ·npm run typecheck:studio0 ·npm run gate:studio0 (tests/ type-check debt holds at baseline 363) · full root suite green.CI deferred — quota (CEO 2026-08-28): merged on local green.
Territory
Lane
core:studio-coreownssrc/studio/**andtests/unit/studio/**— three of the four files.lane-extra:declared fortests/unit/cli/studio.test.ts: itsplantRecordfixture planted<root>/installed, a path the acquirer cannot write, so SEC-2 necessarily reds it. Corrected to<root>/<version>, which makes the fixture more faithful, not less. The same correction was needed intests/unit/studio/auto-launch.test.ts(in lane).Non-goals held
No change to launch/spawn wiring beyond what containment required — the
deps.launchableguard gap, the catch-blockrmSyncand the env passthrough stay POLISH issues. No move ofsrc/studio/**to the private repo.