package webapp import ( "bufio" "fmt" "net" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" ) // Round 6 attacks the exported API surface directly rather than the routes: // a function reachable from two callers is only as safe as the weaker one. // Everything here calls the function the route calls, with the fixture the // route would have built, and asserts the SECURE outcome. // ---- helpers (secapi prefix) ---- // secapiAccountID resolves an account id by email, the way the admin routes // get the id they hand to Approve/Deny. func secapiAccountID(t *testing.T, a *BuiltinAuth, email string) string { t.Helper() a.mu.Lock() defer a.mu.Unlock() u := a.findByEmail(email) if u == nil { t.Fatalf("no account for %s", email) } return u.ID } // secapiFlakyAccounts is an AccountRepo whose writes can be made to fail, so a // store that refuses a record can be exercised the way a real one does when // the disk is full or Postgres is unreachable. type secapiFlakyAccounts struct { AccountRepo failAccount atomic.Bool failPolicy atomic.Bool failDelete atomic.Bool } func (r *secapiFlakyAccounts) PutAccount(u *authUser) error { if r.failAccount.Load() { return fmt.Errorf("account store unavailable") } return r.AccountRepo.PutAccount(u) } func (r *secapiFlakyAccounts) DeleteAccount(id string) error { if r.failDelete.Load() { return fmt.Errorf("account store unavailable") } return r.AccountRepo.DeleteAccount(id) } func (r *secapiFlakyAccounts) PutPolicy(p authPolicy) error { if r.failPolicy.Load() { return fmt.Errorf("account store unavailable") } return r.AccountRepo.PutPolicy(p) } // secapiAuth builds a BuiltinAuth over a flaky repo plus a mux carrying its // own /auth/* pages — the same wiring Server.Handler() does. func secapiAuth(t *testing.T, mail *Mailer) (*BuiltinAuth, *secapiFlakyAccounts, http.Handler) { t.Helper() repo := &secapiFlakyAccounts{AccountRepo: newFileAccountRepo(filepath.Join(t.TempDir(), "auth.json"))} a, err := NewBuiltinAuth(repo, true, mail) if err != nil { t.Fatal(err) } mux := http.NewServeMux() a.Register(mux) return a, repo, mux } // secapiForm posts an html form the way a browser does, with an explicit Host. func secapiForm(h http.Handler, host, path string, form url.Values) *httptest.ResponseRecorder { req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if host != "" { req.Host = host } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // ---- a minimal SMTP sink, so the mail the hub actually sends can be read ---- type secapiMailbox struct { addr string mu sync.Mutex msgs []string } // secapiSMTP starts a plaintext SMTP server that accepts everything and keeps // the message bodies. It advertises neither STARTTLS nor AUTH, which is what // net/smtp's SendMail needs to proceed without credentials. func secapiSMTP(t *testing.T) *secapiMailbox { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } t.Cleanup(func() { ln.Close() }) box := &secapiMailbox{addr: ln.Addr().String()} go func() { for { c, err := ln.Accept() if err != nil { return } go box.serve(c) } }() return box } func (b *secapiMailbox) serve(c net.Conn) { defer c.Close() br := bufio.NewReader(c) fmt.Fprint(c, "220 secapi ready\r\n") for { line, err := br.ReadString('\n') if err != nil { return } verb := strings.ToUpper(strings.TrimSpace(line)) if i := strings.IndexAny(verb, " :"); i >= 0 { verb = verb[:i] } switch verb { case "EHLO": fmt.Fprint(c, "250-secapi\r\n250 SIZE 10485760\r\n") case "HELO": fmt.Fprint(c, "250 secapi\r\n") case "DATA": fmt.Fprint(c, "354 go ahead\r\n") var msg strings.Builder for { l, err := br.ReadString('\n') if err != nil { return } if l == ".\r\n" || l == ".\n" { break } msg.WriteString(l) } b.mu.Lock() b.msgs = append(b.msgs, msg.String()) b.mu.Unlock() fmt.Fprint(c, "250 queued\r\n") case "QUIT": fmt.Fprint(c, "221 bye\r\n") return default: fmt.Fprint(c, "250 ok\r\n") } } } func (b *secapiMailbox) mailer() *Mailer { host, port, _ := net.SplitHostPort(b.addr) p := 0 fmt.Sscanf(port, "%d", &p) return &Mailer{Host: host, Port: p, From: "hub@example.test"} } // next waits for message number n (0-based) and returns it. func (b *secapiMailbox) next(t *testing.T, n int) string { t.Helper() deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { b.mu.Lock() if len(b.msgs) > n { m := b.msgs[n] b.mu.Unlock() return m } b.mu.Unlock() time.Sleep(5 * time.Millisecond) } t.Fatalf("no message %d arrived at the smtp sink", n) return "" } func (b *secapiMailbox) count() int { b.mu.Lock() defer b.mu.Unlock() return len(b.msgs) } // ---- Deny: what an account removal does NOT take with it ---- // Deny is the only account-removal path on the hub. Every authorization // decision downstream of it — org role, project grant, share-link liveness — // is resolved by EMAIL, and Deny touches none of them: it drops the account // row and (since round 5) its tokens. // // So an offboarding leaves a full set of live grants attached to an address // with no account, and the moment that address has an account again — a // re-signup on an open hub, an invite redeemed, an admin re-adding someone // who then leaves again — the new account inherits everything the removed one // held, including an explicit project admin grant, with no owner action. // Round 1 established that a grant must not outlive the org membership; this // is the same rule one level up: it must not outlive the ACCOUNT. func TestSec_Account_RemovedAccountsGrantsDoNotOutliveIt(t *testing.T) { h, srv, c, p := permHub(t) auth := srv.Auth.(*BuiltinAuth) // bob is a plain org member holding an explicit admin grant on the project. if err := srv.Projects.SetPerm(p.ID, "bob@x.io", PermAdmin); err != nil { t.Fatal(err) } // Control: he can read it today, so the fixture is wired. if rec := doAs(t, h, "GET", "/api/p/"+p.ID+"/tree", nil, c["bob"]); rec.Code != 200 { t.Fatalf("control: member read before removal: %d %s", rec.Code, rec.Body) } // The hub admin removes bob's account. if err := auth.Deny(secapiAccountID(t, auth, "bob@x.io")); err != nil { t.Fatalf("deny: %v", err) } if role := srv.Dir.Role(p.Org, "bob@x.io"); role != "" { t.Errorf("a removed account still holds org role %q", role) } if got, _ := srv.Projects.Get(p.ID); got.Perms["bob@x.io"] != "" { t.Errorf("a removed account still holds project grant %q", got.Perms["bob@x.io"]) } // Control: an untouched member is unaffected by the removal, so anything // asked for below is the server deciding about bob, not a broken fixture. if rec := doAs(t, h, "GET", "/api/p/"+p.ID+"/tree", nil, c["carol"]); rec.Code != 200 { t.Fatalf("control: untouched member read: %d %s", rec.Code, rec.Body) } // The consequence, end to end: a brand-new account on the same address // walks straight back into the project as its admin. fresh := signupAndSession(t, h, "bob@x.io", "Bob", "password1") rec := doAs(t, h, "GET", "/api/p/"+p.ID+"/tree", nil, fresh) if rec.Code != http.StatusForbidden { t.Errorf("a brand-new account on a removed member's address reached the project: %d %s; want 403", rec.Code, rec.Body) } perms := doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions/carol@x.io", map[string]string{"level": PermNone}, fresh) if perms.Code == 200 { t.Errorf("a brand-new account on a removed member's address inherited the project ADMIN grant "+ "and edited permissions: %d %s", perms.Code, perms.Body) } } // A share link is the strongest grant on the hub: the org's live content, to // anyone with the URL, forever. Round 2 made offboarding reach it and round 3 // made that resolution fail closed — but both resolve the creator's MEMBERSHIP, // and deleting the account leaves the membership standing. So the one action an // operator takes when someone must lose access immediately ("remove the // account") is the one that leaves their public links serving. func TestSec_Share_RemovedAccountsPublicLinkStopsServing(t *testing.T) { srv, p, _, _, h := shareHub(t) auth := srv.Auth.(*BuiltinAuth) orgs, err := OpenOrgDB(filepath.Join(t.TempDir(), "orgs.json")) if err != nil { t.Fatal(err) } org, err := orgs.Create("acme", "owner@x.io") if err != nil { t.Fatal(err) } if err := orgs.AddMember(org.ID, "s@x.io", RoleMember); err != nil { t.Fatal(err) } srv.Dir = LocalDirectory{OrgDB: orgs} if err := srv.Projects.SetOrg(p.ID, org.ID); err != nil { t.Fatal(err) } token, _ := authedShare(t, srv, h, p.ID, "wiki/notes.md") get := func() int { return doHTTP(h, httptest.NewRequest("GET", "/s/"+token, nil)).Code } if code := get(); code != 200 { t.Fatalf("control: a live link must serve before anything is removed: %d", code) } // The account that minted it is removed from the hub. if err := auth.Deny(secapiAccountID(t, auth, "s@x.io")); err != nil { t.Fatalf("deny: %v", err) } if code := get(); code == 200 { t.Errorf("a public share link minted by a REMOVED account still serves the file: GET /s/%s = 200", token) } // Control that the check exists and only this door misses it: dropping the // same address from the org does end the link. if err := orgs.RemoveMember(org.ID, "s@x.io"); err != nil { t.Fatal(err) } if code := get(); code == 200 { t.Fatalf("control: removing the creator from the org did not end the link either (%d) — "+ "the round 2/3 fix is gone, not just bypassed", code) } } // ---- writes the store refused, applied anyway ---- // Every service struct here keeps its state in memory and persists each change // as one record. Round 2 (invite revocation) and round 3 (project grants) // established the rule: a change the store REFUSED must not be in effect. // Three account-administration paths still break it, each in the direction // that widens access. func TestSec_Admin_AChangeTheStoreRefusedIsNotInEffect(t *testing.T) { t.Run("approve", func(t *testing.T) { a, repo, _ := secapiAuth(t, nil) a.RequireApproval = true u, err := a.signup("waiting@x.io", "Waiting", "password1") if err != nil { t.Fatal(err) } if u.Status != statusPending { t.Fatalf("fixture: new account status = %q, want pending", u.Status) } tok, err := a.issueToken(u.ID, "browser") if err != nil { t.Fatal(err) } if _, ok := a.userForToken(tok); ok { t.Fatal("control: a pending account must not authenticate") } // The admin approves; the store refuses the record. repo.failAccount.Store(true) if err := a.Approve(u.ID); err == nil { t.Fatal("control: Approve did not surface the store's refusal") } if _, ok := a.userForToken(tok); ok { t.Error("an approval the store REFUSED activated the account anyway: " + "the admin was told it failed and the account can authenticate") } }) t.Run("policy", func(t *testing.T) { a, repo, _ := secapiAuth(t, nil) if err := a.SetPolicy(false, true); err != nil { t.Fatal(err) } if u, err := a.signup("first@x.io", "First", "password1"); err != nil || u.Status != statusPending { t.Fatalf("control: approval gate not in force (%v, %+v)", err, u) } // The admin turns the approval gate OFF; the store refuses. repo.failPolicy.Store(true) if err := a.SetPolicy(false, false); err == nil { t.Fatal("control: SetPolicy did not surface the store's refusal") } u, err := a.signup("second@x.io", "Second", "password1") if err != nil { t.Fatal(err) } if u.Status != statusPending { t.Errorf("a gating change the store REFUSED took effect anyway: a new signup is %q, "+ "want pending — the hub is now un-gated across a restart it does not agree with", u.Status) } }) t.Run("deny", func(t *testing.T) { a, repo, _ := secapiAuth(t, nil) u, err := a.signup("leaving@x.io", "Leaving", "password1") if err != nil { t.Fatal(err) } repo.failDelete.Store(true) if err := a.Deny(u.ID); err == nil { t.Fatal("control: Deny did not surface the store's refusal") } repo.failDelete.Store(false) // The hub restarts. A removal the store refused must not have looked // done: either it never happened (and the admin retries) or it is // durable. What must never happen is "gone until the next restart". reloaded, err := NewBuiltinAuth(repo, true, nil) if err != nil { t.Fatal(err) } // verifyPassword returns *authUser, not error: non-nil means the // account is still on disk and would sign in again after the restart. // (The original spelling compared it to nil as if it were an error, so // BOTH outcomes failed — the assertion below is the one the comment // above describes, and it still fails on the code that shipped.) onDisk := reloaded.verifyPassword("leaving@x.io", "password1") != nil a.mu.Lock() _, inMemory := a.users[u.ID] a.mu.Unlock() if onDisk && !inMemory { t.Error("a removal the store REFUSED emptied the in-memory registry anyway: " + "the account is gone until the next restart, then signs in again with its old password") } }) } // A password reset is the documented recovery for a stolen account. Round 5 // made the token revocation half durable — a logout that reported success had // been leaving the credential alive on disk. The password half is still the // old shape: pageResetConfirm discards PutAccount's error, tells the human // "Your password is updated", and the hub comes back at the next restart with // the password the thief chose still live. func TestSec_Password_ResetThatWasNotPersistedIsNotReportedAsDone(t *testing.T) { a, repo, h := secapiAuth(t, nil) reset := func(a *BuiltinAuth, userID, pass string) *httptest.ResponseRecorder { t.Helper() tok := a.newGrant("reset", userID, time.Hour) return secapiForm(h, "hub.example", "/auth/reset/confirm", url.Values{"token": {tok}, "password": {pass}}) } // Control: a reset the store accepts really does survive a restart. ok, err := a.signup("ok@x.io", "Ok", "password1") if err != nil { t.Fatal(err) } if rec := reset(a, ok.ID, "password2"); !strings.Contains(rec.Body.String(), "Password updated") { t.Fatalf("control: an ordinary reset did not report success: %d %s", rec.Code, rec.Body) } if control, err := NewBuiltinAuth(repo, true, nil); err != nil { t.Fatal(err) } else if control.verifyPassword("ok@x.io", "password2") == nil { t.Fatal("control: a persisted reset did not survive a reload — the fixture cannot see the disk") } // Attack: the same flow while the store refuses the write. victim, err := a.signup("victim@x.io", "Victim", "oldpassword") if err != nil { t.Fatal(err) } repo.failAccount.Store(true) rec := reset(a, victim.ID, "thiefchosen1") claimed := strings.Contains(rec.Body.String(), "Password updated") repo.failAccount.Store(false) reloaded, err := NewBuiltinAuth(repo, true, nil) if err != nil { t.Fatal(err) } if claimed && reloaded.verifyPassword("victim@x.io", "thiefchosen1") == nil { t.Errorf("the reset page reported \"Password updated\" for a write the store refused: "+ "after a restart %q is not the password", "thiefchosen1") } if claimed && reloaded.verifyPassword("victim@x.io", "oldpassword") != nil { t.Errorf("the reset page reported \"Password updated\" but the OLD password is live again " + "after a restart — the documented recovery from a stolen account does not recover it") } } // secapiFlakyShares is a ShareRepo whose delete can be made to fail. type secapiFlakyShares struct { ShareRepo failDelete atomic.Bool } func (r *secapiFlakyShares) Delete(token string) error { if r.failDelete.Load() { return fmt.Errorf("share store unavailable") } return r.ShareRepo.Delete(token) } // Round 5 found that revoking a device token dropped the row from memory and // DISCARDED the store's error, so a logout reported success while the // credential survived on disk. ShareDB.Revoke is the same three lines on the // hub's most public grant — a /s/ URL serving an org's live content to anyone // — and it is the emergency stop for a leaked link. OrgDB.RevokeInvite, the // sibling emergency stop, already puts the row back and reports the failure; // this one returns true and moves on. func TestSec_Share_RevocationMustNotSurviveOnlyInMemory(t *testing.T) { path := filepath.Join(t.TempDir(), "shares.json") repo := &secapiFlakyShares{ShareRepo: newFileShareRepo(path)} db, err := NewShareDB(repo) if err != nil { t.Fatal(err) } // Control: an ordinary revocation is durable. ok, err := db.Create("p1", "wiki/ok.md", "alice@x.io", 0) if err != nil { t.Fatal(err) } if !db.Revoke(ok.Token) { t.Fatal("control: Revoke reported failure on a live share") } if reloaded, err := NewShareDB(repo); err != nil { t.Fatal(err) } else if _, live := reloaded.lookup(ok.Token); live { t.Fatal("control: an ordinary revocation did not survive a reload") } // Attack: the store refuses the delete. leaked, err := db.Create("p1", "secret/salaries.md", "alice@x.io", 0) if err != nil { t.Fatal(err) } repo.failDelete.Store(true) reported := db.Revoke(leaked.Token) repo.failDelete.Store(false) reloaded, err := NewShareDB(repo) if err != nil { t.Fatal(err) } if _, live := reloaded.lookup(leaked.Token); live && reported { t.Errorf("Revoke reported the link dead, but the row survived the store's refusal: "+ "/s/%s serves %s again after a restart", leaked.Token, leaked.Path) } } // ---- the AuthProvider seam a managed deployment swaps ---- type secapiStubAuth struct{ user User } func (a secapiStubAuth) CLILoginPath() string { return "/auth/login" } func (a secapiStubAuth) Authenticate(*http.Request) (User, bool) { return a.user, a.user.ID != "" } func (a secapiStubAuth) Register(*http.ServeMux) {} func (a secapiStubAuth) Accounts() []User { return nil } // This test is about the identity a provider hands back, not about devices — // but a provider that drops the binder is the whole subject of // sec_provider_test.go, so the no-op is deliberate and belongs to this fixture // only. func (a secapiStubAuth) UseDeviceBinder(DeviceBinder) {} // Every authorization decision on the hub is keyed on the email an // AuthProvider hands back. BuiltinAuth happens to guarantee a non-empty, // lowercased, unique address; the interface promises none of that, and a // managed deployment swaps the implementation. The properties the hub relies // on have to hold for an identity it did not mint. func TestSec_Auth_AProviderIdentityTheHubCannotResolveReachesNothing(t *testing.T) { h, srv, c, p := permHub(t) real := srv.Auth // Control: with the real provider, bob is in. if rec := doAs(t, h, "GET", "/api/p/"+p.ID+"/tree", nil, c["bob"]); rec.Code != 200 { t.Fatalf("control: member read: %d %s", rec.Code, rec.Body) } t.Cleanup(func() { srv.Auth = real }) for _, email := range []string{ "", // provider returned no address at all " ", // whitespace only "bob@x.io.attacker.test", // a superset of a member's address "bob", // the local part alone "@x.io", // the domain alone } { srv.Auth = secapiStubAuth{user: User{ID: "u-stub", Email: email, Name: "Stub"}} for _, route := range []string{"/api/p/" + p.ID + "/tree", "/api/p/" + p.ID + "/history", "/api/p/" + p.ID + "/store/list"} { rec := doAs(t, h, "GET", route, nil, nil) if rec.Code != http.StatusForbidden { t.Errorf("a provider identity with email %q reached %s: %d %s; want 403", email, route, rec.Code, rec.Body) } } } // Case must not mint a second principal: a provider that upper-cases the // same person is the same person, not a stranger with a fresh grant map. srv.Auth = secapiStubAuth{user: User{ID: "u-stub", Email: "BOB@X.IO", Name: "Bob"}} if rec := doAs(t, h, "GET", "/api/p/"+p.ID+"/tree", nil, nil); rec.Code != 200 { t.Errorf("the same address in upper case resolved to a different principal: %d %s", rec.Code, rec.Body) } } // ---- mail: where the links in it point ---- // requestBaseURL builds every link the hub mails from r.Host (and an // unconditionally trusted X-Forwarded-Proto). For the three URLs handed back // to the caller that is self-inflicted; for the two that are MAILED it is // classic reset poisoning: an unauthenticated stranger posts /auth/reset for // a victim's address with a Host of their choosing, and the hub sends the // victim a genuine reset mail whose link hands the single-use grant to the // attacker's server. func TestSec_Mail_ResetLinkCannotBeAimedAtAnAttackerChosenHost(t *testing.T) { box := secapiSMTP(t) a, _, h := secapiAuth(t, box.mailer()) // The hub's public origin, which is what a mailed link is built from (and // which a hub with smtp configured must now set: ValidateSignupPolicy). a.BaseURL = "http://hub.example" if _, err := a.signup("victim@x.io", "Victim", "password1"); err != nil { t.Fatal(err) } // Control: an ordinary request mails a link on the hub's own host, so the // sink and the flow both work. secapiForm(h, "hub.example", "/auth/reset", url.Values{"email": {"victim@x.io"}}) if got := box.next(t, 0); !strings.Contains(got, "hub.example/auth/reset/confirm?token=") { t.Fatalf("control: no reset link for the hub's own host in the mail:\n%s", got) } // Attack: the same anonymous request, a Host the attacker chose. secapiForm(h, "evil.example", "/auth/reset", url.Values{"email": {"victim@x.io"}}) got := box.next(t, 1) if strings.Contains(got, "evil.example") { t.Errorf("the reset mail delivered to the victim carries a link on an attacker-chosen host:\n%s", got) } } // Same seam, the other mailed link: a verification mail is sent on every login // attempt against an unverified account, so anyone who knows an address on an // approval/verification-gated hub can aim its activation link anywhere. func TestSec_Mail_VerificationLinkCannotBeAimedAtAnAttackerChosenHost(t *testing.T) { box := secapiSMTP(t) a, _, h := secapiAuth(t, box.mailer()) a.BaseURL = "http://hub.example" a.RequireVerification = true if _, err := a.signup("newbie@x.io", "Newbie", "password1"); err != nil { t.Fatal(err) } // Control: signing in re-sends the verification link, on the hub's host. secapiForm(h, "hub.example", "/auth/login", url.Values{"email": {"newbie@x.io"}, "password": {"password1"}}) if got := box.next(t, 0); !strings.Contains(got, "hub.example/auth/verify?token=") { t.Fatalf("control: no verification link for the hub's own host in the mail:\n%s", got) } secapiForm(h, "evil.example", "/auth/login", url.Values{"email": {"newbie@x.io"}, "password": {"password1"}}) got := box.next(t, 1) if strings.Contains(got, "evil.example") { t.Errorf("the verification mail carries a link on an attacker-chosen host:\n%s", got) } } // Mailer.Send joins its arguments into headers with CRLF and validates // nothing, and createAccount accepts an email address carrying a bare CRLF. // Assert the property that matters: a recipient with an embedded newline never // produces a delivered message (net/smtp refuses the envelope today, which is // the only thing standing between that address and an injected Bcc). func TestSec_Mail_RecipientCRLFNeverBecomesAHeader(t *testing.T) { box := secapiSMTP(t) m := box.mailer() // Control: an ordinary address is delivered, so the sink counts. if err := m.Send("ordinary@x.io", "Subject", "body"); err != nil { t.Fatalf("control: an ordinary send failed: %v", err) } box.next(t, 0) if err := m.Send("victim@x.io\r\nBcc: boss@corp.example", "Subject", "body"); err == nil { t.Error("Send accepted a recipient carrying CRLF") } if n := box.count(); n != 1 { t.Errorf("a message went out for a CRLF recipient (%d delivered, want 1)", n) } for _, msg := range func() []string { box.mu.Lock(); defer box.mu.Unlock(); return append([]string(nil), box.msgs...) }() { if strings.Contains(msg, "Bcc:") { t.Errorf("an injected header reached the wire:\n%s", msg) } } }