Skip to content

fix(webapp): load an HTML page's relative files in its sandboxed frame - #338

Open
thefron wants to merge 1 commit into
mainfrom
fix/html-relative-assets
Open

thefron wants to merge 1 commit into
mainfrom
fix/html-relative-assets

Conversation

@thefron

@thefron thefron commented Nov 5, 2026

Copy link
Copy Markdown
Member

TL;DR

  • Synced HTML reports showed broken images whenever they used a relative src="shot.png". That covers every screenshot report under projects/larkin/. This PR fixes it.
  • There were two causes. The iframe loads /file?path=…, so shot.png resolved to /api/p/<id>/shot.png. And the sandboxed (opaque-origin) frame sends no session cookie for its subresources, so even a correct absolute URL returned 401. I confirmed the second one in Chromium: the request had no cookie, naturalWidth was 0, and an absolute file?path= also failed.
  • The frame now loads from /api/p/<id>/raw/<token>/<path>. The token is a short-lived HMAC credential scoped to the page's folder. Relative paths resolve by ordinary URL rules, and the token stands in for the cookie.
  • Known gap: the page can read its own URL, so authors of a page can exfiltrate a reader's token. That token grants read on that one folder, as that reader, for 4h. Refs that climb out of the page's folder (../x.png) still don't load.

How it works

  • GET /api/p/{project}/frame?path=<page> (PermRead, same lookup as /file) returns {url} for the caller.
  • GET /api/p/{project}/raw/{token}/{path...}: authGate resolves the token before the provider (frameUser, same shape as appUser). handleRaw checks that the project matches and that the path is under the token's folder, then serves through serveBlob. So the sandbox CSP, nosniff, folder rules and revocation all apply per request. A ?path= in the query can't redirect it. View flags (comment, edit, print) pass through.
  • Signing key: 32 random bytes at hub/frame-key in the storage root (outside every project prefix). It's created on first use with PutIf(VersionAbsent) where the backend supports it, then read back, so all hub processes share one key. Volume/desktop mode has no root and uses a per-process key.
  • Frontend: useFrameURL (React Query, 1h staleTime against a 4h TTL). HtmlView and VisualEdit use it. Pinned history versions keep their blob URL. If minting fails, the frame falls back to the old file?path= URL.
  • Desktop route table: both routes are routeLocal.

Tests

  • frame_test.go: relative siblings and subfolders load cookieless; ../ and %2E%2E escapes are refused; forged, tampered and expired tokens get 401; a folder rule or revocation applied after minting takes effect; a token for one project fails on another; minting needs read; the query can't override the path; a second Server on the same root verifies the token.
  • e2e browse.spec.ts: "an html page's relative images load" (sibling + subfolder PNG, naturalWidth === 1).
  • go test ./internal/webapp ./cmd/bdrive passes. The e2e run over browse/fullscreen/review-comments/inline-edit/print/sidebar/solo-handoff had 125 passing. browse.spec.ts:14 (tab title) also fails on origin/main. review-comments.spec.ts:171 flaked once and passed on rerun.

Architecture changes

  • webapp-server.md: adds frameToken (frame.go), Server -frameKey, and the edges Server ..> frameToken and frameToken ..> Backend.
  • webapp-frontend.md: adds useFrameURL to hooks.
flowchart LR
  Server -->|"✅ handleFrame, handleRaw, authGate"| frameToken["✅ frameToken"]
  frameToken -->|"✅ hub/frame-key"| Backend
  hooks["hooks: ✅ useFrameURL"] -.-> HtmlView
Loading

🤖 Generated with Claude Code

Synced HTML renders from /file?path=, so a relative src resolves against
/api/p/<id>/ rather than the page's folder, and the frame's opaque origin
means the browser sends no session cookie for the page's subresources.
Every relative image in a report 401'd.

The frame now loads from /api/p/<id>/raw/<token>/<path>, minted per reader
at /api/p/<id>/frame. The token is an HMAC (key shared through the storage
root so any hub process verifies it) over project, the page's folder,
reader and a 4h expiry; authGate resolves it before the provider and the
raw route serves through serveBlob, so folder rules and revocation apply
per request. HtmlView and the visual editor use it; pinned versions keep
their blob URL.

Co-Authored-By: Claude Opus 5.5 <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