From cb621c153ecbbe65bc2d92e98f699d84d69dd3e5 Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Thu, 16 Jul 2026 11:01:38 -0700 Subject: [PATCH] feat(web): friendly 404 for missing paths; HTML files render as sandboxed pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two viewer features plus the security fix the second one surfaced: - Missing file/folder paths now get a not-found view: the path, and the hint that a just-created file may still be uploading or syncing from a teammate's device — the tree polls every few seconds so it appears on its own, plus a Check again button that refetches immediately. The topbar's share/download actions no longer show for nonexistent files. - Opening an .html file renders it as a page (sandboxed iframe, allow-scripts only) instead of showing source text. - SECURITY: /api/file was already serving synced HTML inline as text/html on the hub origin with session cookies — a stored-XSS surface reachable by direct navigation, previously masked only by the viewer showing HTML as text. Inline HTML and SVG responses now carry 'Content-Security-Policy: sandbox allow-scripts' (the same wall as /s/* share pages); downloads are exempt (attachments never execute in the hub origin). Tests: Go CSP-header matrix (html/svg sandboxed, md clean, download exempt); e2e: sandboxed-iframe rendering incl. in-frame content + CSP assertion, and the not-found → late-upload → Check again flow. 44 specs green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt --- internal/webapp/dir_test.go | 29 +++++++++++++++ internal/webapp/frontend/e2e/browse.spec.ts | 36 +++++++++++++++++++ internal/webapp/frontend/src/apps/Browser.tsx | 28 ++++++++++++++- .../frontend/src/components/FileView.tsx | 16 ++++++++- internal/webapp/frontend/src/style.css | 9 +++++ internal/webapp/frontend/src/util.ts | 3 +- internal/webapp/server.go | 10 +++++- .../webapp/static/assets/index-DKQXtbc9.js | 11 ------ .../webapp/static/assets/index-Dz7xLxXQ.js | 11 ++++++ ...{index-Cq1fMR2i.css => index-h2c92P2n.css} | 2 +- internal/webapp/static/index.html | 4 +-- 11 files changed, 141 insertions(+), 18 deletions(-) delete mode 100644 internal/webapp/static/assets/index-DKQXtbc9.js create mode 100644 internal/webapp/static/assets/index-Dz7xLxXQ.js rename internal/webapp/static/assets/{index-Cq1fMR2i.css => index-h2c92P2n.css} (96%) diff --git a/internal/webapp/dir_test.go b/internal/webapp/dir_test.go index 9fcb7e2..8a390f0 100644 --- a/internal/webapp/dir_test.go +++ b/internal/webapp/dir_test.go @@ -67,6 +67,35 @@ func TestDirSourceServesFolder(t *testing.T) { } } +// Synced HTML served inline must never run with the hub origin's session: +// the file endpoint sandboxes it (same posture as /s/* shares). Downloads +// are exempt — an attachment never executes in the hub's origin. +func TestInlineHTMLIsSandboxed(t *testing.T) { + h := dirServer(t, map[string]string{ + "page.html": "

hi

", + "pic.svg": "", + "plan.md": "# md", + }) + for path, wantCSP := range map[string]bool{ + "/api/file?path=page.html": true, + "/api/file?path=pic.svg": true, + "/api/file?path=plan.md": false, + "/api/download?path=page.html": false, // attachment, not rendered + } { + rec := get(t, h, path) + if rec.Code != 200 { + t.Fatalf("%s: %d", path, rec.Code) + } + csp := rec.Header().Get("Content-Security-Policy") + if wantCSP && csp != "sandbox allow-scripts" { + t.Errorf("%s: CSP = %q, want sandbox", path, csp) + } + if !wantCSP && csp != "" { + t.Errorf("%s: unexpected CSP %q", path, csp) + } + } +} + // The frontend serves real assets directly but returns the app shell for any // client-side route (a deep file path, /join/), so a deep link or // refresh doesn't 404. Reserved API/auth/share prefixes stay real 404s. diff --git a/internal/webapp/frontend/e2e/browse.spec.ts b/internal/webapp/frontend/e2e/browse.spec.ts index bb07420..3fe7fd7 100644 --- a/internal/webapp/frontend/e2e/browse.spec.ts +++ b/internal/webapp/frontend/e2e/browse.spec.ts @@ -127,3 +127,39 @@ test("upload into the selected folder, then the file opens", async ({ page }) => await expect(page.locator("#content h1")).toHaveText("Dropped"); await expect(page.locator('#tree .row[data-path="notes/dropped.md"]')).toBeVisible(); }); + +test("html file renders as a page in a sandboxed iframe", async ({ page }) => { + await login(page); + const pid = await wikiId(page); + const html = "

Hello from HTML

- + +