Skip to content

fix(annotate): block symlink escape in HTML asset serving + cleanups#927

Merged
backnotprop merged 3 commits into
mainfrom
fix/html-asset-symlink-escape
Jun 17, 2026
Merged

fix(annotate): block symlink escape in HTML asset serving + cleanups#927
backnotprop merged 3 commits into
mainfrom
fix/html-asset-symlink-escape

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

Security fix (the main one)

The raw-HTML annotate feature (#924) serves local support assets via /api/html-assets/<token>/<path> and inlines them into share payloads. Both checked path containment lexically, so a symlink inside the HTML's directory pointing outside it (e.g. evil.css -> ~/.ssh/id_rsa) passed the check and was served / base64-inlined. In remote mode the inliner auto-fires at server startup, so a malicious HTML bundle could exfiltrate symlinked local files to the paste service with no user action.

Fix: resolve symlinks with realpathSync on both the asset and the root before the relative-path containment check, in all three runtime copies:

  • packages/server/html-assets.ts (Bun route handler)
  • packages/shared/html-assets-node.ts (shared share-inliner; Pi gets it via vendor.sh)
  • apps/pi-extension/server/serverAnnotate.ts (Pi route handler)

Non-existent assets fall back to the lexical path and 404 on read; the root is realpath'd too so macOS /tmp/private/tmp symlinking doesn't cause false rejections. Adds regression tests for both sinks (route returns 403, inliner doesn't embed the secret).

Found via an adversarial multi-agent QA pass over the two unreleased PRs (#890, #924); confirmed and reproduced by two independent reviewers.

Also in this PR (unrelated, bundled to keep it moving)

  • test(pi): expand tests/manual/local/sandbox-pi.sh to build rich git state (multiple commits, a feature branch, rename+delete+modify) so /plannotator-review can exercise every diff mode.
  • docs: fix a broken verification link in README.md and apps/hook/README.md — they pointed at a non-existent installation/#verifying-your-install anchor; now point to the standalone /docs/reference/verifying-your-install/ page.

Test

  • bun test packages/server/annotate-html-assets.test.ts packages/shared/html-assets.test.ts — 11 pass (incl. 2 new symlink regressions)
  • bun run typecheck — clean
  • route-parity test green

The /api/html-assets route and the share-payload inliner checked path
containment lexically, so an in-directory symlink pointing outside the
HTML's folder (e.g. evil.css -> ~/.ssh/id_rsa) passed the check and was
served or base64-inlined into the share payload. In remote mode the
inliner auto-fires at startup, so this could upload symlinked local
files to the paste service with no user action.

Resolve symlinks with realpathSync on both the asset and the root before
the relative-path check, in all three runtime copies (Bun route handler,
shared node inliner, Pi route handler). Non-existent assets fall back to
the lexical path and 404 on read. Adds regression tests for both sinks.
Expand the Pi sandbox harness to create multiple commits, a feature
branch, and a rename+delete+modify commit so /plannotator-review can
exercise every diff mode (uncommitted, staged, branch, merge-base).
The READMEs pointed at a non-existent anchor
(installation/#verifying-your-install); the verification guide is a
standalone reference page. Point to /docs/reference/verifying-your-install/
(and split the hook README's link so version pinning -> installation,
verification -> the reference page).
@backnotprop backnotprop merged commit f564448 into main Jun 17, 2026
13 checks passed
@backnotprop backnotprop deleted the fix/html-asset-symlink-escape branch June 17, 2026 02:55
backnotprop added a commit that referenced this pull request Jun 17, 2026
… sinks

The symlink-containment check existed as four byte-identical copies (Bun
html-assets route, share inliner, annotate /api/share-html, Pi server). That
duplication is exactly why the escape was missed in one sink before — #927
hardened three and missed the fourth, and the #929 fix added a fifth-in-waiting.

Export the single canonical isWithinDirectory from the shared (Pi-vendored)
html-assets-node module and have every sink import it; delete the three
duplicate bodies and their now-dead realpathSync/relative/isAbsolute imports.
A new sink can no longer silently diverge. Regression tests + 690 server/shared
tests pass; build:pi clean.
backnotprop added a commit that referenced this pull request Jun 17, 2026
)

* fix(server): always show remote URL + close share-html symlink gap

Two pre-release fixes found by the QA sweep.

1. Remote URL stranding: handleServerReady only opened a browser (silent on
   a headless box) and wrote a ready-file; the user-visible URL came solely
   from writeRemoteShareLink, gated on sharingEnabled. So a remote/SSH user
   with sharing disabled (PLANNOTATOR_SHARE=disabled or config.json
   {share:"disabled"}, widened by #921) saw no URL and the agent hung on
   waitForDecision() forever. Now handleServerReady prints the reachable
   localhost URL whenever the session is remote, independent of sharing; the
   share link stays an extra. Pi/OpenCode already printed the URL
   unconditionally and are unaffected.

2. share-html symlink gap: the #927 symlink-containment fix hardened the
   /api/html-assets asset sinks but missed packages/server/annotate.ts's
   /api/share-html containment, which stayed lexical. A symlinked *.html
   inside the doc directory pointing outside it leaked the target's contents
   into the share payload. Now realpath-resolves both root and target like
   the other sinks. Bun-only — Pi's copy was already hardened.

Adds regression tests for both (handleServerReady remote stderr; share-html
returns 403 on symlink escape).

* refactor(server): single shared isWithinDirectory for all asset/share sinks

The symlink-containment check existed as four byte-identical copies (Bun
html-assets route, share inliner, annotate /api/share-html, Pi server). That
duplication is exactly why the escape was missed in one sink before — #927
hardened three and missed the fourth, and the #929 fix added a fifth-in-waiting.

Export the single canonical isWithinDirectory from the shared (Pi-vendored)
html-assets-node module and have every sink import it; delete the three
duplicate bodies and their now-dead realpathSync/relative/isAbsolute imports.
A new sink can no longer silently diverge. Regression tests + 690 server/shared
tests pass; build:pi clean.

* fix(pi): surface the session URL in the in-turn 'opened' notice for remote

Remote Pi users never saw a review/annotate/last URL: it was emitted from
openBrowserForServer AFTER the command's turn ended (fire-and-forget), and
Pi only renders a notify during an active turn — so it silently dropped.
The 'X opened. You can keep chatting' notice fires in-turn and DOES show,
so fold the URL into it for remote sessions (single-line, matching the
notify convention). Local sessions are unchanged (browser auto-opens).
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