From 621f7993a8b651bb35275b351b6b368b6cf0537b Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Tue, 3 Nov 2026 00:37:12 +0000 Subject: [PATCH] fix(cache,jwt): bound grace, resize, memory cap and time claims Grace extensions overwrote ExpiresAt, so a polled stale entry never hit MaxGracePeriod. The bound now uses the first expiry. SetMaxSize with a non-positive size spun the eviction loop forever. It now clamps to 1. One value larger than MaxMemoryBytes flushed every other entry and left the cache over its cap. The cache now drops that value. int64(float64) wrapped a far-future nbf or iat into the past, so the token passed. Non-finite and out-of-range claims now fail. --- jwt.go | 8 ++++ universal_cache.go | 40 +++++++++++++++- universal_cache_bounds_test.go | 88 ++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 universal_cache_bounds_test.go 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) + } + } +}