From 93e5501bfa8e319213c72b35e52a525da3b93b01 Mon Sep 17 00:00:00 2001 From: jalikajalika5 <105954036+jalikajalika5@users.noreply.github.com> Date: Wed, 27 May 2026 21:30:54 +0700 Subject: [PATCH 1/7] fix: mitigate user enumeration vulnerability in recover endpoint --- internal/api/recover.go | 9 +++++ internal/api/recover_test.go | 35 +++++++++++++++++++- internal/utilities/fake_rate_limiter.go | 44 +++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 internal/utilities/fake_rate_limiter.go diff --git a/internal/api/recover.go b/internal/api/recover.go index 7c967aaf00..92595ba5bf 100644 --- a/internal/api/recover.go +++ b/internal/api/recover.go @@ -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 @@ -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") + + // Mitigate rate-limit enumeration by using an in-memory cache for non-existent users + if lastReq := utilities.CheckFakeRateLimit(params.Email, aud, 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) diff --git a/internal/api/recover_test.go b/internal/api/recover_test.go index a7e655c596..cfcf2abba5 100644 --- a/internal/api/recover_test.go +++ b/internal/api/recover_test.go @@ -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) @@ -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) +} diff --git a/internal/utilities/fake_rate_limiter.go b/internal/utilities/fake_rate_limiter.go new file mode 100644 index 0000000000..513b3bc407 --- /dev/null +++ b/internal/utilities/fake_rate_limiter.go @@ -0,0 +1,44 @@ +package utilities + +import ( + "crypto/sha256" + "encoding/hex" + "sync" + "time" +) + +var ( + // fakeRateLimitCache stores hashes of non-existent emails and their last request timestamp. + fakeRateLimitCache sync.Map +) + +// 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(email string, aud string, frequency time.Duration) *time.Time { + hash := sha256.Sum256([]byte(email + "|" + aud)) + hashStr := hex.EncodeToString(hash[:]) + + now := time.Now() + if val, ok := fakeRateLimitCache.Load(hashStr); ok { + lastReq := val.(time.Time) + if now.Sub(lastReq) < frequency { + return &lastReq // Rate limited + } + } + + fakeRateLimitCache.Store(hashStr, now) + return nil // Not rate limited +} + +// CleanupFakeRateLimitCache removes expired entries from the cache. +// Call this periodically or when necessary to prevent unbounded memory growth. +func CleanupFakeRateLimitCache(frequency time.Duration) { + now := time.Now() + fakeRateLimitCache.Range(func(key, value interface{}) bool { + lastReq := value.(time.Time) + if now.Sub(lastReq) > frequency { + fakeRateLimitCache.Delete(key) + } + return true + }) +} From e54366c6a9d852ee134eeb16aa4aac0ed22eb051 Mon Sep 17 00:00:00 2001 From: jalikajalika5 <105954036+jalikajalika5@users.noreply.github.com> Date: Wed, 27 May 2026 21:53:56 +0700 Subject: [PATCH 2/7] fix: remove user-controlled aud from fake rate-limit cache key --- internal/api/recover.go | 2 +- internal/utilities/fake_rate_limiter.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/api/recover.go b/internal/api/recover.go index 92595ba5bf..7168dc711a 100644 --- a/internal/api/recover.go +++ b/internal/api/recover.go @@ -58,7 +58,7 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error { crypto.GenerateTokenHash(params.Email, "dummy") // Mitigate rate-limit enumeration by using an in-memory cache for non-existent users - if lastReq := utilities.CheckFakeRateLimit(params.Email, aud, config.SMTP.MaxFrequency); lastReq != nil { + if lastReq := utilities.CheckFakeRateLimit(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{}) diff --git a/internal/utilities/fake_rate_limiter.go b/internal/utilities/fake_rate_limiter.go index 513b3bc407..7cef8cc5fd 100644 --- a/internal/utilities/fake_rate_limiter.go +++ b/internal/utilities/fake_rate_limiter.go @@ -14,8 +14,8 @@ var ( // 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(email string, aud string, frequency time.Duration) *time.Time { - hash := sha256.Sum256([]byte(email + "|" + aud)) +func CheckFakeRateLimit(email string, frequency time.Duration) *time.Time { + hash := sha256.Sum256([]byte(email)) hashStr := hex.EncodeToString(hash[:]) now := time.Now() From 741c7245265b8c507f1ef43e6cb87d9472b5d6fb Mon Sep 17 00:00:00 2001 From: jalikajalika5 <105954036+jalikajalika5@users.noreply.github.com> Date: Wed, 27 May 2026 22:07:51 +0700 Subject: [PATCH 3/7] fix: use database-backed fake rate limiter to fix TOCTOU and distributed bypass --- internal/api/recover.go | 2 +- internal/utilities/fake_rate_limiter.go | 62 ++++++++++++------- ...20260527000000_add_fake_rate_limits.up.sql | 4 ++ 3 files changed, 44 insertions(+), 24 deletions(-) create mode 100644 migrations/20260527000000_add_fake_rate_limits.up.sql diff --git a/internal/api/recover.go b/internal/api/recover.go index 7168dc711a..f7c7c43131 100644 --- a/internal/api/recover.go +++ b/internal/api/recover.go @@ -58,7 +58,7 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error { crypto.GenerateTokenHash(params.Email, "dummy") // Mitigate rate-limit enumeration by using an in-memory cache for non-existent users - if lastReq := utilities.CheckFakeRateLimit(params.Email, config.SMTP.MaxFrequency); lastReq != nil { + 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{}) diff --git a/internal/utilities/fake_rate_limiter.go b/internal/utilities/fake_rate_limiter.go index 7cef8cc5fd..5ee613d18e 100644 --- a/internal/utilities/fake_rate_limiter.go +++ b/internal/utilities/fake_rate_limiter.go @@ -3,42 +3,58 @@ package utilities import ( "crypto/sha256" "encoding/hex" - "sync" "time" -) -var ( - // fakeRateLimitCache stores hashes of non-existent emails and their last request timestamp. - fakeRateLimitCache sync.Map + "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(email string, frequency time.Duration) *time.Time { +func CheckFakeRateLimit(db *storage.Connection, email string, frequency time.Duration) *time.Time { hash := sha256.Sum256([]byte(email)) hashStr := hex.EncodeToString(hash[:]) - now := time.Now() - if val, ok := fakeRateLimitCache.Load(hashStr); ok { - lastReq := val.(time.Time) - if now.Sub(lastReq) < frequency { - return &lastReq // Rate limited + 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) + + 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 + }) - fakeRateLimitCache.Store(hashStr, now) - return nil // Not rate limited + return lastReq } // CleanupFakeRateLimitCache removes expired entries from the cache. // Call this periodically or when necessary to prevent unbounded memory growth. -func CleanupFakeRateLimitCache(frequency time.Duration) { - now := time.Now() - fakeRateLimitCache.Range(func(key, value interface{}) bool { - lastReq := value.(time.Time) - if now.Sub(lastReq) > frequency { - fakeRateLimitCache.Delete(key) - } - return true - }) +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() } diff --git a/migrations/20260527000000_add_fake_rate_limits.up.sql b/migrations/20260527000000_add_fake_rate_limits.up.sql new file mode 100644 index 0000000000..a4b19452e1 --- /dev/null +++ b/migrations/20260527000000_add_fake_rate_limits.up.sql @@ -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 +); From 223bf2e09d981c748e20838f49820cae87e6360e Mon Sep 17 00:00:00 2001 From: jalikajalika5 <105954036+jalikajalika5@users.noreply.github.com> Date: Wed, 27 May 2026 22:36:36 +0700 Subject: [PATCH 4/7] fix: secure fake rate limits with HMAC-SHA256 and insert-first pattern --- internal/api/recover.go | 2 +- internal/utilities/fake_rate_limiter.go | 40 ++++++++++++++----------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/internal/api/recover.go b/internal/api/recover.go index f7c7c43131..2e3b1dc9ab 100644 --- a/internal/api/recover.go +++ b/internal/api/recover.go @@ -58,7 +58,7 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error { crypto.GenerateTokenHash(params.Email, "dummy") // 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 { + if lastReq := utilities.CheckFakeRateLimit(db, params.Email, config.SMTP.MaxFrequency, []byte(config.JWT.Secret)); lastReq != nil { return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, "%s", generateFrequencyLimitErrorMessage(lastReq, config.SMTP.MaxFrequency)) } return sendJSON(w, http.StatusOK, map[string]string{}) diff --git a/internal/utilities/fake_rate_limiter.go b/internal/utilities/fake_rate_limiter.go index 5ee613d18e..ad2bd11a0f 100644 --- a/internal/utilities/fake_rate_limiter.go +++ b/internal/utilities/fake_rate_limiter.go @@ -1,6 +1,7 @@ package utilities import ( + "crypto/hmac" "crypto/sha256" "encoding/hex" "time" @@ -20,30 +21,33 @@ func (FakeRateLimit) TableName() string { // 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)) - hashStr := hex.EncodeToString(hash[:]) +func CheckFakeRateLimit(db *storage.Connection, email string, frequency time.Duration, secret []byte) *time.Time { + h := hmac.New(sha256.New, secret) + h.Write([]byte(email)) + hashStr := hex.EncodeToString(h.Sum(nil)) var lastReq *time.Time _ = db.Transaction(func(tx *storage.Connection) error { - // Lock the row + // Pre-insert a sentinel row so the row always exists before we lock it. + // This prevents two concurrent first-requests from both racing past FOR UPDATE. + epoch := time.Unix(0, 0).UTC() + _ = tx.RawQuery(`INSERT INTO fake_rate_limits (email_hash, last_request_at) VALUES (?, ?) ON CONFLICT DO NOTHING`, hashStr, epoch).Exec() + + // Lock the now-guaranteed-existing row existing := &FakeRateLimit{} - err := tx.RawQuery(`SELECT last_request_at FROM fake_rate_limits WHERE email_hash = ? FOR UPDATE`, hashStr).First(existing) - + if err := tx.RawQuery(`SELECT last_request_at FROM fake_rate_limits WHERE email_hash = ? FOR UPDATE`, hashStr).First(existing); err != nil { + return err + } + 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() + if now.Sub(existing.LastRequestAt) < frequency { + // Rate limited! + last := existing.LastRequestAt + lastReq = &last + return nil } + // Not rate limited, update the timestamp + _ = tx.RawQuery(`UPDATE fake_rate_limits SET last_request_at = ? WHERE email_hash = ?`, now, hashStr).Exec() return nil }) From c864709c4f7e0f32aa94fbfe54bd1df6ebb908e9 Mon Sep 17 00:00:00 2001 From: jalikajalika5 <105954036+jalikajalika5@users.noreply.github.com> Date: Wed, 27 May 2026 22:59:03 +0700 Subject: [PATCH 5/7] fix: mitigate timing attack on user enumeration by enforcing minimum response time --- internal/api/recover.go | 8 ++++++-- test_jwks.go | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 test_jwks.go diff --git a/internal/api/recover.go b/internal/api/recover.go index 2e3b1dc9ab..625db11c35 100644 --- a/internal/api/recover.go +++ b/internal/api/recover.go @@ -2,9 +2,9 @@ package api import ( "net/http" + "time" "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" @@ -33,6 +33,7 @@ func (p *RecoverParams) Validate(a *API) error { // Recover sends a recovery email func (a *API) Recover(w http.ResponseWriter, r *http.Request) error { + start := time.Now() ctx := r.Context() db := a.db.WithContext(ctx) config := a.config @@ -55,7 +56,10 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error { if err != nil { if models.IsNotFoundError(err) { // Simulate processing time to mitigate timing attacks - crypto.GenerateTokenHash(params.Email, "dummy") + const minResponseTime = 500 * time.Millisecond + if elapsed := time.Since(start); elapsed < minResponseTime { + time.Sleep(minResponseTime - elapsed) + } // Mitigate rate-limit enumeration by using an in-memory cache for non-existent users if lastReq := utilities.CheckFakeRateLimit(db, params.Email, config.SMTP.MaxFrequency, []byte(config.JWT.Secret)); lastReq != nil { diff --git a/test_jwks.go b/test_jwks.go new file mode 100644 index 0000000000..830d9c6b05 --- /dev/null +++ b/test_jwks.go @@ -0,0 +1,17 @@ +package main + +import ( + "fmt" + "github.com/go-jose/go-jose/v3" +) + +func main() { + jwksStr := `{"keys":[{"kty":"EC","crv":"secp256k1","x":"1","y":"2"}]}` + var jwks jose.JSONWebKeySet + err := jwks.UnmarshalJSON([]byte(jwksStr)) + if err != nil { + fmt.Println("Error:", err) + return + } + fmt.Println("Parsed:", len(jwks.Keys)) +} From 8ff1b77ae6d71ff1c0a33b397e72a6945977a6db Mon Sep 17 00:00:00 2001 From: jalikajalika5 <105954036+jalikajalika5@users.noreply.github.com> Date: Wed, 27 May 2026 23:08:51 +0700 Subject: [PATCH 6/7] fix: global min response time and domain-separated HMAC secret --- internal/api/recover.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/internal/api/recover.go b/internal/api/recover.go index 625db11c35..70569c1e7b 100644 --- a/internal/api/recover.go +++ b/internal/api/recover.go @@ -31,9 +31,15 @@ func (p *RecoverParams) Validate(a *API) error { return nil } -// Recover sends a recovery email func (a *API) Recover(w http.ResponseWriter, r *http.Request) error { start := time.Now() + const minResponseTime = 500 * time.Millisecond + defer func() { + if elapsed := time.Since(start); elapsed < minResponseTime { + time.Sleep(minResponseTime - elapsed) + } + }() + ctx := r.Context() db := a.db.WithContext(ctx) config := a.config @@ -55,14 +61,10 @@ 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 - const minResponseTime = 500 * time.Millisecond - if elapsed := time.Since(start); elapsed < minResponseTime { - time.Sleep(minResponseTime - elapsed) - } - // Mitigate rate-limit enumeration by using an in-memory cache for non-existent users - if lastReq := utilities.CheckFakeRateLimit(db, params.Email, config.SMTP.MaxFrequency, []byte(config.JWT.Secret)); lastReq != nil { + // Use a domain-separated secret to prevent key separation violations + secret := []byte("fake_rate_limit:" + config.JWT.Secret) + if lastReq := utilities.CheckFakeRateLimit(db, params.Email, config.SMTP.MaxFrequency, secret); lastReq != nil { return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, "%s", generateFrequencyLimitErrorMessage(lastReq, config.SMTP.MaxFrequency)) } return sendJSON(w, http.StatusOK, map[string]string{}) From 9e1597726d853d29a47b6f2e492fa3b2f23e265f Mon Sep 17 00:00:00 2001 From: jalikajalika5 <105954036+jalikajalika5@users.noreply.github.com> Date: Wed, 27 May 2026 23:18:02 +0700 Subject: [PATCH 7/7] fix: implement probabilistic cleanup for fake rate limits table --- internal/utilities/fake_rate_limiter.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/utilities/fake_rate_limiter.go b/internal/utilities/fake_rate_limiter.go index ad2bd11a0f..bebd6fa7d1 100644 --- a/internal/utilities/fake_rate_limiter.go +++ b/internal/utilities/fake_rate_limiter.go @@ -4,6 +4,7 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/hex" + "math/rand" "time" "github.com/supabase/auth/internal/storage" @@ -51,6 +52,11 @@ func CheckFakeRateLimit(db *storage.Connection, email string, frequency time.Dur return nil }) + // Probabilistic cleanup (10% chance) to prevent table unbounded growth + if rand.Intn(10) == 0 { + go CleanupFakeRateLimitCache(db, frequency) + } + return lastReq }