From 2207ac6132100a7f3a302f57cb89cc7a6a7bcf2e Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 03:02:49 +0200 Subject: [PATCH] feat(account): throttle confirm-code sends per recipient Add an in-memory SendLimiter enforcing a one-per-minute cooldown and a five-per-rolling-hour cap per recipient address, checked before provisioning or sending in RequestCode, RequestLoginCode and RequestLinkCode. It guards against email bombing and protects the relay quota. The limiter is injected in main (nil in tests, so the domain suite is unaffected); ErrTooManyRequests maps to HTTP 429. --- backend/cmd/backend/main.go | 3 + backend/internal/account/email.go | 20 +++++++ backend/internal/account/link.go | 3 + backend/internal/account/ratelimit.go | 66 ++++++++++++++++++++++ backend/internal/account/ratelimit_test.go | 43 ++++++++++++++ backend/internal/server/dto_test.go | 1 + backend/internal/server/handlers.go | 2 + 7 files changed, 138 insertions(+) create mode 100644 backend/internal/account/ratelimit.go create mode 100644 backend/internal/account/ratelimit_test.go diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 2f9a61b..a976387 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -175,6 +175,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { // so they are handed to the server (like the route groups) for the handlers. mailer := newMailer(cfg.SMTP, logger) emails := account.NewEmailService(accounts, mailer, cfg.PublicBaseURL) + // Throttle confirm-code sends per recipient: at most one per minute and five per + // rolling hour, guarding against email bombing and the relay's own quota. + emails.SetSendLimiter(account.NewSendLimiter(time.Minute, 5)) // Account linking & merge: the orchestrator over the account, merge and // session layers. Wired to the /api/v1/user/link REST surface below. links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions) diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index 73886f9..e4df71f 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -55,6 +55,9 @@ var ( ErrTooManyAttempts = errors.New("account: too many confirmation attempts") // ErrCodeMismatch is returned when the submitted code does not match. ErrCodeMismatch = errors.New("account: confirmation code does not match") + // ErrTooManyRequests is returned when confirm-code sends to an address are being + // requested too frequently (the resend cooldown or the rolling-hour cap). + ErrTooManyRequests = errors.New("account: too many code requests") ) // EmailService runs the email confirm-code flow: it issues a 6-digit code over a @@ -67,6 +70,7 @@ type EmailService struct { store *Store mailer Mailer baseURL string + limiter *SendLimiter now func() time.Time } @@ -78,6 +82,16 @@ func NewEmailService(store *Store, mailer Mailer, baseURL string) *EmailService return &EmailService{store: store, mailer: mailer, baseURL: baseURL, now: func() time.Time { return time.Now().UTC() }} } +// SetSendLimiter installs a per-recipient send throttle. When unset (nil), sends are +// not throttled — production wires a limiter; tests leave it off. +func (s *EmailService) SetSendLimiter(l *SendLimiter) { s.limiter = l } + +// allowSend reports whether a confirm-code send to email is permitted now, recording +// it when so. A nil limiter permits every send. +func (s *EmailService) allowSend(email string) bool { + return s.limiter == nil || s.limiter.Allow(email) +} + // issueCode generates a fresh confirm-code for (accountID, email), replaces any prior // pending confirmation, and mails the branded code in locale; purpose selects the // email wording. Only the SHA-256 hash of the code is stored. @@ -115,6 +129,9 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema if err != nil { return err } + if !s.allowSend(addr) { + return ErrTooManyRequests + } owner, ok, err := s.store.confirmedEmailAccount(ctx, addr) if err != nil { return err @@ -172,6 +189,9 @@ func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, l if err != nil { return uuid.UUID{}, err } + if !s.allowSend(addr) { + return uuid.UUID{}, ErrTooManyRequests + } acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ, language) if err != nil { return uuid.UUID{}, err diff --git a/backend/internal/account/link.go b/backend/internal/account/link.go index 1999245..1a597ed 100644 --- a/backend/internal/account/link.go +++ b/backend/internal/account/link.go @@ -26,6 +26,9 @@ func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, if err != nil { return err } + if !s.allowSend(addr) { + return ErrTooManyRequests + } return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID)) } diff --git a/backend/internal/account/ratelimit.go b/backend/internal/account/ratelimit.go new file mode 100644 index 0000000..b8f1345 --- /dev/null +++ b/backend/internal/account/ratelimit.go @@ -0,0 +1,66 @@ +package account + +import ( + "sync" + "time" +) + +// SendLimiter throttles confirm-code sends per recipient address: it enforces a +// minimum cooldown between two sends and a cap over a rolling hour. It guards against +// email bombing and protects the relay's own quota. State is in-memory (per process, +// reset on restart) and keyed by the normalised recipient address, which is adequate +// for the single-instance backend. Safe for concurrent use. +type SendLimiter struct { + mu sync.Mutex + cooldown time.Duration + perHour int + now func() time.Time + sends map[string][]time.Time +} + +// NewSendLimiter returns a SendLimiter allowing at most one send per cooldown and at +// most perHour sends over any rolling hour, to the same recipient. +func NewSendLimiter(cooldown time.Duration, perHour int) *SendLimiter { + return &SendLimiter{ + cooldown: cooldown, + perHour: perHour, + now: func() time.Time { return time.Now() }, + sends: make(map[string][]time.Time), + } +} + +// Allow reports whether a send to key is permitted now, recording the send when it is. +// It is denied when the last send was within the cooldown or the rolling-hour cap is +// already reached. +func (l *SendLimiter) Allow(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + cutoff := now.Add(-time.Hour) + kept := l.sends[key][:0] + for _, t := range l.sends[key] { + if t.After(cutoff) { + kept = append(kept, t) + } + } + if n := len(kept); n > 0 && now.Sub(kept[n-1]) < l.cooldown { + l.set(key, kept) + return false + } + if len(kept) >= l.perHour { + l.set(key, kept) + return false + } + l.set(key, append(kept, now)) + return true +} + +// set stores the retained send times for key, dropping the entry entirely once empty +// so the map stays bounded to recipients active within the last hour. +func (l *SendLimiter) set(key string, times []time.Time) { + if len(times) == 0 { + delete(l.sends, key) + return + } + l.sends[key] = times +} diff --git a/backend/internal/account/ratelimit_test.go b/backend/internal/account/ratelimit_test.go new file mode 100644 index 0000000..15236b4 --- /dev/null +++ b/backend/internal/account/ratelimit_test.go @@ -0,0 +1,43 @@ +package account + +import ( + "testing" + "time" +) + +// TestSendLimiter checks the per-recipient cooldown and the rolling-hour cap, and +// that recipients are throttled independently. +func TestSendLimiter(t *testing.T) { + base := time.Now() + now := base + l := NewSendLimiter(time.Minute, 3) + l.now = func() time.Time { return now } + + if !l.Allow("a") { + t.Fatal("send 1 should be allowed") + } + if l.Allow("a") { + t.Fatal("immediate resend must be blocked by the cooldown") + } + if !l.Allow("b") { + t.Fatal("a different recipient is independent") + } + + now = base.Add(time.Minute) + if !l.Allow("a") { + t.Fatal("send 2 after the cooldown should be allowed") + } + now = base.Add(2 * time.Minute) + if !l.Allow("a") { + t.Fatal("send 3 should be allowed") + } + now = base.Add(3 * time.Minute) + if l.Allow("a") { + t.Fatal("send 4 within the hour must be blocked by the cap") + } + + now = base.Add(time.Hour + time.Minute) + if !l.Allow("a") { + t.Fatal("after the rolling hour the cap resets") + } +} diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go index d0a023d..5880637 100644 --- a/backend/internal/server/dto_test.go +++ b/backend/internal/server/dto_test.go @@ -30,6 +30,7 @@ func TestStatusForError(t *testing.T) { "illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"}, "email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"}, "code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"}, + "too many codes": {account.ErrTooManyRequests, http.StatusTooManyRequests, "too_many_requests"}, "session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"}, "chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"}, "no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"}, diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 6edd670..b87a2d0 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -237,6 +237,8 @@ func statusForError(err error) (int, string) { case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired), errors.Is(err, account.ErrNoPendingCode), errors.Is(err, account.ErrTooManyAttempts): return http.StatusUnauthorized, "code_invalid" + case errors.Is(err, account.ErrTooManyRequests): + return http.StatusTooManyRequests, "too_many_requests" case errors.Is(err, session.ErrNotFound): return http.StatusUnauthorized, "session_invalid" case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong),