Skip to content

HIGH security: confine the file IPC to the workspace when symlinks are involved - #24

Merged
aaroncoville merged 1 commit into
mainfrom
upstream/symlink-sandbox-escape
Aug 26, 2026
Merged

aaroncoville merged 1 commit into
mainfrom
upstream/symlink-sandbox-escape

Conversation

@aaroncoville

Copy link
Copy Markdown
Owner

What & why

The file IPC that backs the in-app editor confined paths to the selected
workspace with a purely lexical check (resolve + normalize + relative), and
then let Node open the result. A symlink placed inside the workspace but
pointing outside it — the kind of thing an agent-generated repo or a cloned
repo routinely contains — is lexically in-bounds, so the guard passed it and the
open followed the link to its external target. That allowed reading any file the
user can read and overwriting any file the user can write, from inside the
sandbox. getDiff's working-tree read had the same exposure with an additional
final-component TOCTOU window.

The fix canonicalizes the workspace root and the target with realpath,
re-checks containment on the canonical paths, rejects any symlink component that
survives canonicalization (this is what catches a dangling link, which
realpath cannot resolve), and opens with O_NOFOLLOW. Reads are centralized
through one openForRead helper so the flags cannot drift between sinks, and
getDiff now stats through the open handle rather than re-resolving the path.

In-workspace symlinks whose target stays in-workspace are still followed (so a
node_modules symlink remains browsable); only out-of-workspace targets and
dangling links are refused.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Docs
  • Build / CI

Evidence

A self-contained PoC driver builds a throwaway workspace, loads the real
src/main/fs.ts + src/main/git.ts, runs 7 symlink-escape attacks plus 3
controls, and cleans up — no real external file is ever touched.

Before

CleanShot 2026-08-26 at 14 01 53@2x

After

CleanShot 2026-08-26 at 14 02 53@2x

How I tested it

  • OS: macOS (Darwin)
  • Steps:
    • npm run typecheck — 0 errors (node + web)
    • npm run test:focused — 591 of 591 (9 new containment tests, incl. a
      deterministic check-to-open TOCTOU test for the getDiff read that shadows
      git on PATH to swap the final component mid-call)
    • npm run build — succeeds
    • the PoC driver above, before and after

Discord (optional)

Discord:

Checklist

  • Before and after evidence is attached above, under both headings.
  • npm run typecheck passes.
  • npm run test:focused passes.
  • npm run build succeeds.
  • This PR is one change. Unrelated fixes belong in their own PR.
  • I read the diff myself before opening this, and there is no debug output,
    commented-out code, or unrelated formatting churn in it.
  • Any new UI derives from DESIGN.md / tokens.ts — no ad-hoc colors,
    spacing, or fonts. (No UI change.)
  • If I added art, it's my own or compatibly licensed. (No art.)

A symlink inside the selected workspace let the file IPC read and overwrite
files outside it. `<workspace>/notes.txt -> ../elsewhere/target` reads as an
ordinary in-workspace relative name, and the read/write/list call then reached
the external target.

Root cause: the shared containment guard was lexical only. `resolve`,
`normalize` and `relative` are string math and know nothing about symlinks,
while the `readFile`/`writeFile`/`readdir` that runs afterwards is resolved by
the kernel, which does follow them. Every consumer of the guard was affected —
the text read, the binary read, the write, the directory listing, and both git
path operations — because they all share it.

The guard becomes an async `safeResolve` that all of them call, in three
layers:

1. canonicalize the workspace root and the target with `realpath` and re-check
   containment on the canonical paths. The not-yet-existing tail of a path is
   re-attached to its deepest existing ancestor, so creating a new file in a
   real workspace directory still works.
2. reject any path component that is a symlink, via an `lstat` walk. Not
   redundant with (1): `realpath` throws ENOENT on a dangling symlink, so
   canonicalization alone treats it as a name to be created and the write
   follows the link and creates the file at its external target.
3. open reads and writes with `O_NOFOLLOW` (plus `O_NONBLOCK`, so a FIFO cannot
   park the open and with it the IPC call), so containment does not rest on the
   path still meaning the same thing between the check and the open. Those
   flags are POSIX-only, which is why (2) is a check in its own right and not
   merely a pre-filter.

Layer 3 is why the git diff's working-side read had to change shape rather than
just gain a call to the guard. It was a plain `stat` + `readFile` on the cleared
path, which is a second and third resolution of a name the kernel is free to
look up differently — anything that swaps that final component for a symlink
after the check is what the read actually gets, and the external file then
travels to the renderer as diff text. It now opens through the shared
`openForRead` and stats through the returned handle, like every other read of a
confined path, so the flags are stated in exactly one place.

The boundary is the WORKSPACE, not symlinks. A link whose target exists and is
itself inside the workspace is followed, and what comes back is the canonical
path of that target: it reaches nothing the caller could not already reach by
the target's real name, and refusing it would make an ordinary `node_modules` or
monorepo checkout unbrowsable for no gain. What is refused is a link that leaves
the workspace, and a dangling link, whose target does not exist yet and so
cannot be shown to land inside it. Both directions of that are pinned by tests,
so the policy cannot be flipped by accident later.

The lexical form is no longer exported — a caller reaching for it would
reintroduce exactly this bug.
@aaroncoville
aaroncoville merged commit deaab43 into main Aug 26, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant