fix: final review of slice 1
sameAnswer narrowed to spec 3.2 (address and rate outcomes pass through); force=1 documented; per-link budget consulted before the shared pool; budget keys evicted; go mod tidy; latestPending tiebreak; title() logs an open failure; the two waived 13 gates written: member-address exhaustion of the shared limiter, and an expired pending row admitting nobody.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7 files changed,
+96
−13
go.mod+1 −1internal/meet/app.go+1 −1internal/meet/auth.go+23 −6internal/meet/budget.go+7 −2internal/meet/store.go+5 −2internal/meet/store_test.go+1 −1internal/meettest/join_test.go+58 −0
CI passed — run details
diff --git a/go.mod b/go.mod| index 5fda04c..a20abe5 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -6,6 +6,7 @@ require ( |
| amadan.net/rastrillo/rastrillo v0.26.0 |
| github.com/go-chi/chi/v5 v5.3.2 |
| golang.org/x/crypto v0.56.0 |
| + gorm.io/gorm v1.31.2 |
| ) |
| require ( |
| @@ -20,7 +21,6 @@ require ( |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| golang.org/x/sys v0.47.0 // indirect |
| golang.org/x/text v0.41.0 // indirect |
| - gorm.io/gorm v1.31.2 // indirect |
| gorm.io/plugin/dbresolver v1.6.2 // indirect |
| modernc.org/libc v1.74.1 // indirect |
| modernc.org/mathutil v1.7.1 // indirect |
diff --git a/internal/meet/app.go b/internal/meet/app.go| index a08d8a2..d32cddb 100644 |
| --- a/internal/meet/app.go |
| +++ b/internal/meet/app.go |
| @@ -51,7 +51,7 @@ func build(d *db.DB, cfg Config) (*http.ServeMux, *app, error) { |
| rc.Logger.Info("migrate", "applied", migrated.Applied, |
| "skipped", migrated.Skipped, "adopted", migrated.Adopted) |
| - st := &store{g: d.G, sealer: sealer, now: time.Now} |
| + st := &store{g: d.G, sealer: sealer, now: time.Now, logger: rc.Logger} |
| a := &app{ |
| db: d, cfg: rc, sealer: sealer, store: st, logger: rc.Logger, |
| linkBudget: newBudget(5, 15*time.Minute), |
diff --git a/internal/meet/auth.go b/internal/meet/auth.go| index 7635394..d412ef2 100644 |
| --- a/internal/meet/auth.go |
| +++ b/internal/meet/auth.go |
| @@ -44,10 +44,12 @@ func (a *app) authorize(address string) bool { |
| return live |
| } |
| -// gateBegin sits in front of rastrillo's Begin. The guarantee: every |
| -// answer is byte-identical, 303 Location: /signin?sent=1, whatever the |
| -// address's standing, so the endpoint is not a membership oracle. Task 6 |
| -// adds the pending-join exception in front of the allowlist check. |
| +// gateBegin sits in front of rastrillo's Begin. Meet's own refusals are |
| +// byte-identical to success; rastrillo's address and rate outcomes are |
| +// shown; membership of a specific address is not a secret Meet keeps at |
| +// this form, per spec §3.2, and the timing of a sent mail is a further |
| +// channel the response cannot hide. Task 6 adds the pending-join |
| +// exception in front of the allowlist check. |
| func (a *app) gateBegin(next http.HandlerFunc) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| _ = r.ParseForm() |
| @@ -59,6 +61,11 @@ func (a *app) gateBegin(next http.HandlerFunc) http.HandlerFunc { |
| if r.Form == nil { |
| r.Form = url.Values{} |
| } |
| + // force makes rastrillo skip its address classifier, so Begin |
| + // never takes the keymail branch (a 303 to an external host |
| + // chosen by the address's DNS, which would be a distinguishable |
| + // outcome and an oracle); it also means keymail sign-in is |
| + // disabled app-wide, deliberately. |
| r.Form.Set("force", "1") |
| next(&sameAnswer{ResponseWriter: w, app: a}, r) |
| } |
| @@ -87,7 +94,9 @@ func (a *app) mayRequestLink(w http.ResponseWriter, r *http.Request, address str |
| } |
| if !member { |
| now := a.store.now() |
| - if !a.outsiderBudget.allow("all", now) || !a.linkBudget.allow(ck.TokenHash, now) { |
| + // The per-link budget is consulted first: a link already over |
| + // its own budget must not keep charging the shared pool. |
| + if !a.linkBudget.allow(ck.TokenHash, now) || !a.outsiderBudget.allow("all", now) { |
| a.logger.Warn("meet: outsider sign-in attempts over budget", "link", ck.TokenHash[:8]) |
| return false |
| } |
| @@ -109,13 +118,21 @@ func (a *app) mayRequestLink(w http.ResponseWriter, r *http.Request, address str |
| // sameAnswer rewrites rastrillo's own refusal redirects to the sent |
| // location. It hooks WriteHeader because http.Redirect sets Location |
| // and then calls WriteHeader, so the Location is visible in time. |
| +// Meet's own refusals are byte-identical to success; rastrillo's |
| +// address and rate outcomes are shown; membership of a specific |
| +// address is not a secret Meet keeps at this form, per spec §3.2, and |
| +// the timing of a sent mail is a further channel the response cannot |
| +// hide. |
| type sameAnswer struct { |
| http.ResponseWriter |
| app *app |
| } |
| func (s *sameAnswer) WriteHeader(status int) { |
| - if loc := s.Header().Get("Location"); loc != "" && loc != sentLocation && strings.HasPrefix(loc, signinPath+"?err=") { |
| + loc := s.Header().Get("Location") |
| + rewrite := loc != "" && strings.HasPrefix(loc, signinPath+"?err=") && |
| + loc != signinPath+"?err=address" && loc != signinPath+"?err=rate" |
| + if rewrite { |
| s.app.logger.Warn("meet: sign-in refused; answering as if sent", "outcome", loc) |
| s.Header().Set("Location", sentLocation) |
| } |
diff --git a/internal/meet/budget.go b/internal/meet/budget.go| index 746eb0c..5fc55f6 100644 |
| --- a/internal/meet/budget.go |
| +++ b/internal/meet/budget.go |
| @@ -8,7 +8,8 @@ import ( |
| // budget is a sliding-window counter: at most limit events per key per |
| // window. It counts attempts, refused or not, because the point is to |
| // keep outsider traffic away from rastrillo's shared limiter (spec |
| -// §3.2), and an attempt that reaches Begin is what consumes that. |
| +// §3.2), and an attempt that reaches Begin is what consumes that. The |
| +// budgets are process-local and reset on every deploy or restart. |
| type budget struct { |
| mu sync.Mutex |
| limit int |
| @@ -31,8 +32,12 @@ func (b *budget) allow(key string, now time.Time) bool { |
| kept = append(kept, t) |
| } |
| } |
| - if len(kept) >= b.limit { |
| + if len(kept) == 0 { |
| + delete(b.hits, key) |
| + } else { |
| b.hits[key] = kept |
| + } |
| + if len(kept) >= b.limit { |
| return false |
| } |
| b.hits[key] = append(kept, now) |
diff --git a/internal/meet/store.go b/internal/meet/store.go| index e033444..10469f7 100644 |
| --- a/internal/meet/store.go |
| +++ b/internal/meet/store.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "errors" |
| "fmt" |
| + "log/slog" |
| "strings" |
| "time" |
| @@ -27,6 +28,7 @@ type store struct { |
| g *gorm.DB |
| sealer *seal.Sealer |
| now func() time.Time |
| + logger *slog.Logger |
| } |
| func actorFor(p Person) string { return fmt.Sprintf("person:%d", p.ID) } |
| @@ -69,6 +71,7 @@ type meetingView struct { |
| func (s *store) title(m Meeting) string { |
| t, err := s.sealer.OpenString(seal.PurposeMeetingTitle, m.TitleSealed) |
| if err != nil { |
| + s.logger.Error("meet: open title", "meeting_id", m.ID, "err", err) |
| return "(title unreadable)" |
| } |
| return t |
| @@ -279,7 +282,7 @@ func (s *store) latestPending(ctx context.Context, address string) (PendingJoin, |
| } |
| var p PendingJoin |
| err = s.g.WithContext(ctx).Where("address_fingerprint = ? AND expires_at > ?", fp, s.now().UTC()). |
| - Order("created_at DESC").Take(&p).Error |
| + Order("created_at DESC, id DESC").Take(&p).Error |
| if errors.Is(err, gorm.ErrRecordNotFound) { |
| return PendingJoin{}, false, nil |
| } |
| @@ -301,7 +304,7 @@ func (s *store) deletePending(ctx context.Context, address string) error { |
| func (s *store) consumePending(ctx context.Context, p Person, pend PendingJoin, member bool) (string, error) { |
| dest := "" |
| err := s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| - ts := &store{g: tx, sealer: s.sealer, now: s.now} |
| + ts := &store{g: tx, sealer: s.sealer, now: s.now, logger: s.logger} |
| if err := ts.deletePending(ctx, p.Email); err != nil { |
| return err |
| } |
diff --git a/internal/meet/store_test.go b/internal/meet/store_test.go| index 1fe5b3e..ce8f125 100644 |
| --- a/internal/meet/store_test.go |
| +++ b/internal/meet/store_test.go |
| @@ -28,7 +28,7 @@ func testStore(t *testing.T) (*store, *time.Time) { |
| } |
| sl, _ := seal.New(bytes.Repeat([]byte{9}, 32)) |
| now := time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC) |
| - return &store{g: d.G, sealer: sl, now: func() time.Time { return now }}, &now |
| + return &store{g: d.G, sealer: sl, now: func() time.Time { return now }, logger: logger}, &now |
| } |
| func TestCreateMeetingSealsTitleAndMintsOneLiveLink(t *testing.T) { |
diff --git a/internal/meettest/join_test.go b/internal/meettest/join_test.go| index 474d9a7..a674857 100644 |
| --- a/internal/meettest/join_test.go |
| +++ b/internal/meettest/join_test.go |
| @@ -128,6 +128,64 @@ func TestOutsiderBudgetsStopMailAtFivePerLinkAndTenOverall(t *testing.T) { |
| } |
| } |
| +func TestMemberAddressExhaustsTheSharedLimiterWithTheSameAnswer(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + for i := 0; i < 5; i++ { |
| + g := x.browser(t) |
| + rec := g.post("/signin", url.Values{"address": {"paul@example.com"}}) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" { |
| + t.Fatalf("attempt %d: %d %s, want 303 /signin?sent=1", i, rec.Code, rec.Header().Get("Location")) |
| + } |
| + } |
| + g := x.browser(t) |
| + rec := g.post("/signin", url.Values{"address": {"paul@example.com"}}) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?err=rate" { |
| + t.Fatalf("sixth attempt: %d %s, want 303 /signin?err=rate", rec.Code, rec.Header().Get("Location")) |
| + } |
| + if got := x.mail.count(); got != 5 { |
| + t.Fatalf("mail count: %d, want 5", got) |
| + } |
| + if body := g.get("/signin?err=rate").Body.String(); !strings.Contains(body, "Too many sign-in attempts") { |
| + t.Fatal("?err=rate does not render rastrillo's rate-limit message") |
| + } |
| +} |
| + |
| +func TestExpiredPendingRowAdmitsNobody(t *testing.T) { |
| + x, _, tok := memberWithMeeting(t) |
| + g := x.browser(t) |
| + g.get("/j/" + tok) |
| + rec := g.post("/signin", url.Values{"address": {"guest@example.com"}}) |
| + if rec.Header().Get("Location") != "/signin?sent=1" { |
| + t.Fatalf("outsider's request: %d %s", rec.Code, rec.Header().Get("Location")) |
| + } |
| + before := x.mail.count() |
| + _, link := x.mail.last() |
| + x.advance(16 * time.Minute) |
| + u, err := url.Parse(link) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec := g.get(u.RequestURI()); rec.Code != http.StatusForbidden { |
| + t.Fatalf("expired pending row click: %d, want 403 from Authorize", rec.Code) |
| + } |
| + if got := x.mail.count(); got != before { |
| + t.Fatalf("mail count changed after the expired click: %d, want %d", got, before) |
| + } |
| + |
| + x2, _, tok2 := memberWithMeeting(t) |
| + m := x2.browser(t) |
| + m.get("/j/" + tok2) |
| + rec = m.post("/signin", url.Values{"address": {"paul@example.com"}}) |
| + if rec.Header().Get("Location") != "/signin?sent=1" { |
| + t.Fatalf("member's request: %d %s", rec.Code, rec.Header().Get("Location")) |
| + } |
| + _, link2 := x2.mail.last() |
| + x2.advance(16 * time.Minute) |
| + if dest := m.follow(link2); dest != "/" { |
| + t.Fatalf("signed-out member after an expired pending row landed on %s, want /", dest) |
| + } |
| +} |
| + |
| func TestExpiredGrantIsRefusedEntry(t *testing.T) { |
| x, sqid, tok := memberWithMeeting(t) |
| g := x.browser(t) |