Files
beardrive/internal/webapp/dir_test.go
T
Snow LeeandClaude Fable 5 cb621c153e 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
2026-07-16 11:01:38 -07:00

143 lines
4.8 KiB
Go

package webapp
import (
"encoding/json"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
)
func dirServer(t *testing.T, files map[string]string) http.Handler {
t.Helper()
root := t.TempDir()
for rel, content := range files {
abs := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
s := &Server{Source: &DirSource{Root: root}, Volume: "local", Refresh: 0}
return s.Handler()
}
func TestDirSourceServesFolder(t *testing.T) {
h := dirServer(t, map[string]string{
"README.md": "# Local",
"notes/plan.md": "content",
".bdrive": `{"volume":"x"}`, // settings file must be hidden
".git/config": "noise", // .git must be skipped
})
var root Node
if err := json.Unmarshal(get(t, h, "/api/tree").Body.Bytes(), &root); err != nil {
t.Fatal(err)
}
names := []string{}
for _, n := range root.Children {
names = append(names, n.Name)
}
if len(root.Children) != 2 || root.Children[0].Name != "notes" || root.Children[1].Name != "README.md" {
t.Fatalf("tree children = %v, want [notes README.md]", names)
}
rec := get(t, h, "/api/file?path=notes/plan.md")
if rec.Code != 200 || rec.Body.String() != "content" {
t.Fatalf("file: %d %q", rec.Code, rec.Body)
}
if rec.Header().Get("ETag") == "" {
t.Fatal("dir source should still produce ETags")
}
rec = get(t, h, "/api/render?path=README.md")
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Local") {
t.Fatalf("render: %d %s", rec.Code, rec.Body)
}
if rec := get(t, h, "/api/file?path=.git/config"); rec.Code != 404 {
t.Fatalf(".git content must be hidden, got %d", rec.Code)
}
if rec := get(t, h, "/api/file?path=../escape"); rec.Code != 404 {
t.Fatalf("path traversal must 404, got %d", rec.Code)
}
}
// 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.
func TestFrontendSPAFallback(t *testing.T) {
h := dirServer(t, map[string]string{"notes/plan.md": "content"})
shell := func(url string) {
t.Helper()
rec := get(t, h, url)
if rec.Code != 200 || !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
t.Fatalf("%s: want 200 html, got %d %s", url, rec.Code, rec.Header().Get("Content-Type"))
}
if !strings.Contains(rec.Body.String(), `id="root"`) {
t.Fatalf("%s: expected the app shell, got %.60q", url, rec.Body.String())
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-cache" {
t.Fatalf("%s: the shell must revalidate, got Cache-Control %q", url, cc)
}
}
// Client routes all resolve to the shell, not a 404 or file content.
shell("/")
shell("/notes/plan.md") // a deep file route (not the raw file)
shell("/p-deadbeef/notes/plan.md") // hub-style route
shell("/join/abc123") // invite route
// Real assets are served as themselves, cacheable forever: Vite emits
// content-hashed filenames, so find one instead of hardcoding a hash.
assets, err := fs.Glob(staticFiles, "static/assets/*.js")
if err != nil || len(assets) == 0 {
t.Fatalf("no built js asset embedded (run npm run build in frontend/): %v", err)
}
rec := get(t, h, strings.TrimPrefix(assets[0], "static"))
if rec.Code != 200 || !strings.Contains(rec.Header().Get("Content-Type"), "javascript") {
t.Fatalf("%s: %d %s", assets[0], rec.Code, rec.Header().Get("Content-Type"))
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
t.Fatalf("hashed assets must be immutable, got Cache-Control %q", cc)
}
// A mistyped API path is a genuine 404, not the shell.
if rec := get(t, h, "/api/bogus"); rec.Code != 404 {
t.Fatalf("/api/bogus: want 404, got %d", rec.Code)
}
}