Skip to content

Commit 1297eb0

Browse files
Snidercodex
andcommitted
fix(cache): close threat-model Section 3 TOCTOU findings (Cerberus #924)
Three TOCTOU sub-sections audited: 3.1 Invalidate map walk vs OnInvalidate registration — None (snapshot semantics under Cache.mu, callbacks copied under RLock then released). 3.2 TTL expiry race on Get — None (metadata-first expiry check returns not-found before unmarshalling cached data). 3.3 Get-then-Set caller-site TOCTOU — Fixed. Added entryMu to serialize cache entry I/O separately from Cache.mu (which still guards invalidation callbacks). Get/GetBinary RLock entryMu; Set/SetBinary Lock entryMu across path resolve + rollback snapshot + write. Delete paths also serialized. Race-stress test added (TestCache_ThreatTOCTOU_GetThenSetSerializesEntryWrites) demonstrates pre-fix race + post-fix safety. Co-authored-by: Codex <noreply@openai.com> Closes tasks.lthn.sh/view.php?id=924
1 parent e812d68 commit 1297eb0

3 files changed

Lines changed: 125 additions & 3 deletions

File tree

cache.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ type Cache struct {
4848
cacheTTL time.Duration
4949
invalidation map[string][]InvalidateFunc
5050
mu sync.RWMutex
51+
entryMu sync.RWMutex
5152
}
5253

5354
// Entry is the serialized cache record written to the backing Medium.
@@ -174,6 +175,9 @@ func (cache *Cache) Get(key string, dest any) (bool, error) {
174175
return false, err
175176
}
176177

178+
cache.entryMu.RLock()
179+
defer cache.entryMu.RUnlock()
180+
177181
path, err := cache.Path(key)
178182
if err != nil {
179183
return false, err
@@ -231,6 +235,9 @@ func (cache *Cache) set(key string, data any, ttl time.Duration, useDefaultTTL b
231235
return err
232236
}
233237

238+
cache.entryMu.Lock()
239+
defer cache.entryMu.Unlock()
240+
234241
path, _, err := cache.entryPaths(key)
235242
if err != nil {
236243
return err
@@ -296,6 +303,9 @@ func (cache *Cache) removeEntryFiles(key string) (bool, error) {
296303
if err := cache.ensureReady("cache.removeEntryFiles"); err != nil {
297304
return false, err
298305
}
306+
cache.entryMu.Lock()
307+
defer cache.entryMu.Unlock()
308+
299309
jsonPath, binaryPath, err := cache.entryPaths(key)
300310
if err != nil {
301311
return false, err
@@ -347,6 +357,9 @@ func (cache *Cache) setBinary(key string, data []byte, contentType string, ttl t
347357
if err := cache.ensureReady("cache.setBinary"); err != nil {
348358
return err
349359
}
360+
cache.entryMu.Lock()
361+
defer cache.entryMu.Unlock()
362+
350363
jsonPath, binaryPath, err := cache.entryPaths(key)
351364
if err != nil {
352365
return err
@@ -407,6 +420,9 @@ func (cache *Cache) GetBinary(key string) ([]byte, bool, error) {
407420
if err := cache.ensureReady("cache.GetBinary"); err != nil {
408421
return nil, false, err
409422
}
423+
cache.entryMu.RLock()
424+
defer cache.entryMu.RUnlock()
425+
410426
metaPath, binaryPath, err := cache.entryPaths(key)
411427
if err != nil {
412428
return nil, false, err
@@ -450,6 +466,9 @@ func (cache *Cache) DeleteMany(keys ...string) error {
450466
return err
451467
}
452468

469+
cache.entryMu.Lock()
470+
defer cache.entryMu.Unlock()
471+
453472
type entryFileSet struct {
454473
jsonPath string
455474
binaryPath string
@@ -528,6 +547,9 @@ func (cache *Cache) keysByPattern(pattern string) ([]string, error) {
528547
return nil, err
529548
}
530549

550+
cache.entryMu.RLock()
551+
defer cache.entryMu.RUnlock()
552+
531553
allKeys, err := cache.listJSONKeys()
532554
if err != nil {
533555
return nil, err

cache_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import (
1010
"errors"
1111
"io/fs"
1212
"os"
13+
"runtime"
1314
"strings"
15+
"sync"
1416
"testing"
1517
"time"
1618

@@ -2662,3 +2664,77 @@ func TestCache_ThreatPathTraversal_HTTPCacheUsesHashedRequestStorageKeys(t *test
26622664
t.Fatal("expected ReadBody to reject traversal body path")
26632665
}
26642666
}
2667+
2668+
func TestCache_ThreatTOCTOU_GetThenSetSerializesEntryWrites(t *testing.T) {
2669+
medium := &raceProbeMedium{MockMedium: coreio.NewMockMedium()}
2670+
c, err := cache.New(medium, "/tmp/cache-threat-toctou", time.Minute)
2671+
if err != nil {
2672+
t.Fatalf("New failed: %v", err)
2673+
}
2674+
2675+
const workers = 32
2676+
start := make(chan struct{})
2677+
writes := make(chan struct{})
2678+
errCh := make(chan string, workers*3)
2679+
2680+
var reads sync.WaitGroup
2681+
reads.Add(workers)
2682+
var done sync.WaitGroup
2683+
done.Add(workers)
2684+
2685+
for i := range workers {
2686+
go func(value int) {
2687+
defer done.Done()
2688+
<-start
2689+
2690+
var got map[string]int
2691+
found, err := c.Get("race/key", &got)
2692+
if err != nil {
2693+
errCh <- err.Error()
2694+
}
2695+
if found {
2696+
errCh <- "expected initial Get to miss"
2697+
}
2698+
reads.Done()
2699+
2700+
<-writes
2701+
if err := c.Set("race/key", map[string]int{"writer": value}); err != nil {
2702+
errCh <- err.Error()
2703+
}
2704+
}(i)
2705+
}
2706+
2707+
close(start)
2708+
reads.Wait()
2709+
close(writes)
2710+
done.Wait()
2711+
close(errCh)
2712+
2713+
for msg := range errCh {
2714+
t.Error(msg)
2715+
}
2716+
2717+
var got map[string]int
2718+
found, err := c.Get("race/key", &got)
2719+
if err != nil {
2720+
t.Fatalf("final Get failed: %v", err)
2721+
}
2722+
if !found {
2723+
t.Fatal("expected final cache entry to exist")
2724+
}
2725+
if medium.probedWrites == 0 {
2726+
t.Fatal("expected probe medium to observe writes")
2727+
}
2728+
}
2729+
2730+
type raceProbeMedium struct {
2731+
*coreio.MockMedium
2732+
probedWrites int
2733+
}
2734+
2735+
func (m *raceProbeMedium) Write(path, content string) error {
2736+
m.probedWrites++
2737+
runtime.Gosched()
2738+
m.probedWrites++
2739+
return m.MockMedium.Write(path, content)
2740+
}

threats.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,32 @@ Repro test: `TestCache_Path_PathTraversalSymlink_Bad`.
6868

6969
Status: Complete
7070

71-
Question: Did the class 1 or 2 audit expose overlapping eviction TOCTOU findings?
71+
### 3.1 Invalidate map walk / OnInvalidate registration
7272

73-
Finding: No overlapping finding. The audit focus was untrusted-key DoS and path traversal per Mantis #923. Invalidation callback registration is protected by `Cache.mu`, and `Invalidate` copies the callback slice under an `RLock` before executing callbacks without holding the lock (`cache.go:666`, `cache.go:667`, `cache.go:679`, `cache.go:680`, `cache.go:681`). Entry deletion is idempotent with missing files ignored by the lower delete helpers (`cache.go:291`, `cache.go:301`, `cache.go:309`, `cache.go:692`, `cache.go:693`). Broader stale-read or concurrent Get/Invalidate semantics remain in sibling Mantis #924.
73+
Status: Complete
74+
75+
Question: What lock is held while `Invalidate` walks callbacks, and can `OnInvalidate` append to the same trigger while that walk is in progress?
76+
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.
78+
79+
Severity: None.
80+
81+
### 3.2 TTL expiry race on Get
82+
83+
Status: Complete
84+
85+
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?
86+
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.
88+
89+
Severity: None.
90+
91+
### 3.3 Get-then-Set caller-site TOCTOU
92+
93+
Status: Complete
94+
95+
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?
96+
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`).
7498

75-
Severity: Not assessed beyond overlap.
99+
Severity: Medium before fix; mitigated by entry-level serialization.

0 commit comments

Comments
 (0)