diff --git a/jwt.go b/jwt.go index a355130..875e4da 100644 --- a/jwt.go +++ b/jwt.go @@ -7,6 +7,7 @@ import ( "crypto/rsa" "encoding/base64" "fmt" + "math" "math/big" "strings" "time" @@ -231,6 +232,9 @@ func verifyIssuer(tokenIssuer, expectedIssuer string) error { return nil } +// maxClaimUnix is 9999-12-31T23:59:59Z, the largest time claim accepted. +const maxClaimUnix = 253402300799 + // verifyTimeConstraint validates time-based JWT claims with clock skew tolerance. // It handles both future constraints (exp) and past constraints (iat, nbf). // Parameters: @@ -241,6 +245,10 @@ func verifyIssuer(tokenIssuer, expectedIssuer string) error { // Returns: // - An error describing the failure (e.g., "token has expired", "token used before issued") func verifyTimeConstraint(unixTime float64, claimName string, future bool) error { + // int64(float64) wraps out of range, which put a far-future nbf/iat in the past. + if math.IsNaN(unixTime) || math.IsInf(unixTime, 0) || math.Abs(unixTime) > maxClaimUnix { + return fmt.Errorf("token %s claim out of range", claimName) + } claimTime := time.Unix(int64(unixTime), 0) now := time.Now() diff --git a/universal_cache.go b/universal_cache.go index 8f491fa..d76a66e 100644 --- a/universal_cache.go +++ b/universal_cache.go @@ -380,6 +380,10 @@ func (c *UniversalCache) setLocalLocked(key string, value interface{}, ttl time. growth = size - existing.Size } + if c.rejectOversized(key, size) { + return + } + // Check memory limits if c.config.MaxMemoryBytes > 0 { // Evict items if necessary to make room @@ -712,6 +716,9 @@ func (c *UniversalCache) getLocal(key string) (interface{}, bool) { if gracePeriod, ok := item.Metadata["grace_period_active"].(bool); ok && gracePeriod { if c.shouldExtendGracePeriod(item, now) { newExpiry := c.calculateNewExpiry(item, now) + if _, set := item.Metadata[graceOriginKey]; !set { + item.Metadata[graceOriginKey] = item.ExpiresAt + } item.ExpiresAt = newExpiry c.logger.Infof("UniversalCache[%s]: Extended grace period for key=%s until %v", c.config.Type, key, newExpiry) @@ -915,11 +922,29 @@ func (c *UniversalCache) evictOldest() { } } +// rejectOversized drops a value larger than the whole memory cap instead of +// flushing every other entry to fit it. A stale copy under the key is removed. +// Caller holds c.mu. +func (c *UniversalCache) rejectOversized(key string, size int64) bool { + if c.config.MaxMemoryBytes <= 0 || size <= c.config.MaxMemoryBytes { + return false + } + if existing, ok := c.items[key]; ok { + c.removeItem(key, existing) + } + c.logger.Infof("UniversalCache[%s]: dropped oversized value key=%s size=%d cap=%d", + c.config.Type, key, size, c.config.MaxMemoryBytes) + return true +} + // SetMaxSize sets the maximum size and evicts items if necessary func (c *UniversalCache) SetMaxSize(newSize int) { c.mu.Lock() defer c.mu.Unlock() + if newSize < 1 { + newSize = 1 // a non-positive bound would spin the eviction loop forever + } oldSize := c.config.MaxSize c.config.MaxSize = newSize @@ -1021,6 +1046,9 @@ func (c *UniversalCache) estimateSize(value interface{}) int64 { } } +// graceOriginKey holds the item's first expiry once grace has started. +const graceOriginKey = "grace_origin" + // shouldExtendGracePeriod determines if grace period should be extended func (c *UniversalCache) shouldExtendGracePeriod(item *CacheItem, now time.Time) bool { if c.config.MetadataConfig == nil { @@ -1040,8 +1068,12 @@ func (c *UniversalCache) shouldExtendGracePeriod(item *CacheItem, now time.Time) } } - // Calculate how long since the item originally expired - timeSinceExpiry := now.Sub(item.ExpiresAt) + // Measure from the first expiry: later extensions overwrite ExpiresAt. + origin := item.ExpiresAt + if o, ok := item.Metadata[graceOriginKey].(time.Time); ok { + origin = o + } + timeSinceExpiry := now.Sub(origin) return timeSinceExpiry <= maxGrace } @@ -1523,6 +1555,10 @@ func (c *UniversalCache) updateLocalCache(key string, value interface{}, ttl tim growth = size - existing.Size } + if c.rejectOversized(key, size) { + return nil + } + // Check memory limits if c.config.MaxMemoryBytes > 0 { for c.currentMemory+growth > c.config.MaxMemoryBytes && c.lruList.Len() > 0 { diff --git a/universal_cache_bounds_test.go b/universal_cache_bounds_test.go new file mode 100644 index 0000000..1985aae --- /dev/null +++ b/universal_cache_bounds_test.go @@ -0,0 +1,88 @@ +package traefikoidc + +import ( + "math" + "testing" + "time" +) + +func newBoundsCache(t *testing.T, cfg UniversalCacheConfig) *UniversalCache { + t.Helper() + cfg.CleanupInterval = time.Hour + if cfg.DefaultTTL == 0 { + cfg.DefaultTTL = time.Hour + } + c := NewUniversalCache(cfg) + t.Cleanup(func() { _ = c.Close() }) + return c +} + +// Grace extensions must stay within MaxGracePeriod of the ORIGINAL expiry. +func TestGraceNeverServesPastOriginalExpiryBound(t *testing.T) { + c := newBoundsCache(t, UniversalCacheConfig{ + Type: CacheTypeMetadata, MaxSize: 10, + MetadataConfig: &MetadataCacheConfig{ + GracePeriod: 20 * time.Millisecond, ExtendedGracePeriod: 20 * time.Millisecond, + MaxGracePeriod: 100 * time.Millisecond, SecurityCriticalMaxGracePeriod: 100 * time.Millisecond, + }, + }) + if err := c.Set("k", "v", 10*time.Millisecond); err != nil { + t.Fatal(err) + } + c.ActivateGracePeriod("k") + deadline := time.Now().Add(600 * time.Millisecond) + for time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + if _, ok := c.Get("k"); !ok { + return + } + } + t.Fatal("stale entry still served 600ms after a 100ms max grace") +} + +func TestSetMaxSizeNegativeTerminates(t *testing.T) { + c := newBoundsCache(t, UniversalCacheConfig{Type: CacheTypeGeneral, MaxSize: 10}) + for _, k := range []string{"a", "b", "c"} { + _ = c.Set(k, "v", time.Hour) + } + done := make(chan struct{}) + go func() { c.SetMaxSize(-1); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("SetMaxSize(-1) did not return") + } +} + +// One oversized value must not flush the other live entries. +func TestOversizedValueDoesNotFlushCache(t *testing.T) { + c := newBoundsCache(t, UniversalCacheConfig{ + Type: CacheTypeGeneral, MaxSize: 10, MaxMemoryBytes: 1000, EnableMemoryLimit: true, + }) + _ = c.Set("live", "x", time.Hour) + _ = c.Set("big", make([]byte, 5000), time.Hour) + if _, ok := c.Get("live"); !ok { + t.Fatal("oversized insert evicted a live entry") + } + c.mu.RLock() + mem := c.currentMemory + c.mu.RUnlock() + if mem > 1000 { + t.Fatalf("currentMemory=%d exceeds cap 1000", mem) + } +} + +// Out-of-range or non-finite nbf/iat must be rejected, not wrapped into the past. +func TestTimeClaimsRejectOutOfRange(t *testing.T) { + for _, v := range []float64{1e19, 1e300, math.Inf(1), math.Inf(-1), math.NaN()} { + if verifyNotBefore(v) == nil { + t.Errorf("nbf=%v accepted", v) + } + if verifyIssuedAt(v) == nil { + t.Errorf("iat=%v accepted", v) + } + if verifyExpiration(v) == nil { + t.Errorf("exp=%v accepted", v) + } + } +}