Skip to content

Commit 1d0e21c

Browse files
Snidercodex
andcommitted
docs(cache): threat-model audit Section 3 (TOCTOU + invalidation race) (#924 Cerberus)
Filled Section 3 of threats.md with Question/Finding/Severity/Repro test/Fix for all three concerns: 1. Invalidate vs OnInvalidate concurrent registration 2. TTL expiry race on concurrent Get 3. Get-then-Set caller-side TOCTOU Added threat-named race-stress tests in cache_test.go covering invalidation snapshot, concurrent expired Get, and get-then-set race probe. Tests pass under -race -count=10. No cache.go hardening needed — analysis showed existing locking is correct. Closes tasks.lthn.sh/view.php?id=924 Co-authored-by: Codex <noreply@openai.com>
1 parent 8c31450 commit 1d0e21c

2 files changed

Lines changed: 153 additions & 3 deletions

File tree

cache_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"os"
1818
"runtime"
1919
"sync"
20+
"sync/atomic"
2021
"testing"
2122
"time"
2223

@@ -2684,6 +2685,143 @@ func TestCache_ThreatPathTraversal_HTTPCacheUsesHashedRequestStorageKeys(t *test
26842685
}
26852686
}
26862687

2688+
func TestCache_ThreatTOCTOU_InvalidateOnInvalidateRegistrationIsSnapshotRaceClean(t *testing.T) {
2689+
c, _ := newTestCache(t, "/tmp/cache-threat-invalidate-snapshot", time.Minute)
2690+
2691+
if err := c.Set("victim", "old"); err != nil {
2692+
t.Fatalf("Set victim failed: %v", err)
2693+
}
2694+
if err := c.Set("late", "new"); err != nil {
2695+
t.Fatalf("Set late failed: %v", err)
2696+
}
2697+
2698+
var registerOnce sync.Once
2699+
var lateCalls int64
2700+
c.OnInvalidate("reload", func(trigger string) []string {
2701+
registerOnce.Do(func() {
2702+
c.OnInvalidate(trigger, func(string) []string {
2703+
atomic.AddInt64(&lateCalls, 1)
2704+
return []string{"late"}
2705+
})
2706+
})
2707+
runtime.Gosched()
2708+
return []string{"victim"}
2709+
})
2710+
2711+
deleted, err := c.Invalidate("reload")
2712+
if err != nil {
2713+
t.Fatalf("Invalidate failed: %v", err)
2714+
}
2715+
if deleted != 1 {
2716+
t.Fatalf("expected first invalidation to delete snapshot callback match only, got %d", deleted)
2717+
}
2718+
if got := atomic.LoadInt64(&lateCalls); got != 0 {
2719+
t.Fatalf("newly registered callback should not run in same invalidation pass, got %d calls", got)
2720+
}
2721+
2722+
deleted, err = c.Invalidate("reload")
2723+
if err != nil {
2724+
t.Fatalf("second Invalidate failed: %v", err)
2725+
}
2726+
if deleted != 1 {
2727+
t.Fatalf("expected second invalidation to delete late callback match, got %d", deleted)
2728+
}
2729+
if got := atomic.LoadInt64(&lateCalls); got != 1 {
2730+
t.Fatalf("expected late callback to run once on next invalidation pass, got %d calls", got)
2731+
}
2732+
}
2733+
2734+
func TestCache_ThreatTOCTOU_InvalidateConcurrentRegistrationRaceClean(t *testing.T) {
2735+
c, _ := newTestCache(t, "/tmp/cache-threat-invalidate-race", time.Minute)
2736+
c.OnInvalidate("reload", func(string) []string {
2737+
runtime.Gosched()
2738+
return nil
2739+
})
2740+
2741+
const workers = 16
2742+
const registrationsPerWorker = 16
2743+
start := make(chan struct{})
2744+
errCh := make(chan error, workers)
2745+
2746+
var done sync.WaitGroup
2747+
done.Add(workers * 2)
2748+
for range workers {
2749+
go func() {
2750+
defer done.Done()
2751+
<-start
2752+
for range registrationsPerWorker {
2753+
c.OnInvalidate("reload", func(string) []string {
2754+
runtime.Gosched()
2755+
return nil
2756+
})
2757+
}
2758+
}()
2759+
}
2760+
for range workers {
2761+
go func() {
2762+
defer done.Done()
2763+
<-start
2764+
for range registrationsPerWorker {
2765+
if _, err := c.Invalidate("reload"); err != nil {
2766+
errCh <- err
2767+
return
2768+
}
2769+
}
2770+
}()
2771+
}
2772+
2773+
close(start)
2774+
done.Wait()
2775+
close(errCh)
2776+
2777+
for err := range errCh {
2778+
t.Errorf("Invalidate failed: %v", err)
2779+
}
2780+
}
2781+
2782+
func TestCache_ThreatTOCTOU_ExpiredGetConcurrentReadersReturnNotFound(t *testing.T) {
2783+
c, _ := newTestCache(t, "/tmp/cache-threat-expired-get", time.Minute)
2784+
if err := c.SetWithTTL("ttl/race", map[string]string{"state": "expired"}, time.Nanosecond); err != nil {
2785+
t.Fatalf("SetWithTTL failed: %v", err)
2786+
}
2787+
time.Sleep(2 * time.Millisecond)
2788+
2789+
const readers = 64
2790+
start := make(chan struct{})
2791+
errCh := make(chan string, readers)
2792+
2793+
var done sync.WaitGroup
2794+
done.Add(readers)
2795+
for range readers {
2796+
go func() {
2797+
defer done.Done()
2798+
<-start
2799+
2800+
got := map[string]string{"state": "sentinel"}
2801+
found, err := c.Get("ttl/race", &got)
2802+
if err != nil {
2803+
errCh <- err.Error()
2804+
return
2805+
}
2806+
if found {
2807+
errCh <- "expected expired Get to return found=false"
2808+
return
2809+
}
2810+
if got["state"] != "sentinel" {
2811+
errCh <- "expired Get unmarshaled stale data into destination"
2812+
}
2813+
}()
2814+
}
2815+
2816+
close(start)
2817+
done.Wait()
2818+
close(errCh)
2819+
2820+
for msg := range errCh {
2821+
t.Error(msg)
2822+
}
2823+
}
2824+
26872825
func TestCache_ThreatTOCTOU_GetThenSetSerializesEntryWrites(t *testing.T) {
26882826
medium := &raceProbeMedium{MockMedium: coreio.NewMockMedium()}
26892827
c, err := cache.New(medium, "/tmp/cache-threat-toctou", time.Minute)

threats.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,26 +74,38 @@ Status: Complete
7474

7575
Question: What lock is held while `Invalidate` walks callbacks, and can `OnInvalidate` append to the same trigger while that walk is in progress?
7676

77-
Finding: No map-walk race found. `Cache.mu` is the lock protecting the invalidation callback map (`cache.go:50`). `OnInvalidate` takes the write lock before appending to `cache.invalidation[trigger]` (`cache.go:696`, `cache.go:698`). `Invalidate` takes the read lock only long enough to copy the trigger's callback slice, then releases the lock before executing callbacks and deleting entries (`cache.go:709`, `cache.go:710`, `cache.go:711`, `cache.go:713`). A callback that registers more invalidations therefore cannot mutate the map while it is being read. The newly registered callback is not included in the already-snapshotted invalidation pass, which is acceptable snapshot semantics. No `delete(c.invalidation, trigger)` call exists in the reviewed cache implementation.
77+
Finding: No map-walk race found. `Cache.mu` is the lock protecting the invalidation callback map (`cache.go:56`). `OnInvalidate` takes the write lock before appending to `cache.invalidation[trigger]` (`cache.go:818`, `cache.go:820`). `Invalidate` takes the read lock only long enough to copy the trigger's callback slice, then releases the lock before executing callbacks and deleting entries (`cache.go:831`, `cache.go:832`, `cache.go:833`, `cache.go:835`). A callback that registers more invalidations therefore cannot mutate the map while it is being read, and it does not deadlock by trying to acquire the write lock from inside the callback. The newly registered callback is not included in the already-snapshotted invalidation pass, which is acceptable snapshot semantics. No `delete(cache.invalidation, trigger)` call exists in the reviewed cache implementation.
7878

7979
Severity: None.
8080

81+
Repro test: `TestCache_ThreatTOCTOU_InvalidateOnInvalidateRegistrationIsSnapshotRaceClean` and `TestCache_ThreatTOCTOU_InvalidateConcurrentRegistrationRaceClean` (`cache_test.go:2688`, `cache_test.go:2734`).
82+
83+
Fix: No code change required. The existing callback snapshot under `Cache.mu` is the intended mitigation; the added tests pin the race-clean and snapshot semantics.
84+
8185
### 3.2 TTL expiry race on Get
8286

8387
Status: Complete
8488

8589
Question: Can two concurrent readers of a freshly expired entry return expired data, or does one reader delete/alter state out from under the other?
8690

87-
Finding: No unsafe TTL expiry race found. `Get` reads the entry under the entry read lock, unmarshals the cache envelope, checks `time.Now().After(entry.ExpiresAt)`, and returns `found=false` before unmarshalling cached data into the caller's destination (`cache.go:178`, `cache.go:186`, `cache.go:195`, `cache.go:200`, `cache.go:201`, `cache.go:204`). `GetBinary` follows the same metadata-first expiry check and returns `found=false` before reading the payload body (`cache.go:439`, `cache.go:445`, `cache.go:446`, `cache.go:449`). Expired reads do not delete files, so two readers can both lose and safely return not-found; neither path returns expired data after observing the expiry check.
91+
Finding: No unsafe TTL expiry race found. `Get` reads the entry under the entry read lock, unmarshals the cache envelope, checks `time.Now().After(entry.ExpiresAt)`, and returns `found=false` before unmarshalling cached data into the caller's destination (`cache.go:300`, `cache.go:317`, `cache.go:322`, `cache.go:323`, `cache.go:326`). `GetBinary` follows the same metadata-first expiry check and returns `found=false` before reading the payload body (`cache.go:545`, `cache.go:562`, `cache.go:567`, `cache.go:568`, `cache.go:571`). Expired reads do not delete files, so two readers can both lose and safely return not-found; neither path returns expired data after observing the expiry check.
8892

8993
Severity: None.
9094

95+
Repro test: `TestCache_ThreatTOCTOU_ExpiredGetConcurrentReadersReturnNotFound` (`cache_test.go:2782`).
96+
97+
Fix: No code change required. The current metadata-first expiry check and non-mutating expired-read behavior are safe for concurrent readers.
98+
9199
### 3.3 Get-then-Set caller-site TOCTOU
92100

93101
Status: Complete
94102

95103
Question: If two consumers both observe `Get` as missing or expired and then both call `Set`, does `Cache.mu` serialize the writes, or is this just last-writer-wins cache behavior?
96104

97-
Finding: Yes, fixed. `Cache.mu` protects invalidation callback registration and snapshotting, while `entryMu` now serializes cache entry I/O separately (`cache.go:50`, `cache.go:51`). `Get` and `GetBinary` take `entryMu.RLock` while reading entries (`cache.go:178`, `cache.go:423`). `Set` and `SetBinary` take `entryMu.Lock` across path resolution, rollback snapshot, and writes (`cache.go:238`, `cache.go:246`, `cache.go:279`, `cache.go:280`, `cache.go:360`, `cache.go:368`, `cache.go:401`, `cache.go:407`). Delete paths are also serialized: single-key removal locks before deleting metadata and binary sidecars, `DeleteMany` locks across its batch, and invalidation pattern listing takes the entry read lock while walking keys (`cache.go:306`, `cache.go:315`, `cache.go:323`, `cache.go:469`, `cache.go:486`, `cache.go:550`, `cache.go:553`). Pure cache freshness remains last-writer-wins, but callers no longer need the backing `coreio.Medium` to tolerate overlapping entry operations. Regression coverage: `TestCache_ThreatTOCTOU_GetThenSetSerializesEntryWrites` (`cache_test.go:2668`).
105+
Finding: Yes, fixed. `Cache.mu` protects invalidation callback registration and snapshotting, while `entryMu` serializes cache entry I/O separately (`cache.go:56`, `cache.go:57`). `Get` and `GetBinary` take `entryMu.RLock` while reading entries (`cache.go:300`, `cache.go:545`). `Set` and `SetBinary` take `entryMu.Lock` across path resolution, rollback snapshot, and writes (`cache.go:360`, `cache.go:368`, `cache.go:401`, `cache.go:482`, `cache.go:490`, `cache.go:494`, `cache.go:523`, `cache.go:529`). Delete paths are also serialized: single-key removal locks before deleting metadata and binary sidecars, `DeleteMany` locks across its batch, and invalidation pattern listing takes the entry read lock while walking keys (`cache.go:428`, `cache.go:431`, `cache.go:591`, `cache.go:667`, `cache.go:672`, `cache.go:675`). Pure cache freshness remains last-writer-wins, but callers no longer need the backing `coreio.Medium` to tolerate overlapping entry operations.
98106

99107
Severity: Medium before fix; mitigated by entry-level serialization.
108+
109+
Repro test: `TestCache_ThreatTOCTOU_GetThenSetSerializesEntryWrites` (`cache_test.go:2825`).
110+
111+
Fix: Use `entryMu` for cache entry I/O so concurrent caller-side `Get`-then-`Set` misses cannot overlap backing-medium writes. This preserves last-writer-wins cache semantics while removing the lower-level I/O race.

0 commit comments

Comments
 (0)