fix(webapp): the hub 404s a root file it does not have (BEA-148) (#184)

GET /llms.txt answered 200 with the sign-in page. So did /robots.txt,
/sitemap.xml and every mistyped root file: the SPA fallback treated only
api/, auth/ and s/ as genuine 404s, so a crawler probing any conventional
path got a success status and a chunk of login HTML.

In hub mode a first path segment is a project id (UUID or p-xxxxxxxx) or a
reserved word, none of which contain a dot, so a single dotted segment with
no embedded asset can only be a file that is not there. The check runs after
the asset lookup, so a real root asset (share-mermaid.js today, favicon.ico
whenever the build emits one) is unaffected with no allowlist to maintain.

Gated on hub mode: the plain-folder viewer shares this handler and there
/README.md IS the route for a file, so ungating this would make every
top-level file in every `bdrive serve <dir>` unreachable. There is a test
that fails if the gate is removed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-19 12:21:09 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 20f59352ca
commit eef37fa89b
2 changed files with 70 additions and 2 deletions
+52
View File
@@ -689,3 +689,55 @@ func seccfgMedian(d []time.Duration) time.Duration {
sort.Slice(c, func(i, j int) bool { return c[i] < c[j] })
return c[len(c)/2]
}
// TestFrontendRootDottedPathsAre404: a root-level path shaped like a file
// (/llms.txt, /robots.txt) that matches no embedded asset must 404 rather than
// answer the app shell — a soft 200 of login HTML told every crawler probing a
// conventional path that the file exists. Deeper dots are real client routes.
func TestFrontendRootDottedPathsAre404(t *testing.T) {
h, _, _, p := permHub(t)
for _, target := range []string{"/llms.txt", "/robots.txt", "/sitemap.xml", "/nope.json"} {
rec := seccfgRaw(t, h, target)
if rec.Code != 404 {
t.Errorf("GET %s: %d, want 404 (got %s) — the SPA fallback still masks a missing root file",
target, rec.Code, rec.Header().Get("Content-Type"))
}
}
// A root-level dotted path that IS an embedded asset keeps being served:
// the rule is "no embedded asset", not "has a dot", because the check runs
// after the asset lookup. share-mermaid.js is the live case (the /s/ share
// pages import it); a favicon.ico would be the next one, with no allowlist
// to remember to update.
rec := seccfgRaw(t, h, "/share-mermaid.js")
if rec.Code != 200 {
t.Errorf("GET /share-mermaid.js: %d, want 200 — the dotted-path 404 is deciding on the URL "+
"instead of on whether the asset exists, and share pages just lost their mermaid renderer", rec.Code)
}
// Everything that is a genuine client route still resolves to the shell.
// /index.html is here because the asset block skips it deliberately and
// falls through: without the explicit exclusion the rule above would 404 it.
for _, target := range []string{"/", "/index.html", "/" + p.ID, "/" + p.ID + "/dashboard", "/" + p.ID + "/notes/index.md"} {
rec := seccfgRaw(t, h, target)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "<div id=\"root\">") {
t.Errorf("GET %s: %d — a client route stopped resolving to the app shell", target, rec.Code)
}
}
}
// TestFrontendVolumeModeServesRootFiles: the dotted-root-path 404 above is
// hub-only reasoning. The plain-folder viewer shares this handler, and there
// /README.md IS the route for a file (router.ts parsePath, non-hub branch), so
// the rule is gated on hub mode. THIS TEST FAILS IF THAT GATE IS REMOVED —
// ungated, the fix 404s every top-level file in every `bdrive serve <dir>`.
func TestFrontendVolumeModeServesRootFiles(t *testing.T) {
h := dirServer(t, map[string]string{"README.md": "# Local"})
rec := seccfgRaw(t, h, "/README.md")
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "<div id=\"root\">") {
t.Errorf("GET /README.md in volume mode: %d — the hub-mode gate on the dotted-path 404 is gone; "+
"every root-level file in a plain-folder viewer is now unreachable", rec.Code)
}
}
+18 -2
View File
@@ -906,8 +906,9 @@ func (s *Server) Handler() http.Handler {
// frontend serves the embedded single-page app. Real asset files (app.js,
// style.css) are served directly; every other GET that isn't an API, auth,
// or share route returns index.html, so client-side routes like
// /<project-id>/<path> and /join/<token> survive a deep link or refresh.
// or share route — or, on a hub, a root-level path shaped like a file —
// returns index.html, so client-side routes like /<project-id>/<path> and
// /join/<token> survive a deep link or refresh.
func (s *Server) frontend(static fs.FS) http.HandlerFunc {
files := http.FileServerFS(static)
index, _ := fs.ReadFile(static, "index.html")
@@ -970,6 +971,21 @@ func (s *Server) frontend(static fs.FS) http.HandlerFunc {
}
}
}
// No embedded asset matched, so a root-level dotted path is a request
// for a file that does not exist, not a client route: in hub mode the
// first segment is a project id (projectIDRe: UUID or p-xxxxxxxx) or a
// reserved word (orgs/, billing/, join/), none of which contain a dot.
// Answering the shell made /llms.txt, /robots.txt and every mistyped
// root file look like they exist — a soft 200 of login HTML to any
// crawler probing a conventional path. Deeper dots (/<id>/notes/a.md)
// are real client routes and untouched. index.html is excluded because
// the asset block above skips it deliberately and it must keep
// answering the shell.
if s.Root != nil && upath != "index.html" &&
!strings.Contains(upath, "/") && strings.Contains(upath, ".") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(index)
}