From 4d8405b4ebf2bf1b72020e808feb96c321cd08f8 Mon Sep 17 00:00:00 2001 From: Anna Baker Date: Wed, 2 Sep 2026 16:40:07 -0400 Subject: [PATCH] chore(otp): clamp OtpExp to the max safe duration `time.Duration` counts nanoseconds in an int64, so converting an OtpExp above math.MaxInt64 / time.Second wraps to a negative duration. That would make `isOtpExpired` treat every token as already expired. This is defensive, not a fix for an observed bug. Hosted projects are capped at 2147483647 by the platform's int4 config column, roughly 4x below the overflow threshold, so the clamp cannot trigger for them. It guards self-hosted deployments, where GOTRUE_MAILER_OTP_EXP and GOTRUE_SMS_OTP_EXP are unbounded, and it lets the #nosec G115 annotation at verify.go:792 be justified from within this repo. --- internal/conf/configuration.go | 15 +++++++ internal/conf/configuration_test.go | 63 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 63a8144d29..66e819fa57 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "net/url" "regexp" "strings" @@ -25,6 +26,12 @@ const defaultChallengeExpiryDuration float64 = 300 const defaultFactorExpiryDuration time.Duration = 300 * time.Second const defaultFlowStateExpiryDuration time.Duration = 300 * time.Second +// maxOtpExp is the largest OTP validity window, in seconds, that survives +// conversion to a time.Duration, which counts nanoseconds in an int64. That is +// roughly 292 years. ApplyDefaults clamps OtpExp to it so the conversion cannot +// overflow into a negative duration, which would expire tokens at creation. +const maxOtpExp uint = math.MaxInt64 / uint(time.Second) + // See: https://www.postgresql.org/docs/7.0/syntax525.htm var postgresNamesRegexp = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]{0,62}$`) @@ -1203,6 +1210,10 @@ func (config *GlobalConfiguration) ApplyDefaults() error { config.Mailer.OtpExp = 86400 // 1 day } + if config.Mailer.OtpExp > maxOtpExp { + config.Mailer.OtpExp = maxOtpExp + } + if config.Mailer.OtpLength == 0 || config.Mailer.OtpLength < 6 || config.Mailer.OtpLength > 10 { // 6-digit otp by default config.Mailer.OtpLength = 6 @@ -1220,6 +1231,10 @@ func (config *GlobalConfiguration) ApplyDefaults() error { config.Sms.OtpExp = 60 } + if config.Sms.OtpExp > maxOtpExp { + config.Sms.OtpExp = maxOtpExp + } + if config.Sms.OtpLength == 0 || config.Sms.OtpLength < 6 || config.Sms.OtpLength > 10 { // 6-digit otp by default config.Sms.OtpLength = 6 diff --git a/internal/conf/configuration_test.go b/internal/conf/configuration_test.go index 373bf6ca2b..9b79e5cad0 100644 --- a/internal/conf/configuration_test.go +++ b/internal/conf/configuration_test.go @@ -3,6 +3,7 @@ package conf import ( "encoding/base64" "errors" + "math" "os" "sort" "strings" @@ -1251,3 +1252,65 @@ func TestProviderLinkingDomainsDecode(t *testing.T) { require.Error(t, d.Decode("custom:github=")) } } + +func TestApplyDefaultsClampsOtpExp(t *testing.T) { + baseConfig := func() *GlobalConfiguration { + c := &GlobalConfiguration{} + c.JWT.Secret = "secret" + return c + } + + cases := []struct { + desc string + mailerExp uint + smsExp uint + wantMailer uint + wantSms uint + }{ + { + desc: "zero takes the per-channel default", + mailerExp: 0, + smsExp: 0, + wantMailer: 86400, + wantSms: 60, + }, + { + desc: "in-range values pass through untouched", + mailerExp: 3600, + smsExp: 120, + wantMailer: 3600, + wantSms: 120, + }, + { + desc: "the ceiling itself is not clamped", + mailerExp: maxOtpExp, + smsExp: maxOtpExp, + wantMailer: maxOtpExp, + wantSms: maxOtpExp, + }, + { + desc: "values above the ceiling clamp down to it", + mailerExp: maxOtpExp + 1, + smsExp: math.MaxUint, + wantMailer: maxOtpExp, + wantSms: maxOtpExp, + }, + } + + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + cfg := baseConfig() + cfg.Mailer.OtpExp = c.mailerExp + cfg.Sms.OtpExp = c.smsExp + require.NoError(t, cfg.ApplyDefaults()) + + require.Equal(t, c.wantMailer, cfg.Mailer.OtpExp) + require.Equal(t, c.wantSms, cfg.Sms.OtpExp) + + // the clamp exists so that this conversion cannot overflow into a + // negative duration, which would expire every token at creation + require.Positive(t, time.Duration(cfg.Mailer.OtpExp)*time.Second) // #nosec G115 + require.Positive(t, time.Duration(cfg.Sms.OtpExp)*time.Second) // #nosec G115 + }) + } +}