diff --git a/settings.go b/settings.go index 309b797..7b3b20a 100644 --- a/settings.go +++ b/settings.go @@ -659,6 +659,24 @@ func (c *Config) Validate() error { return fmt.Errorf("sessionMaxAge is too large; must be at most %d seconds", math.MaxInt64/int64(time.Second)) } + // Bearer durations are int64 seconds * time.Second at startup (main.go); an + // unbounded value wraps negative or to zero and disables the age check + // and failure throttle. + for name, v := range map[string]int64{ + "maxTokenAgeSeconds": c.MaxTokenAgeSeconds, + "bearerFailureWindowSeconds": int64(c.BearerFailureWindowSeconds), + "bearerFailurePenaltySeconds": int64(c.BearerFailurePenaltySeconds), + } { + if v > maxDurationSeconds { + return fmt.Errorf("%s is too large; must be at most %d seconds", name, maxDurationSeconds) + } + } + // The throttle sweep cutoff adds window and penalty. + if int64(c.BearerFailureWindowSeconds) > 0 && int64(c.BearerFailurePenaltySeconds) > 0 && + int64(c.BearerFailureWindowSeconds)+int64(c.BearerFailurePenaltySeconds) > maxDurationSeconds { + return fmt.Errorf("bearerFailureWindowSeconds plus bearerFailurePenaltySeconds is too large") + } + // Validate audience if specified if c.Audience != "" { // Validate audience format - should be a valid identifier or URL @@ -1228,6 +1246,14 @@ func (c *Config) GetSecurityHeadersApplier() func(http.ResponseWriter, *http.Req } // isOriginAllowed checks if an origin is in the allowed list +const ( + // maxDurationSeconds is the largest whole-second value whose + // time.Duration product with time.Second does not overflow int64. + maxDurationSeconds = math.MaxInt64 / int64(time.Second) + // maxRedisPoolSize bounds the eagerly allocated connection channel. + maxRedisPoolSize = 10000 +) + // Validate checks if the Redis configuration is valid func (rc *RedisConfig) Validate() error { if !rc.Enabled { @@ -1254,6 +1280,9 @@ func (rc *RedisConfig) Validate() error { if rc.PoolSize < 0 { return fmt.Errorf("pool size cannot be negative") } + if rc.PoolSize > maxRedisPoolSize { + return fmt.Errorf("pool size is too large; must be at most %d", maxRedisPoolSize) + } if rc.ConnectTimeout < 0 { return fmt.Errorf("connect timeout cannot be negative") } @@ -1287,6 +1316,21 @@ func (rc *RedisConfig) Validate() error { return fmt.Errorf("health check interval cannot be negative") } + // Seconds become time.Duration (x * time.Second) and the threshold is + // narrowed to int32 in the breaker; out-of-range values wrap, which + // panics time.NewTicker or trips the breaker on the first failure. + for name, v := range map[string]int{ + "health check interval": rc.HealthCheckInterval, + "circuit breaker timeout": rc.CircuitBreakerTimeout, + } { + if int64(v) > maxDurationSeconds { + return fmt.Errorf("%s is too large; must be at most %d seconds", name, maxDurationSeconds) + } + } + if rc.CircuitBreakerThreshold > math.MaxInt32 { + return fmt.Errorf("circuit breaker threshold is too large; must be at most %d", math.MaxInt32) + } + return nil } diff --git a/settings_duration_bounds_test.go b/settings_duration_bounds_test.go new file mode 100644 index 0000000..0c339d3 --- /dev/null +++ b/settings_duration_bounds_test.go @@ -0,0 +1,73 @@ +package traefikoidc + +import ( + "math" + "testing" + "time" +) + +func boundsTestConfig() *Config { + cfg := CreateConfig() + cfg.ProviderURL = "https://auth.example.com" + cfg.CallbackURL = "/callback" + cfg.ClientID = "test-client" + cfg.ClientSecret = "test-client-secret" + cfg.SessionEncryptionKey = "test-encryption-key-32-bytes-long!!" + return cfg +} + +const maxWholeSeconds = math.MaxInt64 / int64(time.Second) + +func TestConfigValidateRejectsDurationOverflow(t *testing.T) { + cases := map[string]func(c *Config){ + "maxTokenAgeSeconds wraps negative": func(c *Config) { c.MaxTokenAgeSeconds = maxWholeSeconds + 1 }, + "maxTokenAgeSeconds wraps to zero": func(c *Config) { c.MaxTokenAgeSeconds = 1 << 55 }, + "bearerFailureWindowSeconds": func(c *Config) { c.BearerFailureWindowSeconds = int(maxWholeSeconds + 1) }, + "bearerFailurePenaltySeconds": func(c *Config) { c.BearerFailurePenaltySeconds = int(maxWholeSeconds + 1) }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + cfg := boundsTestConfig() + mutate(cfg) + if err := cfg.Validate(); err == nil { + t.Fatal("Validate accepted an overflowing duration") + } + }) + } + t.Run("max exact value still valid", func(t *testing.T) { + cfg := boundsTestConfig() + cfg.MaxTokenAgeSeconds = maxWholeSeconds + if err := cfg.Validate(); err != nil { + t.Fatalf("boundary value rejected: %v", err) + } + }) +} + +func TestRedisConfigValidateRejectsOverflowAndHugePool(t *testing.T) { + base := func() *RedisConfig { + rc := &RedisConfig{Enabled: true, Address: "localhost:6379"} + return rc + } + cases := map[string]func(rc *RedisConfig){ + "healthCheckInterval wraps to zero": func(rc *RedisConfig) { rc.HealthCheckInterval = 1 << 55 }, + "circuitBreakerTimeout wraps": func(rc *RedisConfig) { rc.CircuitBreakerTimeout = int(maxWholeSeconds + 1) }, + "circuitBreakerThreshold narrows": func(rc *RedisConfig) { rc.CircuitBreakerThreshold = math.MaxInt32 + 1 }, + "poolSize absurd": func(rc *RedisConfig) { rc.PoolSize = 1_000_000_000 }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + rc := base() + mutate(rc) + if err := rc.Validate(); err == nil { + t.Fatal("Validate accepted an unsafe value") + } + }) + } + t.Run("defaults and sane values stay valid", func(t *testing.T) { + rc := base() + rc.PoolSize, rc.CircuitBreakerThreshold, rc.HealthCheckInterval = 50, 5, 30 + if err := rc.Validate(); err != nil { + t.Fatalf("sane config rejected: %v", err) + } + }) +}