Skip to content
9 changes: 9 additions & 0 deletions internal/api/recover.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"net/http"

"github.com/supabase/auth/internal/api/apierrors"
"github.com/supabase/auth/internal/crypto"
"github.com/supabase/auth/internal/models"
"github.com/supabase/auth/internal/storage"
"github.com/supabase/auth/internal/utilities"
)

// RecoverParams holds the parameters for a password recovery request
Expand Down Expand Up @@ -52,6 +54,13 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error {
user, err = models.FindUserByEmailAndAudience(db, params.Email, aud)
if err != nil {
if models.IsNotFoundError(err) {
// Simulate processing time to mitigate timing attacks
crypto.GenerateTokenHash(params.Email, "dummy")
Comment thread
depthfirst-app[bot] marked this conversation as resolved.
Outdated

// Mitigate rate-limit enumeration by using an in-memory cache for non-existent users
if lastReq := utilities.CheckFakeRateLimit(db, params.Email, config.SMTP.MaxFrequency); lastReq != nil {
return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, "%s", generateFrequencyLimitErrorMessage(lastReq, config.SMTP.MaxFrequency))
}
return sendJSON(w, http.StatusOK, map[string]string{})
}
return apierrors.NewInternalServerError("Unable to process request").WithInternalError(err)
Expand Down
35 changes: 34 additions & 1 deletion internal/api/recover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ func (ts *RecoverTestSuite) TestRecover_NewEmailSent() {
assert.WithinDuration(ts.T(), time.Now(), *u.RecoverySentAt, 1*time.Second)
}

func (ts *RecoverTestSuite) TestRecover_NoSideChannelLeak() {
func (ts *RecoverTestSuite) TestRecover_NoSideChannelLeak_FirstRequest() {
email := "doesntexist@example.com"

_, err := models.FindUserByEmailAndAudience(ts.API.db, email, ts.Config.JWT.Aud)
Expand All @@ -151,3 +151,36 @@ func (ts *RecoverTestSuite) TestRecover_NoSideChannelLeak() {
ts.API.handler.ServeHTTP(w, req)
assert.Equal(ts.T(), http.StatusOK, w.Code)
}

func (ts *RecoverTestSuite) TestRecover_NoSideChannelLeak_RateLimit() {
email := "doesntexist_ratelimit@example.com"

_, err := models.FindUserByEmailAndAudience(ts.API.db, email, ts.Config.JWT.Aud)
require.True(ts.T(), models.IsNotFoundError(err), "User with email %s does exist", email)

// First Request
var buffer1 bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer1).Encode(map[string]interface{}{
"email": email,
}))
req1 := httptest.NewRequest(http.MethodPost, "http://localhost/recover", &buffer1)
req1.Header.Set("Content-Type", "application/json")

w1 := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w1, req1)
assert.Equal(ts.T(), http.StatusOK, w1.Code)

// Second Request immediately after
var buffer2 bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer2).Encode(map[string]interface{}{
"email": email,
}))
req2 := httptest.NewRequest(http.MethodPost, "http://localhost/recover", &buffer2)
req2.Header.Set("Content-Type", "application/json")

w2 := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w2, req2)

// Should be rate limited
assert.Equal(ts.T(), http.StatusTooManyRequests, w2.Code)
}
60 changes: 60 additions & 0 deletions internal/utilities/fake_rate_limiter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package utilities

import (
"crypto/sha256"
"encoding/hex"
"time"

"github.com/supabase/auth/internal/storage"
)

type FakeRateLimit struct {
EmailHash string `db:"email_hash"`
LastRequestAt time.Time `db:"last_request_at"`
}

// TableName returns the table name
func (FakeRateLimit) TableName() string {
return "fake_rate_limits"
}

// CheckFakeRateLimit simulates a rate limit check for a non-existent email.
// It returns the timestamp of the last request if it was rate limited, or nil if not.
func CheckFakeRateLimit(db *storage.Connection, email string, frequency time.Duration) *time.Time {
hash := sha256.Sum256([]byte(email))
Comment thread
depthfirst-app[bot] marked this conversation as resolved.
Outdated
hashStr := hex.EncodeToString(hash[:])

var lastReq *time.Time
_ = db.Transaction(func(tx *storage.Connection) error {
// Lock the row
existing := &FakeRateLimit{}
err := tx.RawQuery(`SELECT last_request_at FROM fake_rate_limits WHERE email_hash = ? FOR UPDATE`, hashStr).First(existing)
Comment thread
depthfirst-app[bot] marked this conversation as resolved.
Outdated

now := time.Now()
if err == nil { // Row exists
if now.Sub(existing.LastRequestAt) < frequency {
// Rate limited!
last := existing.LastRequestAt
lastReq = &last
return nil
}
// Not rate limited, update it
_ = tx.RawQuery(`UPDATE fake_rate_limits SET last_request_at = ? WHERE email_hash = ?`, now, hashStr).Exec()
} else { // Row doesn't exist or error
// Insert it
_ = tx.RawQuery(`INSERT INTO fake_rate_limits (email_hash, last_request_at) VALUES (?, ?) ON CONFLICT DO NOTHING`, hashStr, now).Exec()
}
return nil
})

return lastReq
}

// CleanupFakeRateLimitCache removes expired entries from the cache.
// Call this periodically or when necessary to prevent unbounded memory growth.
func CleanupFakeRateLimitCache(db *storage.Connection, frequency time.Duration) {
_ = db.RawQuery(
`DELETE FROM fake_rate_limits WHERE EXTRACT(EPOCH FROM (NOW() - last_request_at)) > ?`,
frequency.Seconds(),
).Exec()
}
4 changes: 4 additions & 0 deletions migrations/20260527000000_add_fake_rate_limits.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS fake_rate_limits (
email_hash VARCHAR(64) PRIMARY KEY,
last_request_at TIMESTAMP WITH TIME ZONE NOT NULL
);