Skip to content

Commit e812d68

Browse files
Snidercodex
andcommitted
fix(cache): close threat-model audit findings (Cerberus #923)
Three threat-classes audited: 1. Untrusted-key DoS — oversized keys bounded by validateKey path. Test added. 2. Path traversal — ScopedCache uses SHA-256 of origin (raw origins never embedded). HTTPCache uses SHA-256 of method+URL as storage key. Tests added. 3. Eviction TOCTOU — covered by sibling #924; this audit confirms boundaries. threats.md landed with question/finding/severity/line citations across all 3 sections. No new fixes required — existing defences hold; adds regression coverage. Co-authored-by: Codex <noreply@openai.com> Closes tasks.lthn.sh/view.php?id=923
1 parent 442cdf0 commit e812d68

2 files changed

Lines changed: 182 additions & 17 deletions

File tree

cache_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2546,3 +2546,119 @@ func TestCache_HTTPCache_Match_Bad(t *testing.T) {
25462546
t.Fatal("expected missing cached response to return nil")
25472547
}
25482548
}
2549+
2550+
func TestCache_ThreatUntrustedKeyDoS_RejectsOversizedKeysOnWritePaths(t *testing.T) {
2551+
c, medium := newTestCache(t, "/tmp/cache-threat-untrusted-key", time.Minute)
2552+
key := strings.Repeat("a", 4097)
2553+
2554+
tests := []struct {
2555+
name string
2556+
fn func() error
2557+
}{
2558+
{
2559+
name: "set",
2560+
fn: func() error {
2561+
return c.Set(key, "value")
2562+
},
2563+
},
2564+
{
2565+
name: "set-with-ttl",
2566+
fn: func() error {
2567+
return c.SetWithTTL(key, "value", time.Minute)
2568+
},
2569+
},
2570+
{
2571+
name: "set-binary",
2572+
fn: func() error {
2573+
return c.SetBinary(key, []byte("value"), "text/plain")
2574+
},
2575+
},
2576+
{
2577+
name: "set-binary-with-ttl",
2578+
fn: func() error {
2579+
return c.SetBinaryWithTTL(key, []byte("value"), "text/plain", time.Minute)
2580+
},
2581+
},
2582+
}
2583+
2584+
for _, tt := range tests {
2585+
t.Run(tt.name, func(t *testing.T) {
2586+
if err := tt.fn(); err == nil {
2587+
t.Fatalf("expected %s to reject oversized cache key", tt.name)
2588+
}
2589+
})
2590+
}
2591+
2592+
if len(medium.Files) != 0 {
2593+
t.Fatalf("oversized rejected keys should not write cache files, got %d", len(medium.Files))
2594+
}
2595+
}
2596+
2597+
func TestCache_ThreatPathTraversal_ScopedOriginIsHashedAndKeysStillValidated(t *testing.T) {
2598+
c, _ := newTestCache(t, "/tmp/cache-threat-scoped-path", time.Minute)
2599+
scoped := c.Scoped("../../evil\norigin")
2600+
if scoped == nil {
2601+
t.Fatal("expected scoped cache")
2602+
}
2603+
2604+
if err := scoped.Set("safe-key", "value"); err != nil {
2605+
t.Fatalf("scoped Set with hostile origin failed: %v", err)
2606+
}
2607+
path, err := scoped.Path("safe-key")
2608+
if err != nil {
2609+
t.Fatalf("scoped Path failed: %v", err)
2610+
}
2611+
if strings.Contains(path, "evil") || strings.Contains(path, "..") || strings.Contains(path, "\n") {
2612+
t.Fatalf("expected scoped path to omit raw origin, got %q", path)
2613+
}
2614+
2615+
if err := scoped.Set("../../escape", "value"); err == nil {
2616+
t.Fatal("expected scoped Set to reject traversal key")
2617+
}
2618+
}
2619+
2620+
func TestCache_ThreatPathTraversal_HTTPCacheUsesHashedRequestStorageKeys(t *testing.T) {
2621+
medium := coreio.NewMockMedium()
2622+
storage, err := cache.NewCacheStorage(medium, "/tmp/cache-threat-http-path")
2623+
if err != nil {
2624+
t.Fatalf("NewCacheStorage failed: %v", err)
2625+
}
2626+
2627+
httpCache, err := storage.Open("assets")
2628+
if err != nil {
2629+
t.Fatalf("Open failed: %v", err)
2630+
}
2631+
2632+
req := cache.CachedRequest{
2633+
URL: "https://example.com/../../secret.css?file=../secret",
2634+
Method: "GET",
2635+
}
2636+
resp := cache.CachedResponse{Status: 200, StatusText: "OK"}
2637+
if err := httpCache.Put(req, resp, []byte("body")); err != nil {
2638+
t.Fatalf("Put failed: %v", err)
2639+
}
2640+
2641+
key := httpCacheStorageKey(req)
2642+
if _, ok := medium.Files["/tmp/cache-threat-http-path/assets/responses/"+key+".json"]; !ok {
2643+
t.Fatal("expected HTTP metadata to be stored under hashed request key")
2644+
}
2645+
if _, ok := medium.Files["/tmp/cache-threat-http-path/assets/responses/"+key+".bin"]; !ok {
2646+
t.Fatal("expected HTTP body to be stored under hashed request key")
2647+
}
2648+
for path := range medium.Files {
2649+
if strings.Contains(path, "..") || strings.Contains(path, "secret.css") {
2650+
t.Fatalf("expected stored path to omit raw request URL, got %q", path)
2651+
}
2652+
}
2653+
2654+
matched, err := httpCache.Match(req)
2655+
if err != nil {
2656+
t.Fatalf("Match failed: %v", err)
2657+
}
2658+
if matched == nil {
2659+
t.Fatal("expected cached response to match")
2660+
}
2661+
if _, err := httpCache.ReadBody(&cache.CachedResponse{BodyPath: "../../escape"}); err == nil {
2662+
t.Fatal("expected ReadBody to reject traversal body path")
2663+
}
2664+
}

