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(), "