mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Fable 5
parent
01902d435f
commit
cb621c153e
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
+1
-1
File diff suppressed because one or more lines are too long
@@ -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'>🐻</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">
|
||||
|
||||
Reference in New Issue
Block a user