feat(web): friendly 404 for missing paths; HTML files render as sandboxed pages

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
This commit is contained in:
Snow Lee
2026-07-16 11:01:38 -07:00
co-authored by Claude Fable 5
parent 01902d435f
commit cb621c153e
11 changed files with 141 additions and 18 deletions
+29
View File
@@ -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": "<h1>hi</h1><script>1</script>",
"pic.svg": "<svg xmlns='http://www.w3.org/2000/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/<token>), so a deep link or
// refresh doesn't 404. Reserved API/auth/share prefixes stay real 404s.
@@ -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 = "<h1 id='t'>Hello from HTML</h1><script>document.title='js-ran'</scr" + "ipt>";
await page.request.put(
`/api/p/${pid}/upload/content?path=${encodeURIComponent("pages/hello.html")}`,
{ data: html },
);
await page.goto(`/${pid}/pages/hello.html`);
const frame = page.locator("#content iframe.htmlview");
await expect(frame).toBeVisible();
await expect(frame).toHaveAttribute("sandbox", "allow-scripts");
await expect(page.frameLocator("#content iframe.htmlview").locator("#t")).toHaveText(
"Hello from HTML",
);
// Server-side wall: inline HTML carries the sandbox CSP (same as /s/*).
const res = await page.request.get(`/api/p/${pid}/file?path=${encodeURIComponent("pages/hello.html")}`);
expect(res.headers()["content-security-policy"]).toBe("sandbox allow-scripts");
});
test("missing path gets the not-found view; Check again finds a late upload", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/later.md`);
await expect(page.locator(".notfound h1")).toHaveText("Couldn't find that");
await expect(page.locator(".notfound code")).toHaveText("later.md");
await expect(page.locator(".notfound")).toContainText("still be uploading");
// The file arrives (a teammate/agent finished syncing it)…
await page.request.put(
`/api/p/${pid}/upload/content?path=${encodeURIComponent("later.md")}`,
{ data: "# Finally here\n" },
);
await page.click(".notfound .pbtn"); // Check again
await expect(page.locator("#content h1")).toHaveText("Finally here");
});
+27 -1
View File
@@ -59,7 +59,10 @@ export default function Browser(props: {
const path = route.path;
const isDir = !!path && dirIndex.has(path);
const isFile = !!path && loaded && !dirIndex.has(path);
// A file only counts as one when the tree actually contains it — a
// missing path gets the not-found view, not a broken file view.
const isFile = !!path && loaded && !isDir && flatFiles.some((f) => f.path === path);
const isMissing = !!path && loaded && !isDir && !isFile;
const listingShowing = isDir && !route.view;
/* ---- tree expansion ---- */
@@ -280,6 +283,29 @@ export default function Browser(props: {
} else if (path) {
if (!loaded) {
view = <div className="empty">Loading</div>;
} else if (isMissing) {
// The tree polls every few seconds, so a file that's mid-upload (or
// mid-sync from a teammate's device) appears here on its own.
contentClass = "view";
view = (
<div className="notfound">
<h1>Couldn't find that</h1>
<p>
<code>{path}</code> isn't in this project right now.
</p>
<p className="nf-sub">
If it was just created, it may still be uploading or syncing
from a teammate's device this page checks again automatically
every few seconds, so refresh or come back in a moment.
</p>
<button
className="pbtn"
onClick={() => qc.invalidateQueries({ queryKey: ["tree", apiBase] })}
>
Check again
</button>
</div>
);
} else if (isDir) {
contentClass = "view";
view = (
@@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { HeatMap, Node, RenderDoc } from "../api/types";
import { heatTotal, heatText } from "../hooks/useBrowse";
import { IMG_EXT, MD_EXT, TEXT_EXT, joinPath } from "../util";
import { HTML_EXT, IMG_EXT, MD_EXT, TEXT_EXT, joinPath } from "../util";
export function FileView(props: {
apiBase: string;
@@ -20,6 +20,20 @@ export function FileView(props: {
useEffect(() => () => onMeta(""), [path, onMeta]); // leaving a file clears its meta line
if (MD_EXT.test(path)) return <MarkdownView {...props} />;
if (HTML_EXT.test(path)) {
// Rendered, not shown as source — inside a sandboxed iframe so synced
// HTML never runs with the hub origin's session (the server also
// stamps the response with a sandbox CSP; this is belt and braces).
return (
<iframe
className="htmlview"
sandbox="allow-scripts"
src={fileURL}
title={path}
onLoad={props.onRendered}
/>
);
}
if (IMG_EXT.test(path)) {
return <ImgView src={fileURL} alt={path} onRendered={props.onRendered} />;
}
+9
View File
@@ -517,6 +517,15 @@ button, input, a.btn { font-family: inherit; }
.markdown table.frontmatter code { white-space: pre-wrap; font-size: 11px; }
.markdown input[type="checkbox"] { accent-color: var(--accent); }
/* rendered HTML files: a sandboxed page in a framed viewport */
.htmlview { display: block; width: 100%; height: calc(100vh - 150px); border: 1px solid var(--border); border-radius: var(--r-card); background: #fff; }
/* missing path: friendly not-found with the uploading hint */
.notfound { margin-top: 12vh; text-align: center; color: var(--text-dim); }
.notfound h1 { color: var(--text); font-size: 1.4em; margin-bottom: .5em; }
.notfound code { background: var(--hover); border: 1px solid var(--border); padding: .15em .5em; border-radius: 6px; }
.notfound .nf-sub { max-width: 440px; margin: 12px auto 20px; font-size: 13px; color: var(--text-faint); line-height: 1.6; }
/* plain file / binary views */
pre.plain { background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--r-card); padding: 14px 16px; overflow-x: auto; font: 12.5px/1.6 var(--mono); color: #c6cbd3; white-space: pre-wrap; overflow-wrap: anywhere; max-width: 900px; }
#content.markdown { min-width: 0; } /* let long unbreakable lines wrap, not blow out the column */
+2 -1
View File
@@ -1,7 +1,8 @@
export const MD_EXT = /\.(md|markdown)$/i;
export const IMG_EXT = /\.(png|jpe?g|gif|svg|webp|ico|bmp|avif)$/i;
export const HTML_EXT = /\.html?$/i;
export const TEXT_EXT =
/\.(txt|log|json|ya?ml|toml|csv|go|py|js|ts|jsx|tsx|sh|bash|zsh|rb|rs|c|h|cpp|java|kt|swift|sql|html|css|xml|ini|conf|env|mod|sum|jsonl)$/i;
/\.(txt|log|json|ya?ml|toml|csv|go|py|js|ts|jsx|tsx|sh|bash|zsh|rb|rs|c|h|cpp|java|kt|swift|sql|css|xml|ini|conf|env|mod|sum|jsonl)$/i;
export function humanSize(n: number): string {
if (n < 1024) return n + " B";
+9 -1
View File
@@ -645,10 +645,18 @@ func (s *Server) serveBlob(v *volume, w http.ResponseWriter, r *http.Request, at
}
defer rc.Close()
w.Header().Set("ETag", etag)
w.Header().Set("Content-Type", contentType(p))
ct := contentType(p)
w.Header().Set("Content-Type", ct)
w.Header().Set("Content-Length", fmt.Sprint(fi.Size))
if attach {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", path.Base(p)))
} else if strings.HasPrefix(ct, "text/html") || strings.HasPrefix(ct, "image/svg") {
// Synced HTML (and scriptable SVG) served inline must never run
// with the hub origin's session — same posture as /s/* share
// pages: an opaque sandboxed origin that can't touch the API or
// cookies. The viewer renders these in a sandboxed iframe; direct
// navigation gets the same wall.
w.Header().Set("Content-Security-Policy", "sandbox allow-scripts")
}
io.Copy(w, rc)
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#128059;</text></svg>">
<script type="module" crossorigin src="/assets/index-DKQXtbc9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cq1fMR2i.css">
<script type="module" crossorigin src="/assets/index-Dz7xLxXQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-h2c92P2n.css">
</head>
<body>
<svg width="0" height="0" class="sprite" aria-hidden="true" focusable="false">