Files
eef37fa89b 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>
2026-08-19 12:21:09 -07:00

744 lines
27 KiB
Go

package webapp
// Round 3: the structural blind spot of rounds 1 and 2 — every hub they
// attacked was built by a test fixture, so no hub with a real DSN, a real
// SMTP password, TrustProxy, allowed_domains or an admin list had ever been
// probed. This file builds a hub the way production builds one (the real
// `bdrive serve -c config.json` path through cmd/bdrive/web.go) and attacks
// it, plus the three surfaces the CISO listed as never exercised: GET /
// (Server.frontend), the login rate limiter's effectiveness, and timing-based
// user enumeration.
//
// Helper prefix: seccfg.
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"testing"
"time"
)
// ---------------------------------------------------------------------------
// Part 1 — a hub built through the real configuration path.
// ---------------------------------------------------------------------------
// Sentinels planted in the config file. Each stands for a credential that only
// exists on a real hub: the object-store URL, the metadata DSN, the SMTP
// password. Any of them appearing in a response (or in the server's own
// output) is a leak.
const (
seccfgStorageSecret = "STORAGECRED7QX"
seccfgDBSecret = "DBSECRET7QX"
seccfgSMTPSecret = "SMTPPASS7QX"
seccfgAdminEmail = "admin@example.com"
seccfgAdminPass = "password1"
)
var (
seccfgBinOnce sync.Once
seccfgBinPath string
seccfgBinErr error
)
// seccfgBinary builds cmd/bdrive once for the whole package run.
func seccfgBinary(t *testing.T) string {
t.Helper()
seccfgBinOnce.Do(func() {
dir, err := os.MkdirTemp("", "seccfg-bin")
if err != nil {
seccfgBinErr = err
return
}
bin := filepath.Join(dir, "bdrive")
out, err := exec.Command("go", "build", "-o", bin, "github.com/runbear-io/beardrive/cmd/bdrive").CombinedOutput()
if err != nil {
seccfgBinErr = fmt.Errorf("go build: %v\n%s", err, out)
return
}
seccfgBinPath = bin
})
if seccfgBinErr != nil {
t.Fatal(seccfgBinErr)
}
return seccfgBinPath
}
// seccfgFreePort grabs a port the hub can bind.
func seccfgFreePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
// seccfgRealHub starts `bdrive serve -c config.json` — the only code path on
// which a DSN, an SMTP password and a storage credential exist at all — and
// returns its base URL plus a reader for everything the process has printed.
func seccfgRealHub(t *testing.T) (base string, serverOutput func() string) {
t.Helper()
if testing.Short() {
t.Skip("builds and execs the bdrive binary; skipped with -short")
}
bin := seccfgBinary(t)
state := t.TempDir()
home := filepath.Join(state, "home")
if err := os.MkdirAll(home, 0o755); err != nil {
t.Fatal(err)
}
// The object-store root, the metadata DSN and the SMTP password each
// carry a distinct sentinel.
storage := filepath.Join(state, "storage-"+seccfgStorageSecret)
if err := os.MkdirAll(storage, 0o755); err != nil {
t.Fatal(err)
}
cfg := map[string]any{
"remote": "file://" + storage,
"upload": true,
"trust_proxy": false,
"database": map[string]any{
"driver": "sqlite",
"dsn": filepath.Join(state, "hub-"+seccfgDBSecret+".db"),
},
"auth": map[string]any{
"allow_signup": false,
"allowed_domains": []string{"example.com"},
"admins": []string{seccfgAdminEmail},
"brand": "Sec Round 3",
// A hub with smtp must name its public origin: a mailed link may
// not be built from a requester's Host header.
"base_url": "https://hub.example",
"smtp": map[string]any{
// Port 1 refuses instantly, so nothing here ever blocks.
"host": "127.0.0.1", "port": 1,
"user": "mailer@example.com", "pass": seccfgSMTPSecret,
"from": "hub@example.com",
},
},
}
cfgPath := filepath.Join(state, "config.json")
data, _ := json.MarshalIndent(cfg, "", " ")
if err := os.WriteFile(cfgPath, data, 0o600); err != nil {
t.Fatal(err)
}
port := seccfgFreePort(t)
cmd := exec.Command(bin, "serve", "-c", cfgPath, "--addr", fmt.Sprintf("127.0.0.1:%d", port))
cmd.Env = append(envWithout("HOME", "BDRIVE_HOME"),
"HOME="+home, "BDRIVE_HOME="+filepath.Join(home, ".bdrive"))
var out seccfgBuf
cmd.Stdout, cmd.Stderr = &out, &out
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cmd.Process.Kill()
cmd.Wait()
})
base = fmt.Sprintf("http://127.0.0.1:%d", port)
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
resp, err := http.Get(base + "/api/config")
if err == nil {
resp.Body.Close()
if resp.StatusCode == 200 {
return base, out.String
}
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("hub never came up on %s:\n%s", base, out.String())
return "", nil
}
// seccfgBuf is a concurrency-safe sink for the child process's output.
type seccfgBuf struct {
mu sync.Mutex
b bytes.Buffer
}
func (s *seccfgBuf) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.Write(p)
}
func (s *seccfgBuf) String() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.String()
}
// seccfgAdminClient bootstraps the config's admin account (a fresh
// invite-only hub lets exactly the configured admins create the first
// account) and returns a client holding its session.
func seccfgAdminClient(t *testing.T, base string) *http.Client {
t.Helper()
jar, _ := cookiejar.New(nil)
c := &http.Client{Jar: jar, Timeout: 20 * time.Second}
resp, err := c.PostForm(base+"/auth/signup", url.Values{
"email": {seccfgAdminEmail}, "name": {"Admin"}, "password": {seccfgAdminPass},
})
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
u, _ := url.Parse(base)
if len(jar.Cookies(u)) == 0 {
t.Fatalf("admin bootstrap signup left no session cookie: %d\n%s", resp.StatusCode, body)
}
return c
}
func seccfgGet(t *testing.T, c *http.Client, target string) (int, string) {
t.Helper()
resp, err := c.Get(target)
if err != nil {
t.Fatalf("GET %s: %v", target, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(body)
}
// TestSec_Leak_RealConfigPathKeepsSecretsOffTheWire probes row 12 on the only
// kind of hub that has secrets to leak: one configured by cmd/bdrive/web.go
// with a metadata DSN, an SMTP password and a storage URL.
func TestSec_Leak_RealConfigPathKeepsSecretsOffTheWire(t *testing.T) {
base, serverOut := seccfgRealHub(t)
admin := seccfgAdminClient(t, base)
// A project, so the per-project routes (and their error bodies) are live.
body, _ := json.Marshal(map[string]string{"name": "wiki"})
resp, err := admin.Post(base+"/api/projects", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
var created struct {
Project Project `json:"project"`
}
json.NewDecoder(resp.Body).Decode(&created)
resp.Body.Close()
if created.Project.ID == "" {
t.Fatal("could not create a project on the real-config hub")
}
id := created.Project.ID
anon := &http.Client{Timeout: 20 * time.Second}
type probe struct {
what string
client *http.Client
target string
}
probes := []probe{
{"anonymous /api/config", anon, base + "/api/config"},
{"admin /api/config", admin, base + "/api/config"},
{"admin /api/projects", admin, base + "/api/projects"},
{"admin /api/orgs", admin, base + "/api/orgs"},
{"admin /api/admin/policy", admin, base + "/api/admin/policy"},
{"admin /api/admin/pending", admin, base + "/api/admin/pending"},
{"admin project get", admin, base + "/api/projects/" + id},
{"admin tree", admin, base + "/api/p/" + id + "/tree"},
{"missing file error", admin, base + "/api/p/" + id + "/file?path=nope.md"},
{"missing blob error", admin, base + "/api/p/" + id + "/blob?sha=" + strings.Repeat("a", 64)},
{"missing store object error", admin, base + "/api/p/" + id + "/store/object?key=blobs/" + strings.Repeat("b", 64)},
{"unknown project error", admin, base + "/api/p/00000000-0000-0000-0000-000000000000/tree"},
{"anonymous SPA shell", anon, base + "/"},
{"sign-in page", anon, base + "/auth/login"},
}
secrets := map[string]string{
"storage root": seccfgStorageSecret,
"metadata DSN": seccfgDBSecret,
"SMTP password": seccfgSMTPSecret,
"admin password": seccfgAdminPass,
}
for _, p := range probes {
code, out := seccfgGet(t, p.client, p.target)
for name, s := range secrets {
if strings.Contains(out, s) {
t.Errorf("%s (%d) leaks the %s: %q appears in the response\n%s",
p.what, code, name, s, out)
}
}
}
// The hub's own output is the other surface an operator (or a log
// shipper, or a support bundle) sees. Neither true secret belongs there.
logs := serverOut()
for _, s := range []string{seccfgDBSecret, seccfgSMTPSecret, seccfgAdminPass} {
if strings.Contains(logs, s) {
t.Errorf("the hub printed %q to its own output:\n%s", s, logs)
}
}
}
// TestSec_Admin_PolicyCannotWidenServerOwnedAccess checks the claim
// CLAUDE.md makes about the real config path: allow_signup, allowed_domains
// and admins are server-config-owned, so a browser session — even a hub
// admin's — must not be able to widen them.
func TestSec_Admin_PolicyCannotWidenServerOwnedAccess(t *testing.T) {
base, _ := seccfgRealHub(t)
admin := seccfgAdminClient(t, base)
code, before := seccfgGet(t, admin, base+"/api/admin/policy")
if code != 200 {
t.Fatalf("admin cannot read the policy: %d %s", code, before)
}
var pol SignupPolicy
if err := json.Unmarshal([]byte(before), &pol); err != nil {
t.Fatalf("policy is not a SignupPolicy: %v\n%s", err, before)
}
if pol.AllowSignup {
t.Fatalf("fixture wrong: config said allow_signup false, hub reports %v", before)
}
// Everything a session might try to widen, in one body.
widen, _ := json.Marshal(map[string]any{
"require_verification": false,
"require_approval": false,
"allow_signup": true,
"allowed_domains": []string{"example.com", "evil.test"},
"admins": []string{seccfgAdminEmail, "attacker@evil.test"},
"mailer": true,
})
resp, err := admin.Post(base+"/api/admin/policy", "application/json", bytes.NewReader(widen))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
_, after := seccfgGet(t, admin, base+"/api/admin/policy")
var got SignupPolicy
if err := json.Unmarshal([]byte(after), &got); err != nil {
t.Fatal(err)
}
if got.AllowSignup {
t.Errorf("a browser session turned self-signup ON through /api/admin/policy: %s", after)
}
sort.Strings(got.AllowedDomains)
if strings.Join(got.AllowedDomains, ",") != "example.com" {
t.Errorf("a browser session rewrote allowed_domains: %v", got.AllowedDomains)
}
sort.Strings(got.Admins)
if strings.Join(got.Admins, ",") != seccfgAdminEmail {
t.Errorf("a browser session rewrote the hub admin list: %v", got.Admins)
}
// And the functional check the booleans stand for: signup is still shut.
anon := &http.Client{Timeout: 20 * time.Second}
_, page := seccfgGet(t, anon, base+"/auth/signup")
if !strings.Contains(page, "invite-only") {
t.Errorf("self-signup opened after the policy POST:\n%s", page)
}
}
// ---------------------------------------------------------------------------
// Part 2 — GET / (Server.frontend): the only route with no TestSec coverage.
// ---------------------------------------------------------------------------
// seccfgRaw sends a request with a literal (uncleaned) request target, so a
// traversal attempt reaches the handler exactly as an attacker typed it.
func seccfgRaw(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("GET", "http://hub.test/", nil)
u, err := url.Parse(target)
if err != nil {
t.Fatalf("bad target %q: %v", target, err)
}
req.URL = u
req.RequestURI = target
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// TestSec_Frontend_FallbackServesOnlyEmbeddedAssets attacks the SPA fallback
// with every shape of escape: a hub host's files must never come back, and a
// reserved prefix must never be masked by the app shell.
func TestSec_Frontend_FallbackServesOnlyEmbeddedAssets(t *testing.T) {
h, _, _, _ := permHub(t)
// Something recognisable on the host, one and two levels above cwd, so a
// successful escape has a proof string.
host := filepath.Join(t.TempDir(), "hostfile.txt")
if err := os.WriteFile(host, []byte("SECCFG-HOST-FILE"), 0o644); err != nil {
t.Fatal(err)
}
escapes := []string{
"/../../../../etc/passwd",
"/..%2f..%2f..%2fetc%2fpasswd",
"/%2e%2e/%2e%2e/etc/passwd",
"/assets/../../../../etc/passwd",
"/assets/..%2f..%2f..%2f..%2fgo.mod",
"/./../go.mod",
"/go.mod",
"/../server.go",
"/static/../../go.mod",
"//etc/passwd",
"/....//....//etc/passwd",
"/" + strings.TrimPrefix(host, "/"),
"/..\\..\\go.mod",
}
for _, target := range escapes {
rec := seccfgRaw(t, h, target)
body := rec.Body.String()
for _, proof := range []string{"root:x:", "SECCFG-HOST-FILE", "module github.com/runbear-io/beardrive", "package webapp"} {
if strings.Contains(body, proof) {
t.Errorf("GET %s escaped the embedded FS (%d): body contains %q", target, rec.Code, proof)
}
}
// Anything that resolves outside the app is either a 404 or the app
// shell — never a 200 with a non-HTML content type.
if rec.Code == 200 {
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("GET %s returned 200 %s — the fallback served a non-shell body:\n%s",
target, ct, body[:min(len(body), 300)])
}
}
}
// Reserved prefixes must 404 rather than be masked by the shell, so a
// mistyped API URL can't be mistaken for a working page.
for _, target := range []string{"/api/nope", "/auth/nope", "/s/nope-token-that-does-not-exist"} {
rec := seccfgRaw(t, h, target)
if rec.Code == 200 && strings.Contains(rec.Body.String(), "<div id=\"root\">") {
t.Errorf("GET %s was masked by the SPA shell", target)
}
}
}
// TestSec_Frontend_ShellCarriesFramingAndSniffingDefenses: the hub UI holds a
// session cookie and renders user-supplied markdown, so the document it ships
// must not be frameable by another origin and must not be MIME-sniffed.
func TestSec_Frontend_ShellCarriesFramingAndSniffingDefenses(t *testing.T) {
h, _, _, _ := permHub(t)
for _, target := range []string{"/", "/index.html", "/some-project-id/dashboard"} {
rec := seccfgRaw(t, h, target)
if rec.Code != 200 {
t.Fatalf("GET %s: %d (expected the app shell)", target, rec.Code)
}
hdr := rec.Header()
if got := hdr.Get("X-Content-Type-Options"); !strings.EqualFold(got, "nosniff") {
t.Errorf("GET %s: X-Content-Type-Options = %q, want nosniff — the shell is MIME-sniffable", target, got)
}
csp := hdr.Get("Content-Security-Policy")
xfo := hdr.Get("X-Frame-Options")
framed := strings.Contains(strings.ToLower(csp), "frame-ancestors") ||
strings.EqualFold(xfo, "DENY") || strings.EqualFold(xfo, "SAMEORIGIN")
if !framed {
t.Errorf("GET %s: no frame-ancestors CSP and no X-Frame-Options (CSP=%q XFO=%q) — "+
"any origin can frame the signed-in hub UI and clickjack its controls", target, csp, xfo)
}
}
}
// TestSec_Frontend_ImmutableCacheOnlyOnRealAssets: the "assets/* is immutable"
// rule is keyed on the URL prefix, not on whether an asset actually exists, so
// a miss under assets/ is answered with the app shell marked cacheable for a
// year. A shared cache then pins that body at that URL past every upgrade.
func TestSec_Frontend_ImmutableCacheOnlyOnRealAssets(t *testing.T) {
h, _, _, _ := permHub(t)
rec := seccfgRaw(t, h, "/assets/there-is-no-such-asset.js")
cc := rec.Header().Get("Cache-Control")
ct := rec.Header().Get("Content-Type")
if strings.Contains(cc, "immutable") && strings.HasPrefix(ct, "text/html") {
t.Errorf("GET /assets/<missing> returned the app shell (%s) with %q — "+
"a body that is not the asset it claims to be, cached for a year", ct, cc)
}
// A directory under assets/ is the same shape.
rec = seccfgRaw(t, h, "/assets/")
cc = rec.Header().Get("Cache-Control")
ct = rec.Header().Get("Content-Type")
if strings.Contains(cc, "immutable") && strings.HasPrefix(ct, "text/html") {
t.Errorf("GET /assets/ returned the app shell (%s) with %q", ct, cc)
}
}
// ---------------------------------------------------------------------------
// Part 3 — the login rate limiter, both TrustProxy postures.
// ---------------------------------------------------------------------------
// seccfgLogin posts a (wrong) credential from a chosen connection address and
// optional X-Forwarded-For, and reports the status.
func seccfgLogin(t *testing.T, h http.Handler, remoteAddr, xff string) int {
t.Helper()
form := url.Values{"email": {"nobody@x.io"}, "password": {"wrong"}}
req := httptest.NewRequest("POST", "http://hub.test/auth/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = remoteAddr
if xff != "" {
req.Header.Set("X-Forwarded-For", xff)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code
}
// TestSec_RateLimit_LoginLimiterIgnoresSpoofedForwardedFor: with trust_proxy
// off (the default, and the only safe setting on a directly-reachable hub) a
// client-supplied X-Forwarded-For must not mint a fresh bucket per request.
func TestSec_RateLimit_LoginLimiterIgnoresSpoofedForwardedFor(t *testing.T) {
h, srv, _, _ := permHub(t)
if srv.TrustProxy {
t.Fatal("fixture wrong: TrustProxy should default to off")
}
// The limiter is 10/min with a burst of 10, so the 11th attempt from one
// client must be refused however the client decorates the request.
blocked := false
for i := 0; i < 40; i++ {
code := seccfgLogin(t, h, "203.0.113.9:5555", fmt.Sprintf("10.0.0.%d", i%250))
if code == http.StatusTooManyRequests {
blocked = true
break
}
}
if !blocked {
t.Error("40 failed logins from one connection all went through while rotating X-Forwarded-For: " +
"the brute-force limiter is bypassable by a header the client controls")
}
}
// TestSec_RateLimit_TrustedProxyUsesTheHopItAdded: with trust_proxy ON the
// hub must key on the hop its own proxy appended — the LAST value — not the
// first one, which the client wrote itself. X-Forwarded-For grows
// left-to-right, so a client that sends "X-Forwarded-For: <anything>" has its
// value preserved at the head of the list by every downstream proxy.
func TestSec_RateLimit_TrustedProxyUsesTheHopItAdded(t *testing.T) {
h, srv, _, _ := permHub(t)
srv.TrustProxy = true
h = srv.Handler() // rebuild so the flag is in force for this handler
// One real client behind the operator's proxy at 10.1.1.1. The proxy
// appends the address it saw; the client prepends a new lie each time.
const proxy = "10.1.1.1"
blocked := false
for i := 0; i < 40; i++ {
xff := fmt.Sprintf("198.51.100.%d, %s", i%250, proxy)
if seccfgLogin(t, h, proxy+":9999", xff) == http.StatusTooManyRequests {
blocked = true
break
}
}
if !blocked {
t.Error("with trust_proxy on, 40 failed logins arriving through the same proxy hop all went through: " +
"the hub keys the limiter on the FIRST X-Forwarded-For entry, which the client prepends, " +
"so turning trust_proxy on disables the brute-force limiter instead of fixing it")
}
}
// ---------------------------------------------------------------------------
// Part 4 — timing-based user enumeration.
// ---------------------------------------------------------------------------
// seccfgSlowSMTP is a listener that accepts and then stalls, standing in for
// any real mail server: dialing it costs measurable, observable time.
func seccfgSlowSMTP(t *testing.T, stall time.Duration) (host string, port int) {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
go func() {
for {
c, err := l.Accept()
if err != nil {
return
}
go func() {
time.Sleep(stall)
c.Close()
}()
}
}()
a := l.Addr().(*net.TCPAddr)
return "127.0.0.1", a.Port
}
// seccfgMailHub is permHub's shape with SMTP configured — the posture a real
// hub runs in, and the one no fixture ever built.
func seccfgMailHub(t *testing.T, mail *Mailer) (http.Handler, *BuiltinAuth) {
t.Helper()
srv, _, _ := newHub(t, true, nil)
auth, err := OpenBuiltinAuth(filepath.Join(t.TempDir(), "auth.json"), true, mail)
if err != nil {
t.Fatal(err)
}
srv.Auth = auth
orgs, err := OpenOrgDB(filepath.Join(t.TempDir(), "orgs.json"))
if err != nil {
t.Fatal(err)
}
srv.Dir = LocalDirectory{OrgDB: orgs}
return srv.Handler(), auth
}
func seccfgPostForm(t *testing.T, h http.Handler, target, remoteAddr string, form url.Values) (int, time.Duration) {
t.Helper()
req := httptest.NewRequest("POST", "http://hub.test"+target, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = remoteAddr
rec := httptest.NewRecorder()
start := time.Now()
h.ServeHTTP(rec, req)
return rec.Code, time.Since(start)
}
// TestSec_Leak_ResetTimingDoesNotEnumerateAccounts is the enumeration oracle
// that only exists on a hub with SMTP configured: pageReset sends mail when
// the address is known and does nothing at all when it isn't, so the response
// time — bodies are byte-identical, round 1 checked that — says which. Nothing
// throttles /auth/reset either (rateLimitAuth covers only login and signup),
// so the oracle is queryable at full speed.
func TestSec_Leak_ResetTimingDoesNotEnumerateAccounts(t *testing.T) {
const stall = 700 * time.Millisecond
host, port := seccfgSlowSMTP(t, stall)
h, _ := seccfgMailHub(t, &Mailer{Host: host, Port: port, User: "u", Pass: "p", From: "hub@x.io"})
known := signupAndSession(t, h, "victim@x.io", "Victim", "password1")
if known == nil {
t.Fatal("fixture wrong: no session for the known account")
}
codeKnown, dKnown := seccfgPostForm(t, h, "/auth/reset", "203.0.113.1:1", url.Values{"email": {"victim@x.io"}})
codeUnknown, dUnknown := seccfgPostForm(t, h, "/auth/reset", "203.0.113.2:1", url.Values{"email": {"ghost@x.io"}})
if codeKnown != codeUnknown {
t.Fatalf("reset status differs: known %d vs unknown %d", codeKnown, codeUnknown)
}
gap := dKnown - dUnknown
if gap < 0 {
gap = -gap
}
// The mail dial is worth `stall`; anything close to it is a clean signal
// an attacker can read off a single request.
if gap > stall/3 {
t.Errorf("POST /auth/reset takes %v for a known address and %v for an unknown one (gap %v): "+
"the response time enumerates accounts on any hub with SMTP configured, "+
"and /auth/reset is not rate limited", dKnown, dUnknown, gap)
}
}
// TestSec_Password_LoginTimingDoesNotEnumerateAccounts verifies the bcrypt
// time-burn in verifyPassword actually equalizes: a wrong password for a real
// account and a wrong password for an address that does not exist must cost
// the same. Samples are interleaved so machine drift hits both arms, compared
// on the median, and each request uses a fresh connection address so the
// 10/min login limiter never truncates the run.
func TestSec_Password_LoginTimingDoesNotEnumerateAccounts(t *testing.T) {
if testing.Short() {
t.Skip("runs ~30 bcrypt comparisons; skipped with -short")
}
h, _ := seccfgMailHub(t, nil)
if c := signupAndSession(t, h, "real@x.io", "Real", "password1"); c == nil {
t.Fatal("fixture wrong: no session for the known account")
}
const samples = 15
knownD := make([]time.Duration, 0, samples)
ghostD := make([]time.Duration, 0, samples)
n := 0
for i := 0; i < samples; i++ {
for _, arm := range []struct {
email string
into *[]time.Duration
}{{"real@x.io", &knownD}, {"ghost@x.io", &ghostD}} {
n++
code, d := seccfgPostForm(t, h, "/auth/login",
fmt.Sprintf("198.51.100.%d:1", n), url.Values{
"email": {arm.email}, "password": {"definitely-wrong"},
})
if code == http.StatusTooManyRequests {
t.Fatalf("login limiter truncated the timing run at sample %d", i)
}
*arm.into = append(*arm.into, d)
}
}
mk, mg := seccfgMedian(knownD), seccfgMedian(ghostD)
lo, hi := min(mk, mg), max(mk, mg)
// Both arms run exactly one bcrypt cost-10 comparison, so they should be
// within noise. A 3x bound only fires if one arm skips the work entirely.
if hi > 3*lo {
t.Errorf("login median for a known address is %v and for an unknown one %v: "+
"the bcrypt burn in verifyPassword does not equalize, so response time enumerates accounts", mk, mg)
}
}
func seccfgMedian(d []time.Duration) time.Duration {
c := append([]time.Duration(nil), d...)
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)
}
}