threats.md

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,71 @@ Repo: dappco.re/go/cache
55
Date: 2026-04-25
66

77
## 1. Untrusted-key DoS
8-
**Question:** Does every public method (Set, Get, Delete, SetWithTTL, SetBinary, SetBinaryWithTTL, GetBinary, DeleteMany, Clear, ClearScope, Path, Scoped, OnInvalidate, Invalidate) reach `ensureSafeKey` before touching the filesystem? Are there backstops on key length, ScopedCache prefix escape, glob-pattern blowup?
9-
**Finding:** YES - cache keys are bounded and public key-taking methods route through `Path`/`entryPaths` before filesystem access, and scoped prefixes are hashed plus revalidated. However, `Invalidate` accepted callback-returned glob patterns without a length backstop before `keysByPattern` listed and matched all cache keys.
10-
**Severity:** medium
11-
**Repro test:** TestCache_Invalidate_UntrustedPatternLength_Bad
12-
**Fix:** validate invalidation patterns with a fixed byte limit before listing cache entries.
8+
9+
Status: Complete
10+
11+
Question: Are key lengths bounded on the externally reachable write paths?
12+
13+
Finding: Yes. `Cache.Path` validates every key with `ensureSafeKey` before constructing storage paths (`cache.go:131`, `cache.go:136`). That helper rejects empty keys, keys longer than 4096 bytes, backslashes, control bytes, empty path segments, `.`, and `..` (`cache.go:743`, `cache.go:747`, `cache.go:750`, `cache.go:753`, `cache.go:757`). `Set`, `SetWithTTL`, `SetBinary`, and `SetBinaryWithTTL` all route through `entryPaths` and therefore through `Path` before writing (`cache.go:207`, `cache.go:218`, `cache.go:225`, `cache.go:230`, `cache.go:324`, `cache.go:335`, `cache.go:342`, `cache.go:346`). Regression coverage: `TestCache_ThreatUntrustedKeyDoS_RejectsOversizedKeysOnWritePaths` (`cache_test.go:2487`).
14+
15+
Severity: None for overlong single-key path or memory amplification via key string on those write paths.
16+
17+
Question: Can a flood of unique valid keys cause unbounded growth?
18+
19+
Finding: Yes, for storage growth inside the configured cache root. Cache entries are persisted via the configured `coreio.Medium`, and there is no entry-count or byte quota before `medium.Write` in JSON or binary writes (`cache.go:268`, `cache.go:384`, `cache.go:390`). The ordinary cache does not keep cached values in a Go map; the in-memory maps are invalidation callbacks and opened HTTP cache handles (`cache.go:48`, `cache.go:978`). A downstream consumer that forwards attacker-controlled unique valid keys can therefore grow files/inodes within `baseDir` until the backing medium or host quota stops it.
20+
21+
Severity: Medium. This is bounded to the configured cache root and by the underlying storage backend, but the package does not provide a built-in quota/eviction policy. No code fix was applied because adding a default global entry cap would change cache semantics and there is no existing public configuration surface for quotas in this ticket scope.
22+
23+
Question: Does `Invalidate` accept callback-returned glob patterns without a length backstop before `keysByPattern` lists and matches all cache keys?
24+
25+
Finding: Yes (prior-pass finding, retained). Validate invalidation patterns with a fixed byte limit before listing cache entries.
26+
27+
Severity: Medium.
28+
29+
Repro test: `TestCache_Invalidate_UntrustedPatternLength_Bad`.
1330

