Skip to content

fix(doc): deny symlink escapes on /api/doc reads - #1437

Open
bendrucker wants to merge 3 commits into
backnotprop:mainfrom
bendrucker:doc-containment-gate
Open

fix(doc): deny symlink escapes on /api/doc reads#1437
bendrucker wants to merge 3 commits into
backnotprop:mainfrom
bendrucker:doc-containment-gate

Conversation

@bendrucker

Copy link
Copy Markdown

Consolidates file handling for the review UIs into one shared module. Fixes symlinks breaking project folder containment and reading arbitrary paths on disk.

Approach

Resolving a path and reading it are now separate steps, so the permission check can run between them.

Resolving may stat a path to see whether it exists. It now checks the realpath and requires that to be inside the project in addition to the input path.

realpath fails on a path that does not exist, and the check still has to judge those, since an outside path must be refused whether or not the file is there. So it resolves the deepest part of the path that does exist and re-attaches the rest.

That catches a missing file under a symlinked folder without turning an ordinary missing file into a denial. Anything unexpected back from realpath denies the request.

Behavior Changes

  • A symlink pointing outside the project returns 403, on every path through the endpoint, on both servers.
  • HTML over 2MB returns 413. It used to be served in full when you did not pass a base.
  • Point the project root at a symlink and requests now work whether you name the link or the real folder. Both sides have to be normalized, because macOS tmpdir() is itself a symlink and normalizing one side breaks every test on a Mac.
  • A base directory that reaches outside the project through a symlink is also refused.
  • A file that exists but cannot be opened returns 500 instead of 404, because the existence check now happens before the read.

Tests

Each way a symlink can escape has its own test on both servers, since a single test covering one of them passes against a fix that missed a branch. Also covers a project root named through a symlink, an outside path that does not exist, and the HTML size limit.

@backnotprop

Copy link
Copy Markdown
Owner

Heads up while you are actively on this: the branch is conflicting with main, which moved substantially today (a large header rework in packages/editor and packages/review-editor, plus new code-nav endpoints in both server runtimes), and the conflict is blocking CI from running on your latest commits. A rebase onto current main will unblock checks. The doc-read path you are hardening was not restructured, so the rebase should be mostly mechanical. Once it is green we will get you a full review promptly; this fix is wanted.

@bendrucker

Copy link
Copy Markdown
Author

No problem will rebase now!

@backnotprop

Copy link
Copy Markdown
Owner

Owe you an apology and one more rebase, and this one is entirely on us.

Yesterday a fix commit on main was made with a blanket git add and accidentally swept in ~150 of our untracked local scratch files (design notes, release-note drafts, QA docs). We have since cleaned it up properly, which required rewriting recent main history and force-pushing — so the commits your branch is currently based on no longer exist on main.

Your PR still renders correctly, but merging it as-is would reintroduce the pre-cleanup commits into main's history. One more git rebase origin/main (after a fresh fetch) onto the rewritten main will fix it — your two commits should apply cleanly, nothing in the areas you touched changed.

Sorry for asking twice in two days. The review itself is next on our list once the branch is rebased — the containment work looks solid.

`/api/doc` checked containment lexically only, so a symlink planted
inside an allowed root resolved to an in-root path and was then read
through to its target. All six content vectors leaked, plus the
existence oracle on `/api/doc/exists`.

Authorization was spread across 11 call sites in four branches of
`handleDoc`, each interleaving resolve, authorize and read, and
`apps/pi-extension/server/reference.ts` is a hand-maintained clone
carrying the same sites and the same hole.

Split resolve from read so authorization sits on one seam, in a shared
module both runtimes consume. Resolution may stat but never reads, and
the four render branches collapse to a document reader and a code
reader, which also erases the missing size cap on the HTML branch
rather than fixing it twice.

Deliberate behavior changes: escaping symlinks are denied on every
branch in both runtimes; the HTML branch enforces the 2MB cap; roots
are realpath-normalized so a root reachable through a symlink resolves
under either spelling.
Drop prose that restates the adjacent code and keep the facts it carried:
that resolveDocTarget's result is unauthorized, and why the annotate version
endpoints derive their history slug from the contained path.
@bendrucker

Copy link
Copy Markdown
Author

Not a problem, updated!

@backnotprop

Copy link
Copy Markdown
Owner

CI status on the rebased branch, with some triage to save you time:

What we've ruled out:

  • Not our known flake (CI flake: annotate-server durable-record tests fail on runners, pass locally #1464): that one passes on rerun; this failed twice deterministically.
  • Not main: main's tip is green on the same runners.
  • Not macOS: your branch passes the full suite locally on macOS, both standalone and merged with current main (58/0 both ways).
  • The Pi mirror of the same unwritable-directory test passes in the same CI run — only the Bun-server variants fail.

So it's Linux-runner-specific and introduced by the branch. Given your own note about having to realpath-normalize both sides because macOS tmpdir() is a symlink, our best guess is the inverse case: on Linux (no symlinked tmp) the path your resolver hands back for a served folder file may no longer match the path the submission-record machinery keys its directory by (the record dir slug hashes the file path), so the record lands somewhere the test - and real recovery - doesn't look. Worth checking how submissionsDir interacts with your normalized resolution on a non-symlinked tmp.

Happy to dig further if you get stuck - and thanks again for the quick rebase turnaround.

@bendrucker

Copy link
Copy Markdown
Author

Makes sense, I'll test on Docker locally this round, either directly or with act

storage.ts froze PLANNOTATOR_DATA_DIR at module import. Any consumer
that first loaded it through a dynamic import inside a test body captured
that test's temp dir for the rest of the process, so storage silently
disagreed with draft.ts, config.ts, feedback-archive.ts and the annotate
server, all of which resolve the dir live.

That is what made four annotate.test.ts cases fail on CI but not locally:
apps/opencode-plugin/embedded.ts reaches @plannotator/server through an
await import(), and Bun's filesystem-dependent file order put that file
before annotate.test.ts on CI only.

getPlannotatorDataDir() already reads the environment on every call, so
each use site now calls it directly. No signature changes.
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.

2 participants