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