Skip to content

fix(server): sandbox the /s/:id surface document against top-level loads#122

Open
benvinegar wants to merge 1 commit into
mainfrom
fix/sandbox-surface-document
Open

fix(server): sandbox the /s/:id surface document against top-level loads#122
benvinegar wants to merge 1 commit into
mainfrom
fix/sandbox-surface-document

Conversation

@benvinegar

Copy link
Copy Markdown
Member

The gap

The core invariant is agent script must never execute where it can touch the board origin. The viewer upholds this for the embedded case — every surface iframe is sandbox="allow-scripts" with no allow-same-origin (opaque origin), and rich parts/comments render via sandboxed srcdoc. But /s/:id (the html-part document) is served from the board's own origin, and the sandbox lives only on the parent's iframe attribute.

So a top-level load of https://<board>/s/<id> — a user choosing "open frame in new tab", or clicking an agent-shared /s/:id link — runs the agent's <script> in the board origin. The page's meta-tag CSP blocks the API (connect-src) and frame creation, but:

  • a meta tag cannot carry a sandbox directive, and
  • nothing stops window.open('/'), which opens the real viewer same-origin — readable by the agent script, and exfiltratable via an img beacon (img-src https:).

That's a host-origin compromise reachable by getting the user to open one link.

Fix

Set the sandbox directive as a response header on /s/:id (the only place it can live):

c.header("Content-Security-Policy", "sandbox allow-scripts");

This forces the same opaque-origin sandbox however the document is loaded, matching the iframe's own flags exactly: allow-scripts so the bridge still runs, never allow-same-origin. On a top-level load the document now gets a null origin — it can't read the board's cookies/storage, and window.open('/') yields a cross-origin window it can't touch. The embedded path is unchanged (it was already opaque via the iframe attribute; the header is consistent and the intersection is identical).

/a/:id was already safe here (non-image uploads are served attachment + octet-stream + nosniff, so they download rather than execute), and srcdoc rich parts have no top-level URL. /s/:id was the only same-origin route serving agent-executable HTML.

Tests

  • Unit (test/api.test.ts): /s/:id carries a sandbox + allow-scripts CSP header and never allow-same-origin.
  • E2E (e2e/isolation.spec.ts): a top-level load of /s/:id renders (#probe shows, so scripts ran) and window.origin === "null" — proving the browser actually applies the opaque-origin sandbox, not just that the header is present.
  • Embedded rendering verified unaffected (full isolation suite 4/4 on Chromium).
  • npm run typecheck, npm run lint, npm run format:check, npm test (205/205) ✅

🤖 Generated with Claude Code

The viewer embeds surfaces in a sandbox="allow-scripts" iframe (opaque origin),
but /s/:id is served from the board's own origin — so a TOP-LEVEL load (open
frame in new tab, an agent-shared link) ran the agent's script in the board
origin, where it could reach same-origin storage or window.open('/') the real
viewer and read it. The iframe sandbox attribute doesn't apply to a direct
navigation, and a meta-tag CSP can't carry a sandbox directive.

Set `Content-Security-Policy: sandbox allow-scripts` as a response header on
/s/:id, forcing the same opaque-origin sandbox however the document loads:
allow-scripts so the bridge runs, never allow-same-origin. Unit test asserts the
header; an e2e test proves a top-level load actually lands in window.origin
"null" (and still renders), so the browser enforcement is real, not just the
header's presence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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