fix: do not email sign-in links to addresses that cannot sign in
rastrillo/auth consults Config.Authorize at admission — the moment a verified address would receive a session — not when the link is sent. The security property holds either way: a stranger cannot get a session no matter how many links they redeem. What it leaves open is abuse rather than access. Anyone who can reach the instance could make it email a live sign-in link to any address they named, at whatever rate the IP limiter allows. The recipient gets unsolicited mail and our sending identity wears it. Found by POSTing a stranger's address at the deployed instance and getting ?sent=1 back. Every unit test passed, because they all tested admission and none tested delivery — the gap was between two things that were each individually correct. The refusal is deliberately invisible. Answering "that address cannot sign in here" would make this endpoint a membership oracle: anyone could enumerate the team by watching which addresses get a different answer. A non-admitted address now gets byte-identical output to an admitted one and no email. The cost is accepted and real — a member who mistypes their own address is told to check their email and nothing arrives. Four tests, with a recording mailer so "no email was sent" is observed rather than asserted: strangers get none, members still get one (without which, refusing everyone would pass), member and stranger are indistinguishable in status, Location and body, and a capitalised member address still receives one. Verified by removing the gate and watching the stranger test fail with the address it leaked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
9 files changed,
+191
−13
cmd/sheets/main.go+1 −1internal/sheets/app.go+9 −2internal/sheets/app_test.go+1 −1internal/sheets/auth.go+2 −2internal/sheets/auth_test.go+3 −3internal/sheets/begin.go+43 −0internal/sheets/begin_test.go+128 −0internal/sheets/page_test.go+1 −1internal/sheets/signin_test.go+3 −3
Intent
diff --git a/cmd/sheets/main.go b/cmd/sheets/main.go| index 61c598e..29ce1c9 100644 |
| --- a/cmd/sheets/main.go |
| +++ b/cmd/sheets/main.go |
| @@ -80,7 +80,7 @@ func main() { |
| os.Exit(1) |
| } |
| - app := sheets.New(d.G, signin) |
| + app := sheets.New(d.G, signin, sheets.Admits(authEnv.Owners)) |
| // rastrillo.Options.Mux wants a *http.ServeMux; the app's own |
| // router is a chi.Router (an http.Handler), so it mounts under one. |
diff --git a/internal/sheets/app.go b/internal/sheets/app.go| index 78597f7..3820d23 100644 |
| --- a/internal/sheets/app.go |
| +++ b/internal/sheets/app.go |
| @@ -14,6 +14,11 @@ import ( |
| type App struct { |
| db *gorm.DB |
| auth *auth.Auth |
| + |
| + // admit is the allowlist, consulted before a link is SENT. |
| + // auth.Config.Authorize covers the same list at redemption; this |
| + // one exists so we do not email strangers. See handleBegin. |
| + admit func(string) bool |
| } |
| // New builds an App. db may be nil in tests that do not touch storage. |
| @@ -23,7 +28,9 @@ type App struct { |
| // without a database or a mailer. Production always passes one — main |
| // refuses to boot without the configuration auth needs, so there is no |
| // path there that reaches this with nil. |
| -func New(db *gorm.DB, a *auth.Auth) *App { return &App{db: db, auth: a} } |
| +func New(db *gorm.DB, a *auth.Auth, admit func(string) bool) *App { |
| + return &App{db: db, auth: a, admit: admit} |
| +} |
| // Router returns the mounted handler. |
| func (a *App) Router() http.Handler { |
| @@ -48,7 +55,7 @@ func (a *App) Router() http.Handler { |
| // the paths its own docs use. Verify reads ?token=; the path is |
| // baked into every emailed link, so moving it later invalidates |
| // links already in people's inboxes. |
| - r.Post("/signin", a.auth.Begin) |
| + r.Post("/signin", a.handleBegin) |
| r.Get("/auth/callback", a.auth.Callback) |
| r.Get("/auth/verify", a.auth.Verify) |
| r.Post("/signout", a.auth.Signout) |
diff --git a/internal/sheets/app_test.go b/internal/sheets/app_test.go| index d6007c0..10baba0 100644 |
| --- a/internal/sheets/app_test.go |
| +++ b/internal/sheets/app_test.go |
| @@ -12,7 +12,7 @@ import ( |
| // ahead of this one and wins (see the comment on Router in app.go); this |
| // test only proves the app's router itself is wired and reachable. |
| func TestHealthz(t *testing.T) { |
| - app := New(nil, nil) |
| + app := New(nil, nil, nil) |
| req := httptest.NewRequest(http.MethodGet, "/healthz", nil) |
| rec := httptest.NewRecorder() |
| app.Router().ServeHTTP(rec, req) |
diff --git a/internal/sheets/auth.go b/internal/sheets/auth.go| index 011afc6..4861efc 100644 |
| --- a/internal/sheets/auth.go |
| +++ b/internal/sheets/auth.go |
| @@ -135,7 +135,7 @@ func NewAuth(db *sql.DB, env AuthEnv, logger *slog.Logger) (*auth.Auth, error) { |
| Origin: env.Origin, |
| InstanceKey: env.InstanceKey, |
| Mailer: env.Mailer, |
| - Authorize: admits(env.Owners), |
| + Authorize: Admits(env.Owners), |
| SigninPath: "/signin", |
| Subject: "Your Sheets sign-in link", |
| Logger: logger, |
| @@ -154,7 +154,7 @@ func NewAuth(db *sql.DB, env AuthEnv, logger *slog.Logger) (*auth.Auth, error) { |
| // normalisation — addresses are case-sensitive before the @ per RFC |
| // 5321, and no mainstream provider honours that — so this matches what |
| // operators expect when they type an allowlist. |
| -func admits(owners []string) func(string) bool { |
| +func Admits(owners []string) func(string) bool { |
| allowed := make(map[string]struct{}, len(owners)) |
| for _, o := range owners { |
| allowed[o] = struct{}{} |
diff --git a/internal/sheets/auth_test.go b/internal/sheets/auth_test.go| index 1680408..c3b31ba 100644 |
| --- a/internal/sheets/auth_test.go |
| +++ b/internal/sheets/auth_test.go |
| @@ -131,7 +131,7 @@ func TestLoopbackIsNotPrefixSpoofable(t *testing.T) { |
| } |
| func TestAdmitsIsFailClosed(t *testing.T) { |
| - allow := admits(splitOwners("Paul@Example.com, other@example.com,")) |
| + allow := Admits(splitOwners("Paul@Example.com, other@example.com,")) |
| for _, tc := range []struct { |
| address string |
| want bool |
| @@ -144,13 +144,13 @@ func TestAdmitsIsFailClosed(t *testing.T) { |
| {"", false}, |
| } { |
| if got := allow(tc.address); got != tc.want { |
| - t.Errorf("admits(%q) = %v, want %v", tc.address, got, tc.want) |
| + t.Errorf("Admits(%q) = %v, want %v", tc.address, got, tc.want) |
| } |
| } |
| } |
| // TestSplitOwnersDropsBlanks: a trailing comma or a stray space must not |
| -// put an empty string in the allowlist, which admits("") would then let |
| +// put an empty string in the allowlist, which Admits("") would then let |
| // through. |
| func TestSplitOwnersDropsBlanks(t *testing.T) { |
| for _, raw := range []string{"", " ", ",", " , , "} { |
diff --git a/internal/sheets/begin.go b/internal/sheets/begin.go| new file mode 100644 |
| index 0000000..50efd5d |
| --- /dev/null |
| +++ b/internal/sheets/begin.go |
| @@ -0,0 +1,43 @@ |
| +package sheets |
| + |
| +import "net/http" |
| + |
| +// handleBegin gates POST /signin on the allowlist BEFORE auth mints and |
| +// emails a link. |
| +// |
| +// rastrillo/auth checks Config.Authorize at admission — the moment a |
| +// verified address would receive a session — not when the link is sent. |
| +// That is the right place for the security decision, and the security |
| +// property holds without this: an address that is not admitted cannot |
| +// get a session no matter how many links it redeems. |
| +// |
| +// What it leaves open is abuse, not access. Without this gate, anyone |
| +// who can reach the instance can make it email a live sign-in link to |
| +// any address they name, at whatever rate the IP limiter allows. The |
| +// recipient gets unsolicited mail from us and our sending identity wears |
| +// it. Found by POSTing a stranger's address at the deployed instance and |
| +// getting ?sent=1 back — the unit tests all passed, because they tested |
| +// admission rather than delivery. |
| +// |
| +// The refusal is deliberately INVISIBLE. Answering "that address cannot |
| +// sign in" would turn this endpoint into a membership oracle: anyone |
| +// could enumerate who is on the team by watching which addresses get a |
| +// different answer. So a non-admitted address gets exactly the response |
| +// an admitted one gets, and no email. The cost is real and accepted: a |
| +// member who mistypes their own address is told to check their email and |
| +// nothing arrives. Every mainstream provider makes the same trade. |
| +func (a *App) handleBegin(w http.ResponseWriter, r *http.Request) { |
| + // Parse before reading the field: Begin would do this itself, and |
| + // this handler has to read the address before deciding to call it. |
| + if err := r.ParseForm(); err != nil { |
| + http.Error(w, "bad form", http.StatusBadRequest) |
| + return |
| + } |
| + if a.admit != nil && !a.admit(r.FormValue("address")) { |
| + // Identical to auth's own success redirect, so the two cases are |
| + // indistinguishable from outside. |
| + http.Redirect(w, r, "/signin?sent=1", http.StatusSeeOther) |
| + return |
| + } |
| + a.auth.Begin(w, r) |
| +} |
diff --git a/internal/sheets/begin_test.go b/internal/sheets/begin_test.go| new file mode 100644 |
| index 0000000..3229bf8 |
| --- /dev/null |
| +++ b/internal/sheets/begin_test.go |
| @@ -0,0 +1,128 @@ |
| +package sheets |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "testing" |
| + |
| + rdb "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/migrate" |
| +) |
| + |
| +// recorder is a mail.Sender that delivers nothing and remembers who it |
| +// was asked to write to. |
| +type recorder struct { |
| + mu sync.Mutex |
| + sent []string |
| +} |
| + |
| +func (m *recorder) Send(_ context.Context, to, _, _ string) error { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + m.sent = append(m.sent, to) |
| + return nil |
| +} |
| + |
| +func (m *recorder) addresses() []string { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + return append([]string(nil), m.sent...) |
| +} |
| + |
| +const testOrigin = "https://sheets.example.com" |
| + |
| +func appWithRecorder(t *testing.T) (*App, *recorder) { |
| + t.Helper() |
| + d, err := rdb.Open(filepath.Join(t.TempDir(), "test.db"), quietLogger()) |
| + if err != nil { |
| + t.Fatalf("open db: %v", err) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil { |
| + t.Fatalf("migrate: %v", err) |
| + } |
| + rec := &recorder{} |
| + owners := []string{"paul@example.com"} |
| + a, err := NewAuth(d.Writer(), AuthEnv{ |
| + Origin: testOrigin, |
| + InstanceKey: "test-instance-key", |
| + Owners: owners, |
| + Mailer: rec, |
| + }, quietLogger()) |
| + if err != nil { |
| + t.Fatalf("NewAuth: %v", err) |
| + } |
| + return New(d.G, a, Admits(owners)), rec |
| +} |
| + |
| +func postSignin(t *testing.T, app *App, address string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + req := httptest.NewRequest(http.MethodPost, "/signin", |
| + strings.NewReader("address="+address)) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Origin", testOrigin) |
| + w := httptest.NewRecorder() |
| + app.Router().ServeHTTP(w, req) |
| + return w |
| +} |
| + |
| +// TestBeginDoesNotEmailStrangers is the defect this handler exists for. |
| +// auth checks Authorize at redemption, so without the gate a stranger |
| +// still cannot get a session — but the instance cheerfully emails them a |
| +// live link, which is abuse of our sending identity, not access control. |
| +func TestBeginDoesNotEmailStrangers(t *testing.T) { |
| + app, mail := appWithRecorder(t) |
| + w := postSignin(t, app, "stranger@example.com") |
| + if got := mail.addresses(); len(got) != 0 { |
| + t.Errorf("emailed a stranger: %v", got) |
| + } |
| + if w.Code != http.StatusSeeOther { |
| + t.Errorf("status = %d, want 303", w.Code) |
| + } |
| +} |
| + |
| +// TestBeginStillEmailsMembers is the other half: the gate must not have |
| +// broken the thing it guards. Without this, refusing everyone would pass |
| +// the test above. |
| +func TestBeginStillEmailsMembers(t *testing.T) { |
| + app, mail := appWithRecorder(t) |
| + postSignin(t, app, "paul@example.com") |
| + got := mail.addresses() |
| + if len(got) != 1 || got[0] != "paul@example.com" { |
| + t.Fatalf("member mail = %v, want one to paul@example.com", got) |
| + } |
| +} |
| + |
| +// TestBeginIsNotAMembershipOracle: the refusal has to be invisible, or |
| +// the endpoint becomes a way to enumerate the team. Member and stranger |
| +// must be indistinguishable from outside — same status, same location. |
| +func TestBeginIsNotAMembershipOracle(t *testing.T) { |
| + app, _ := appWithRecorder(t) |
| + member := postSignin(t, app, "paul@example.com") |
| + stranger := postSignin(t, app, "stranger@example.com") |
| + |
| + if member.Code != stranger.Code { |
| + t.Errorf("status differs: member %d, stranger %d", member.Code, stranger.Code) |
| + } |
| + if m, s := member.Header().Get("Location"), stranger.Header().Get("Location"); m != s { |
| + t.Errorf("Location differs: member %q, stranger %q", m, s) |
| + } |
| + if member.Body.String() != stranger.Body.String() { |
| + t.Error("body differs between member and stranger") |
| + } |
| +} |
| + |
| +// TestBeginGateIsCaseInsensitive: the allowlist is compared lowercased, |
| +// so a member typing their address with different capitalisation must |
| +// still receive a link rather than being silently dropped. |
| +func TestBeginGateIsCaseInsensitive(t *testing.T) { |
| + app, mail := appWithRecorder(t) |
| + postSignin(t, app, "PAUL@example.com") |
| + if got := mail.addresses(); len(got) != 1 { |
| + t.Errorf("mail for capitalised member address = %v, want one", got) |
| + } |
| +} |
diff --git a/internal/sheets/page_test.go b/internal/sheets/page_test.go| index 5771117..beb8fd5 100644 |
| --- a/internal/sheets/page_test.go |
| +++ b/internal/sheets/page_test.go |
| @@ -10,7 +10,7 @@ import ( |
| func get(t *testing.T, path string) *httptest.ResponseRecorder { |
| t.Helper() |
| rec := httptest.NewRecorder() |
| - New(nil, nil).Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) |
| + New(nil, nil, nil).Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) |
| return rec |
| } |
diff --git a/internal/sheets/signin_test.go b/internal/sheets/signin_test.go| index 0a68db0..57bcead 100644 |
| --- a/internal/sheets/signin_test.go |
| +++ b/internal/sheets/signin_test.go |
| @@ -47,7 +47,7 @@ func TestSigninMessageSentAndEmpty(t *testing.T) { |
| func TestSigninPageDoesNotEchoQuery(t *testing.T) { |
| rec := httptest.NewRecorder() |
| req := httptest.NewRequest(http.MethodGet, "/signin?err=%3Cscript%3Ealert(1)%3C/script%3E", nil) |
| - New(nil, nil).Router().ServeHTTP(rec, req) |
| + New(nil, nil, nil).Router().ServeHTTP(rec, req) |
| if rec.Code != http.StatusOK { |
| t.Fatalf("GET /signin = %d", rec.Code) |
| } |
| @@ -80,7 +80,7 @@ func testAuth(t *testing.T) *App { |
| if err != nil { |
| t.Fatalf("NewAuth: %v", err) |
| } |
| - return New(d.G, a) |
| + return New(d.G, a, Admits([]string{"paul@example.com"})) |
| } |
| // TestAuthRoutesAreMounted proves the four handlers are reachable. It |
| @@ -107,7 +107,7 @@ func TestAuthRoutesAreMounted(t *testing.T) { |
| // pins that those codes really do mean "not mounted", so the assertion |
| // there is measuring what it claims to. |
| func TestAuthRoutesAbsentWithoutAuth(t *testing.T) { |
| - app := New(nil, nil) |
| + app := New(nil, nil, nil) |
| for _, tc := range []struct{ method, path string }{ |
| {http.MethodPost, "/signin"}, |
| {http.MethodGet, "/auth/verify"}, |