From 0bc149f17305c6f55b1ab06130947210928b9ebe Mon Sep 17 00:00:00 2001 From: Tony Xin Date: Fri, 29 May 2026 13:11:30 -0400 Subject: [PATCH] fix(auth): rate-limit OTP email sending ## Why this change is needed `/auth/otp/send` generated and emailed a fresh OTP on every request with no throttling. An attacker could: - bomb any victim's inbox by repeatedly POSTing their email (the address is fully attacker-controlled and the endpoint is unauthenticated), and - cheaply probe the system / drive up email-provider cost. The verify endpoint already caps guesses at 5 attempts per code, but nothing limited how often a new code could be requested. ## What this does - Adds a `CreatedAt` timestamp to `OtpCode`. - `sendOtp` now refuses to issue a new code if one was already sent to that email within a 30-second cooldown, returning `429 otp-rate-limited`. This is backward compatible: existing codes without `createdAt` decode to the zero time and are simply treated as outside the cooldown. The per-code 5-attempt verification cap is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/errs/errors.go | 1 + server/models/otp.go | 1 + server/routes/auth.go | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+) diff --git a/server/errs/errors.go b/server/errs/errors.go index b7ad9dea9..61f403de7 100644 --- a/server/errs/errors.go +++ b/server/errs/errors.go @@ -21,6 +21,7 @@ const ( OtpExpired string = "otp-expired" OtpInvalidCode string = "otp-invalid-code" OtpTooManyAttempts string = "otp-too-many-attempts" + OtpRateLimited string = "otp-rate-limited" InvalidIdToken string = "invalid-id-token" ) diff --git a/server/models/otp.go b/server/models/otp.go index 3ea3276f5..b3b819bc9 100644 --- a/server/models/otp.go +++ b/server/models/otp.go @@ -12,4 +12,5 @@ type OtpCode struct { Code string `json:"-" bson:"code"` ExpiresAt time.Time `json:"-" bson:"expiresAt"` Attempts int `json:"-" bson:"attempts"` + CreatedAt time.Time `json:"-" bson:"createdAt"` } diff --git a/server/routes/auth.go b/server/routes/auth.go index a894a1bb3..9b45f6d2d 100644 --- a/server/routes/auth.go +++ b/server/routes/auth.go @@ -17,6 +17,7 @@ import ( "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" "schej.it/server/db" "schej.it/server/errs" "schej.it/server/logger" @@ -367,6 +368,22 @@ func sendOtp(c *gin.Context) { email := strings.ToLower(strings.TrimSpace(payload.Email)) + // Rate limit: refuse to send a new code if one was already sent to this + // email within the cooldown window. Without this, the endpoint can be + // abused to bomb arbitrary inboxes with OTP emails (and to enumerate + // accounts by timing). The 5-attempt cap on verification is unaffected. + const otpSendCooldown = 30 * time.Second + var lastOtp models.OtpCode + findErr := db.OtpCodesCollection.FindOne( + context.Background(), + bson.M{"email": email}, + options.FindOne().SetSort(bson.M{"createdAt": -1}), + ).Decode(&lastOtp) + if findErr == nil && time.Since(lastOtp.CreatedAt) < otpSendCooldown { + c.JSON(http.StatusTooManyRequests, responses.Error{Error: errs.OtpRateLimited}) + return + } + // Delete any existing OTP codes for this email db.OtpCodesCollection.DeleteMany(context.Background(), bson.M{"email": email}) @@ -376,6 +393,7 @@ func sendOtp(c *gin.Context) { Code: code, ExpiresAt: time.Now().Add(10 * time.Minute), Attempts: 0, + CreatedAt: time.Now(), } _, err := db.OtpCodesCollection.InsertOne(context.Background(), otpDoc)