R68: cache expiry TOCTOU, logout open-redirect, session max-age

This commit is contained in:
2026-08-13 17:06:23 +01:00
parent 2941260a81
commit d5fddebeaa
8 changed files with 223 additions and 19 deletions
+4 -2
View File
@@ -179,8 +179,10 @@ func (m *MemoryCacheBackend) Get(ctx context.Context, key string) (interface{},
value, exists, expired := shard.get(key)
if expired {
// Clean up expired item
shard.delete(key)
// Clean up expired item. Use deleteIfExpired so a concurrent Set
// that refreshed this key between get() and here is not deleted
// (which would drop a freshly-written value = lost update).
shard.deleteIfExpired(key)
m.misses.Add(1)
return nil, ErrCacheMiss
}
+18
View File
@@ -117,6 +117,24 @@ func (s *cacheShard) delete(key string) bool {
return true
}
// deleteIfExpired removes a key only if the entry still present is expired.
// It is safe to call after observing an expired item via get(): a
// concurrent set() that refreshed the key between the read and this call
// leaves the fresh value intact. Unlike delete, it avoids deleting a
// just-written value (a lost update in Get's expiry cleanup).
func (s *cacheShard) deleteIfExpired(key string) bool {
s.mu.Lock()
defer s.mu.Unlock()
item, exists := s.items[key]
if !exists || !item.isExpired() {
return false
}
s.deleteItemLocked(item)
return true
}
// exists checks if a key exists (and is not expired)
func (s *cacheShard) exists(key string) bool {
s.mu.RLock()
+55
View File
@@ -781,3 +781,58 @@ func TestMatchPattern(t *testing.T) {
})
}
}
// TestMemoryShard_DeleteIfExpiredPreservesConcurrentRefresh regresses a
// TOCTOU in MemoryCacheBackend.Get's expired-item cleanup. Get reads an
// item, sees it expired (shard.get), then was deleting it with an
// unconditional shard.delete in a separate critical section. If a
// concurrent Set refreshed the key between the read and the delete, the
// delete removed the freshly-written value (a lost update — the key
// vanished right after it was refreshed). Get now uses deleteIfExpired,
// which deletes only if the entry is still expired, leaving a concurrent
// refresh intact. This test models that interleaving deterministically.
func TestMemoryShard_DeleteIfExpiredPreservesConcurrentRefresh(t *testing.T) {
s := newCacheShard(0, 0)
const key = "k"
// Get's first phase: observe an already-expired entry.
s.set(key, "old", time.Now().Add(-time.Second), 1)
if _, _, expired := s.get(key); !expired {
t.Fatal("precondition: seeded item must be expired")
}
// A concurrent refresh lands a fresh value inside the get->delete window.
s.set(key, "fresh", time.Now().Add(time.Hour), 1)
// Get's cleanup must not remove the fresh value.
if s.deleteIfExpired(key) {
t.Fatal("deleteIfExpired removed a value a concurrent Set just wrote")
}
if v, _, _ := s.get(key); v != "fresh" {
t.Fatalf("fresh value was lost by expiry cleanup, got %v", v)
}
}
// TestMemoryShard_DeleteClobbersConcurrentRefresh documents the failure
// mode the fix addresses: the old unconditional delete removes the value
// a concurrent Set just wrote under the same interleaving. If
// deleteIfExpired ever regresses to unconditional deletion, the sibling
// test above fails.
func TestMemoryShard_DeleteClobbersConcurrentRefresh(t *testing.T) {
s := newCacheShard(0, 0)
const key = "k"
s.set(key, "old", time.Now().Add(-time.Second), 1)
if _, _, expired := s.get(key); !expired {
t.Fatal("precondition: seeded item must be expired")
}
s.set(key, "fresh", time.Now().Add(time.Hour), 1)
// Old cleanup (delete) removes the just-written value: lost update.
if !s.delete(key) {
t.Log("old delete found no item")
}
if v, _, _ := s.get(key); v == "fresh" {
t.Log("note: value happened to survive, but delete is unconditional")
}
}