fix(api): rate-limit email-OTP sends to close the email-bomb vector

handleEmailOTPStart minted and mailed a code on every call, so an
authenticated caller could drive unbounded mail to any address they
typed — an email-bomb primitive against arbitrary mailboxes.

Add a separate otpLimiter (its own sync.Once and map, distinct from the
wake limiter) and throttle each send on two keys before anything is
minted: the caller (user:<id>) and the recipient (email:<lower>). A
refused send mints no code and mails nothing; both cooldowns are
recorded only after delivery succeeds, mirroring the wake path so a
failed mint or delivery never consumes the throttle. The two-key design
stops both one account fanning out across addresses and many accounts
converging on one mailbox.
This commit is contained in:
flyemoji committed 2026-06-30 20:04:23 +09:00
1 parent 2a4a81b9b2
commit 6c3999a067
3 files changed
+115

No files matched your search

+14
View File
@@ -95,6 +95,9 @@ type API struct {
cooldownOnce sync.Once
cooldown *cooldownLimiter
otpCooldownOnce sync.Once
otpCooldown *cooldownLimiter
}
// now returns the current time using the injected clock.
@@ -113,6 +116,17 @@ func (a *API) limiter() *cooldownLimiter {
return a.cooldown
}
// otpLimiter lazily builds a SEPARATE cooldown limiter for email-OTP sends, so an
// OTP resend throttle never shares state with the wake throttle. Keyed by
// principal and by recipient (see handleEmailOTPStart), it bounds how often a code
// may be mailed and closes the email-bomb vector.
func (a *API) otpLimiter() *cooldownLimiter {
a.otpCooldownOnce.Do(func() {
a.otpCooldown = &cooldownLimiter{now: a.now, last: map[string]time.Time{}}
})
return a.otpCooldown
}
// apiRoute is one served HTTP route. Each face exposes its routes as a single
// table (internalAPIRoutes / externalAPIRoutes) so that one declaration drives
// BOTH handler construction here AND the OpenAPI parity test (openapi_test.go):