Files
beardrive/internal/webapp/sec_api_test.go
T
94091a8795 fix(hub): device binding is a provider contract, not a BuiltinAuth field (#147)
ownJournal refuses a journal write unless the device id is bound to the
caller's account, for EVERY provider — it asks only whether s.Devices is
nil. The only thing that creates that binding is DeviceRegistry.Bind, whose
only caller is BuiltinAuth.finishLogin, and that hook was wired behind
`if a, ok := s.Auth.(*BuiltinAuth); ok`.

So a hub running a managed AuthProvider — the deployment the seam exists
for — bound nothing, ever, and refused every journal push from every device
forever. Everything around it read healthy: /api/auth/me answered, project
permissions said write, and blobs (content-addressed, so ownerless) uploaded
fine. Only the journal PUT died. Signing in again could not help, because
signing in was the step that was supposed to bind.

UseDeviceBinder moves the hook onto the AuthProvider interface — a breaking
change for an out-of-tree provider, deliberately, so one that ignores a
precondition of a gate the hub enforces for it does not compile. The hub
cannot bind on the provider's behalf: a bind must be reachable only from a
completed authentication, and Authenticate reports who a request is, never
which credential class it presented. A device token still cannot reach a
bind; no new door was added, and every /store/* door still creates nothing.

Bind also reported success for a row the store had refused — observeLocked
logged the write failure and swallowed it — so a login could hand back a
token whose every push was then denied, with nothing in the hub explaining
why. It now propagates and the login fails honestly.

And a hub that refuses a journal write while holding no binding at all logs
that its provider is not calling the binder — the sentence that would have
ended this investigation on day one instead of day two.

Tested by driving the real binary against a hub with a managed provider
(cli_provider_e2e_test.go), which is the configuration no test in this repo
covered and the reason this shipped: every existing test used BuiltinAuth,
where the wiring happened to work. Both directions are pinned — a provider
that binds pushes, one that ignores the binder reproduces the reported
symptom exactly.


Claude-Session: https://claude.ai/code/session_01GSHsQU4pBCzKkPyPeXSwTm

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:23:34 -07:00

671 lines
24 KiB
Go

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)
}
}
}