1431
## 2. Path traversal
15-
**Question:** What bytes does `hasPathDangerousBytes` cover (null byte, .., leading / or ~, control chars, URL-encoded %2e%2e)? Does symlink-following escape baseDir? Is `ensureSafeResponseBodyPath` rigour applied to JSON entry paths too?
16-
**Finding:** YES - `ensureSafeKey` rejects empty keys, `..` segments, leading `/` via empty segments, backslashes, null/control bytes, and overlong keys; URL-encoded `%2e%2e` remains a literal safe filename segment. `ensureSafeResponseBodyPath` is applied to cached HTTP response metadata on read and write. The gap was local symlink following: a symlinked directory or file already under `baseDir` could redirect an otherwise safe key outside the cache root.
17-
**Severity:** high
18-
**Repro test:** TestCache_Path_PathTraversalSymlink_Bad
19-
**Fix:** reject existing symlink components from the cache root through the resolved cache path before returning paths for filesystem use.
20-
21-
## 3. Eviction / TOCTOU
22-
**Question:** Is `Cache.mu` (RWMutex) discipline correct? Does `Invalidate` walk the `invalidation` map race-cleanly while `OnInvalidate` may register more? On TTL expiry, do concurrent readers race?
23-
**Finding:** TBD
24-
**Severity:** TBD
25-
**Repro test:** TBD
26-
**Fix:** TBD
32+
33+
Status: Complete
34+
35+
Question: Do disk paths derive from raw keys without sanitisation?
36+
37+
Finding: No for the core cache. `Path` validates the key, joins `baseDir` with `key + ".json"`, normalizes to an absolute path, and rejects paths outside the cache root prefix (`cache.go:136`, `cache.go:140`, `cache.go:141`, `cache.go:144`). Binary sidecar paths use the same validated key through `entryPaths` before writes (`cache.go:154`, `cache.go:155`, `cache.go:161`).
38+
39+
Severity: None for direct `../`, absolute path, control-byte, or backslash traversal through `Set`, `SetWithTTL`, `SetBinary`, `Get`, `Delete`, and related key-based operations.
40+
41+
Question: Do CacheStorage or HTTPCache paths derive from untrusted names or request URLs?
42+
43+
Finding: No direct traversal found. `CacheStorage.Open` and `CacheStorage.Delete` validate cache names before joining them under the storage base directory (`cache.go:1015`, `cache.go:1019`, `cache.go:1029`, `cache.go:1047`, `cache.go:1051`, `cache.go:1057`). The validator rejects empty names, names over 255 bytes, `/`, `\`, control bytes, `.`, and `..` (`cache.go:1066`, `cache.go:1070`, `cache.go:1073`, `cache.go:1076`, `cache.go:1079`). `HTTPCache` stores request metadata under SHA-256 hex request keys rather than raw URLs (`cache.go:1215`, `cache.go:1452`, `cache.go:1457`), and cached response body reads validate that `BodyPath` is a relative `responses/<key>.bin` path with safe segments (`cache.go:1407`, `cache.go:1410`, `cache.go:775`, `cache.go:789`, `cache.go:800`).
44+
45+
Severity: None for reviewed raw-name and raw-URL path traversal.
46+
47+
Question: Does ScopedCache origin namespacing allow path injection?
48+
49+
Finding: No. Scope prefixes are `scope_` plus a SHA-256 hex digest of the origin string, so raw origins are not embedded in file paths (`cache.go:711`, `cache.go:714`, `cache.go:814`, `cache.go:815`, `cache.go:817`). Scoped keys are prefixed and then passed back through the parent cache validation and path containment checks (`cache.go:820`, `cache.go:835`, `cache.go:839`). Regression coverage: `TestCache_ThreatPathTraversal_ScopedOriginIsHashedAndKeysStillValidated` (`cache_test.go:2534`).
50+
51+
Severity: None for origin-derived path traversal.
52+
53+
Question: Do HTTPCache request URLs become path components?
54+
55+
Finding: No. HTTP request storage keys are SHA-256 hex digests of `method + NUL + URL` (`cache.go:1215`, `cache.go:1452`, `cache.go:1457`), and `Put` writes metadata/body under `responses/<digest>.json` and `responses/<digest>.bin` (`cache.go:1343`, `cache.go:1347`, `cache.go:1362`, `cache.go:1363`, `cache.go:1383`, `cache.go:1388`). Regression coverage: `TestCache_ThreatPathTraversal_HTTPCacheUsesHashedRequestStorageKeys` (`cache_test.go:2557`).
56+
57+
Severity: None for raw-URL path traversal on the reviewed HTTPCache write path.
58+
59+
Question (prior pass): Symlink-following inside the cache root?
60+
61+
Finding: A symlinked directory or file already under `baseDir` could redirect an otherwise safe key outside the cache root. Reject existing symlink components from the cache root through the resolved cache path before returning paths for filesystem use.
62+
63+
Severity: high.
64+
65+
Repro test: `TestCache_Path_PathTraversalSymlink_Bad`.
66+
67+
## 3. Eviction TOCTOU
68+
69+
Status: Complete
70+
71+
Question: Did the class 1 or 2 audit expose overlapping eviction TOCTOU findings?
72+
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.
74+
75+
Severity: Not assessed beyond overlap.

0 commit comments

Comments
 (0)