edge: the cache stops missing on Vary, stops leaking private responses, and stops being floodable
Five review findings against the response cache, all reproduced before being fixed: C1 — a Vary-carrying response was stored under varyValue(request, vary) but looked up under an empty VaryValue, so it NEVER hit: 2 backend calls for 2 identical requests, measured. Every miss also inserted a new unreachable entry, so a crawler cycling Accept-Language could evict the whole cache. Lookup is now two-phase (Cache.GetFor): the Vary header names the last response for a URL asked for are remembered, replayed against the request, and resolved through the same varyValue both sides now share, so store key and lookup key cannot drift. C1 (admission) — two caps bound how much of the cache one URL or path can own, since the global LRU alone lets one URL evict everyone else's entries: 8 Vary variants per URL, 32 entries per path. RawQuery stays in the key (correctness: different queries can mean different content) and the per-path cap is what bounds a ?x=1..N flood. Measured before: 65 variants and 129 entries from a 64-wide flood; after: 8 and 32. C2 — the cache replayed other visitors' responses. Cache-Control: private was cached, Set-Cookie was cloned and served to everyone (verified: sid=abc123 came back out of the cache), and a response to an Authorization-bearing request was served to anonymous visitors. All three now refuse, with an explicit Cache-Control: public overriding the Authorization case per RFC 9111 §3.5. parseMaxAge also scans the whole header before honoring max-age, so "max-age=600, private" is refused rather than cached on directive order. I1 — TTLs are clamped to DefaultCacheMaxTTL (5m), which is what makes spec decision 4 true: an app asking for max-age=31536000 previously turned one lost purge into a permanently wrong page. I3 — the full-cycle test gains a sibling that drives the REAL purge channel: a JSON POST /purge over the unix control socket ListenPurge binds, rather than the in-process cache API, so a broken socket, encoding, or host scoping would fail it. Minors: gofmt on cache.go; the per-refusal log line for Vary: Cookie is gone (unrate-limited, and a crawler could flood it from a hot path).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
4 files changed,
+607
−44
internal/edge/cache.go+161 −17internal/edge/cache_test.go+59 −0internal/edge/edge.go+85 −15internal/edge/edge_test.go+302 −12
diff --git a/internal/edge/cache.go b/internal/edge/cache.go| index fe873a387..189ec4952 100644 |
| --- a/internal/edge/cache.go |
| +++ b/internal/edge/cache.go |
| @@ -7,6 +7,26 @@ import ( |
| "time" |
| ) |
| +// Admission caps that bound how much of the cache any single URL or path can |
| +// occupy, independent of the global LRU. The global LRU alone does not stop |
| +// one URL from being pumped full of noise entries that then evict OTHER |
| +// hosts' useful entries — a crawler sending Accept-Language: xx-1 … xx-4096 |
| +// (distinct Vary variants) or ?x=1 … ?x=4096 (distinct query strings, part of |
| +// the key because correctness requires it: different query strings can mean |
| +// different content) would otherwise flush the whole cache on demand. |
| +// |
| +// Two caps, because the two floods land on different key components: |
| +// - maxVaryVariantsPerURL bounds the Vary variants of one exact URL |
| +// (host+method+path+query). |
| +// - maxEntriesPerPath bounds everything under one path regardless of query |
| +// string or variant, which is what actually bounds a query-string flood. |
| +// |
| +// Both evict the oldest entry in their group (by StoredAt) when exceeded. |
| +const ( |
| + maxVaryVariantsPerURL = 8 |
| + maxEntriesPerPath = 32 |
| +) |
| + |
| // CacheKey is the full key for a cached response, including Host, Method, Path, RawQuery, |
| // and VaryValue (concatenation of request-header values named by the response's Vary header). |
| type CacheKey struct { |
| @@ -36,15 +56,27 @@ type Cache struct { |
| mu sync.Mutex |
| maxEntries int |
| maxBodyBytes int |
| - order *list.List // MRU at front, LRU at back |
| + order *list.List // MRU at front, LRU at back |
| entries map[cacheFullKey]*list.Element |
| - byURL map[cacheURLKey]map[string]struct{} // urlKey -> set of VaryValue |
| + byURL map[cacheURLKey]map[cacheFullKey]*list.Element // urlKey -> its Vary variants |
| + byPath map[cachePathKey]map[cacheFullKey]*list.Element // pathKey -> every entry under that path |
| + // varyNames records, per URL, the raw Vary header value the most recent |
| + // Vary-carrying response for that URL asked to vary by. It is what makes |
| + // the two-phase lookup possible: at request time the response's Vary |
| + // header isn't known yet, so GetFor replays the last one seen to compute |
| + // the same VaryValue Put stored the entry under. Dropped when a URL's last |
| + // entry goes away, so it can't grow without bound. |
| + varyNames map[cacheURLKey]string |
| } |
| type cacheURLKey struct { |
| Host, Method, Path, RawQuery string |
| } |
| +type cachePathKey struct { |
| + Host, Method, Path string |
| +} |
| + |
| type cacheFullKey struct { |
| cacheURLKey |
| VaryValue string |
| @@ -62,7 +94,9 @@ func NewCache(maxEntries, maxBodyBytes int) *Cache { |
| maxBodyBytes: maxBodyBytes, |
| order: list.New(), |
| entries: make(map[cacheFullKey]*list.Element), |
| - byURL: make(map[cacheURLKey]map[string]struct{}), |
| + byURL: make(map[cacheURLKey]map[cacheFullKey]*list.Element), |
| + byPath: make(map[cachePathKey]map[cacheFullKey]*list.Element), |
| + varyNames: make(map[cacheURLKey]string), |
| } |
| } |
| @@ -74,9 +108,17 @@ func (k CacheKey) fullKey() cacheFullKey { |
| return cacheFullKey{cacheURLKey: k.urlKey(), VaryValue: k.VaryValue} |
| } |
| +func (k cacheURLKey) pathKey() cachePathKey { |
| + return cachePathKey{Host: k.Host, Method: k.Method, Path: k.Path} |
| +} |
| + |
| // Get retrieves a cached response if it exists and has not expired. |
| // Returns (nil, false) for a miss or an expired entry. |
| // Expired entries are evicted lazily on lookup, not swept on a timer. |
| +// |
| +// Get takes the VaryValue already resolved by the caller. Request handling |
| +// goes through GetFor instead, which resolves it first — a request can't know |
| +// the response's Vary header until the response exists. |
| func (c *Cache) Get(key CacheKey) (*CachedResponse, bool) { |
| c.mu.Lock() |
| defer c.mu.Unlock() |
| @@ -94,8 +136,36 @@ func (c *Cache) Get(key CacheKey) (*CachedResponse, bool) { |
| return e.resp, true |
| } |
| +// GetFor is the request-time lookup: phase one resolves which Vary variant r |
| +// belongs to, phase two does the ordinary Get. baseKey carries everything |
| +// known before the response exists (host, method, path, query) with VaryValue |
| +// left empty. |
| +// |
| +// The Vary header names live on the RESPONSE, so at lookup time the only |
| +// honest source for them is what the last Vary-carrying response for this URL |
| +// said (c.varyNames). With none on record the lookup uses an empty VaryValue, |
| +// which is exactly the no-Vary case and keeps that path byte-for-byte as it |
| +// was. Both this and maybeStore compute the variant through varyValue, so |
| +// store and lookup can never drift apart — the bug this method exists to fix |
| +// was precisely that drift: storing under varyValue(request, vary) while |
| +// looking up under "", so a Vary-carrying response never hit. |
| +func (c *Cache) GetFor(baseKey CacheKey, r *http.Request) (*CachedResponse, bool) { |
| + c.mu.Lock() |
| + names := c.varyNames[baseKey.urlKey()] |
| + c.mu.Unlock() |
| + if names != "" { |
| + baseKey.VaryValue = varyValue(r, names) |
| + } |
| + return c.Get(baseKey) |
| +} |
| + |
| // Put stores a response in the cache. Silently drops a response whose body |
| -// exceeds maxBodyBytes. Evicts the least-recently-used entry when at maxEntries. |
| +// exceeds maxBodyBytes. Evicts the least-recently-used entry when at |
| +// maxEntries, and enforces the per-URL and per-path admission caps so no |
| +// single URL or path can flood the cache (see the cap constants above). |
| +// |
| +// A resp whose Header carries Vary also records those header names for this |
| +// URL, so GetFor can resolve the same variant on the next request. |
| func (c *Cache) Put(key CacheKey, resp *CachedResponse) { |
| if len(resp.Body) > c.maxBodyBytes { |
| return |
| @@ -103,6 +173,12 @@ func (c *Cache) Put(key CacheKey, resp *CachedResponse) { |
| c.mu.Lock() |
| defer c.mu.Unlock() |
| fk := key.fullKey() |
| + uk := fk.cacheURLKey |
| + if resp.Header != nil { |
| + if names := resp.Header.Get("Vary"); names != "" { |
| + c.varyNames[uk] = names |
| + } |
| + } |
| if el, ok := c.entries[fk]; ok { |
| el.Value.(*cacheEntry).resp = resp |
| c.order.MoveToFront(el) |
| @@ -110,11 +186,18 @@ func (c *Cache) Put(key CacheKey, resp *CachedResponse) { |
| } |
| el := c.order.PushFront(&cacheEntry{key: fk, resp: resp}) |
| c.entries[fk] = el |
| - uk := fk.cacheURLKey |
| if c.byURL[uk] == nil { |
| - c.byURL[uk] = make(map[string]struct{}) |
| + c.byURL[uk] = make(map[cacheFullKey]*list.Element) |
| + } |
| + c.byURL[uk][fk] = el |
| + pk := uk.pathKey() |
| + if c.byPath[pk] == nil { |
| + c.byPath[pk] = make(map[cacheFullKey]*list.Element) |
| } |
| - c.byURL[uk][fk.VaryValue] = struct{}{} |
| + c.byPath[pk][fk] = el |
| + |
| + c.capGroupLocked(c.byURL[uk], maxVaryVariantsPerURL, el) |
| + c.capGroupLocked(c.byPath[pk], maxEntriesPerPath, el) |
| for len(c.entries) > c.maxEntries { |
| victim := c.order.Back() |
| if victim == nil { |
| @@ -124,15 +207,50 @@ func (c *Cache) Put(key CacheKey, resp *CachedResponse) { |
| } |
| } |
| +// capGroupLocked drops the oldest entries (by StoredAt) from one admission |
| +// group until it holds at most max, never dropping keep (the entry just |
| +// inserted — evicting it would make the flood self-defeating in the wrong |
| +// direction, turning every request into a permanent miss AND a permanent |
| +// insert). Caller must hold c.mu. |
| +func (c *Cache) capGroupLocked(group map[cacheFullKey]*list.Element, max int, keep *list.Element) { |
| + for len(group) > max { |
| + var oldest *list.Element |
| + for _, el := range group { |
| + if el == keep { |
| + continue |
| + } |
| + if oldest == nil || el.Value.(*cacheEntry).resp.StoredAt.Before(oldest.Value.(*cacheEntry).resp.StoredAt) { |
| + oldest = el |
| + } |
| + } |
| + if oldest == nil { |
| + return |
| + } |
| + c.removeLocked(oldest) |
| + } |
| +} |
| + |
| // removeLocked drops el from every index. Caller must hold c.mu. |
| func (c *Cache) removeLocked(el *list.Element) { |
| e := el.Value.(*cacheEntry) |
| c.order.Remove(el) |
| delete(c.entries, e.key) |
| - if variants := c.byURL[e.key.cacheURLKey]; variants != nil { |
| - delete(variants, e.key.VaryValue) |
| + uk := e.key.cacheURLKey |
| + if variants := c.byURL[uk]; variants != nil { |
| + delete(variants, e.key) |
| if len(variants) == 0 { |
| - delete(c.byURL, e.key.cacheURLKey) |
| + delete(c.byURL, uk) |
| + // No entries left for this URL, so the remembered Vary names have |
| + // nothing to resolve against — dropping them keeps varyNames |
| + // bounded by the number of live URLs. |
| + delete(c.varyNames, uk) |
| + } |
| + } |
| + pk := uk.pathKey() |
| + if group := c.byPath[pk]; group != nil { |
| + delete(group, e.key) |
| + if len(group) == 0 { |
| + delete(c.byPath, pk) |
| } |
| } |
| } |
| @@ -147,14 +265,40 @@ func (c *Cache) PurgeURL(host, path, rawQuery string) int { |
| // GET and HEAD are purged together — a write invalidates both. |
| for _, method := range [...]string{"GET", "HEAD"} { |
| uk := cacheURLKey{Host: host, Method: method, Path: path, RawQuery: rawQuery} |
| - variants := c.byURL[uk] |
| - for vary := range variants { |
| - fk := cacheFullKey{cacheURLKey: uk, VaryValue: vary} |
| - if el, ok := c.entries[fk]; ok { |
| - c.removeLocked(el) |
| - n++ |
| - } |
| + for _, el := range elementsOf(c.byURL[uk]) { |
| + c.removeLocked(el) |
| + n++ |
| } |
| } |
| return n |
| } |
| + |
| +// PurgeHost drops every cached entry for host — all paths, all query strings, |
| +// all Vary variants — and returns how many it removed. This is what a deploy |
| +// calls: a fresh process for a host means new code and potentially new |
| +// content, so everything cached from the old one is suspect. A whole-cache |
| +// scan is fine here; it happens once per spawn, not per request. |
| +func (c *Cache) PurgeHost(host string) int { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + var doomed []*list.Element |
| + for key, el := range c.entries { |
| + if key.Host == host { |
| + doomed = append(doomed, el) |
| + } |
| + } |
| + for _, el := range doomed { |
| + c.removeLocked(el) |
| + } |
| + return len(doomed) |
| +} |
| + |
| +// elementsOf snapshots a group's elements so the caller can remove them while |
| +// iterating (removeLocked mutates the very map being ranged over). |
| +func elementsOf(group map[cacheFullKey]*list.Element) []*list.Element { |
| + els := make([]*list.Element, 0, len(group)) |
| + for _, el := range group { |
| + els = append(els, el) |
| + } |
| + return els |
| +} |
diff --git a/internal/edge/cache_test.go b/internal/edge/cache_test.go| index aa06d467d..71bf388e8 100644 |
| --- a/internal/edge/cache_test.go |
| +++ b/internal/edge/cache_test.go |
| @@ -1,6 +1,7 @@ |
| package edge |
| import ( |
| + "fmt" |
| "net/http" |
| "testing" |
| "time" |
| @@ -68,6 +69,64 @@ func TestCacheEvictsLeastRecentlyUsedAtCapacity(t *testing.T) { |
| } |
| } |
| +// A deploy replaces the code behind a host, so every cached page for it is |
| +// suspect — PurgeHost drops all of them, across paths and Vary variants, and |
| +// nothing belonging to any other host. |
| +func TestCachePurgeHostRemovesEveryEntryForThatHost(t *testing.T) { |
| + c := NewCache(100, 1<<20) |
| + mkResp := func(b string) *CachedResponse { |
| + return &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(b), StoredAt: time.Now(), TTL: time.Minute} |
| + } |
| + keys := []CacheKey{ |
| + {Host: "a", Method: "GET", Path: "/one"}, |
| + {Host: "a", Method: "GET", Path: "/two", VaryValue: "en"}, |
| + {Host: "a", Method: "GET", Path: "/two", VaryValue: "fr"}, |
| + {Host: "a", Method: "HEAD", Path: "/one"}, |
| + {Host: "a", Method: "GET", Path: "/one", RawQuery: "page=2"}, |
| + } |
| + for _, k := range keys { |
| + c.Put(k, mkResp(k.Path)) |
| + } |
| + other := CacheKey{Host: "b", Method: "GET", Path: "/one"} |
| + c.Put(other, mkResp("b/one")) |
| + |
| + if n := c.PurgeHost("a"); n != len(keys) { |
| + t.Fatalf("PurgeHost removed %d entries, want %d", n, len(keys)) |
| + } |
| + for _, k := range keys { |
| + if _, ok := c.Get(k); ok { |
| + t.Fatalf("entry %+v survived PurgeHost", k) |
| + } |
| + } |
| + if _, ok := c.Get(other); !ok { |
| + t.Fatal("PurgeHost(\"a\") must leave host b's entries untouched") |
| + } |
| + if n := c.PurgeHost("nobody"); n != 0 { |
| + t.Fatalf("PurgeHost on an uncached host removed %d, want 0", n) |
| + } |
| +} |
| + |
| +// An attacker pumping distinct query strings at one path must not be able to |
| +// evict the rest of the fleet's cache: the per-path cap bounds how many |
| +// entries a single path can occupy, independent of the global LRU. |
| +func TestCacheQueryStringFloodIsBoundedPerPath(t *testing.T) { |
| + c := NewCache(1000, 1<<20) |
| + for i := 0; i < 10*maxEntriesPerPath; i++ { |
| + k := CacheKey{Host: "a", Method: "GET", Path: "/event", RawQuery: fmt.Sprintf("x=%d", i)} |
| + c.Put(k, &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("x"), StoredAt: time.Now(), TTL: time.Minute}) |
| + } |
| + c.mu.Lock() |
| + n := len(c.byPath[cachePathKey{Host: "a", Method: "GET", Path: "/event"}]) |
| + total := len(c.entries) |
| + c.mu.Unlock() |
| + if n > maxEntriesPerPath { |
| + t.Fatalf("one path holds %d cached entries, want at most %d", n, maxEntriesPerPath) |
| + } |
| + if total > maxEntriesPerPath { |
| + t.Fatalf("cache holds %d entries after a query-string flood, want at most %d", total, maxEntriesPerPath) |
| + } |
| +} |
| + |
| func TestCachePurgeURLRemovesAllVaryVariants(t *testing.T) { |
| c := NewCache(10, 1<<20) |
| en := CacheKey{Host: "a", Method: "GET", Path: "/event", VaryValue: "en"} |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index e382c0fff..6e63ec092 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -51,6 +51,14 @@ const ( |
| DefaultCacheMaxBodyBytes = 1 << 20 // 1 MiB |
| ) |
| +// DefaultCacheMaxTTL is the ceiling every cached entry's TTL is clamped to, |
| +// however long the app's own max-age asks for. It is what makes spec decision |
| +// 4 true — "a lost purge self-heals within a bounded window": without a |
| +// ceiling, an app setting max-age=31536000 turns one dropped purge call into a |
| +// permanently wrong page. Five minutes is short enough that a missed purge is |
| +// a blip and long enough that a crawler sweep still rides the cache. |
| +const DefaultCacheMaxTTL = 5 * time.Minute |
| + |
| // Activator is the hibernation seam (implemented by the activator package): |
| // given a hibernate-flagged instance route, it wakes (or joins an in-progress |
| // wake of) the backing process before letting the request through, and |
| @@ -283,7 +291,11 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| var key CacheKey |
| if cacheable { |
| key = CacheKey{Host: host, Method: r.Method, Path: r.URL.Path, RawQuery: r.URL.RawQuery} |
| - if resp, hit := e.cache.Get(key); hit { |
| + // GetFor, not Get: the variant this request belongs to depends on the |
| + // response's Vary header, which doesn't exist yet — GetFor resolves it |
| + // from the Vary names the last response for this URL recorded, so the |
| + // lookup key matches the key maybeStore stored under. |
| + if resp, hit := e.cache.GetFor(key, r); hit { |
| writeCached(w, resp) |
| return |
| } |
| @@ -339,8 +351,22 @@ func (e *Edge) cacheMaxBody() int { |
| } |
| // maybeStore inspects a recorded response and caches it if it qualifies: |
| -// 2xx status, a Cache-Control that names a positive max-age, and no |
| -// Vary: Cookie (varying by cookie would defeat correctness per-visitor). |
| +// 2xx status, a Cache-Control that names a positive max-age, and none of the |
| +// per-visitor markers below. This cache is shared across every visitor of a |
| +// host and caching is unconditional per route (not opt-in per app), so each |
| +// refusal here is the only thing standing between one visitor's response and |
| +// everyone else's: |
| +// |
| +// - Cache-Control: private — the app itself saying "one visitor only". |
| +// - Set-Cookie — the response carries per-visitor state (a session id, a |
| +// CSRF token); replaying it would hand that cookie to later visitors. |
| +// - an Authorization header on the REQUEST — the response is presumed |
| +// private per RFC 9111 §3.5, unless Cache-Control explicitly says public, |
| +// which is the app asserting the response is safe to share. |
| +// - Vary: Cookie — varying by cookie would defeat correctness per-visitor. |
| +// |
| +// The stored TTL is clamped to DefaultCacheMaxTTL so a lost purge always |
| +// self-heals within a bounded window. |
| func (e *Edge) maybeStore(key CacheKey, rec *recordingResponseWriter) { |
| if e.cache == nil || !rec.cacheable || rec.truncated { |
| return |
| @@ -348,18 +374,29 @@ func (e *Edge) maybeStore(key CacheKey, rec *recordingResponseWriter) { |
| if rec.status < 200 || rec.status >= 300 { |
| return |
| } |
| - ttl, ok := parseMaxAge(rec.Header().Get("Cache-Control")) |
| + cc := rec.Header().Get("Cache-Control") |
| + ttl, ok := parseMaxAge(cc) |
| if !ok || ttl <= 0 { |
| return |
| } |
| + if len(rec.Header().Values("Set-Cookie")) > 0 { |
| + return |
| + } |
| + if rec.request.Header.Get("Authorization") != "" && !hasCCDirective(cc, "public") { |
| + return |
| + } |
| + if ttl > DefaultCacheMaxTTL { |
| + ttl = DefaultCacheMaxTTL |
| + } |
| vary := rec.Header().Get("Vary") |
| if vary != "" { |
| for _, h := range strings.Split(vary, ",") { |
| if strings.EqualFold(strings.TrimSpace(h), "Cookie") { |
| - log.Printf("edge: refusing to cache %s%s — Vary: Cookie would break per-visitor correctness", key.Host, key.Path) |
| return |
| } |
| } |
| + // The same computation GetFor replays at lookup time — both call |
| + // varyValue so the store key and the lookup key can never drift. |
| key.VaryValue = varyValue(rec.request, vary) |
| } |
| e.cache.Put(key, &CachedResponse{ |
| @@ -388,23 +425,56 @@ func varyValue(r *http.Request, vary string) string { |
| } |
| // parseMaxAge extracts max-age from a Cache-Control header. Returns |
| -// ok=false for "no-store", "no-cache", or a missing/malformed max-age — any |
| -// of which mean "don't cache this." |
| +// ok=false for "no-store", "no-cache", "private", or a missing/malformed |
| +// max-age — any of which mean "don't cache this in a shared cache." |
| +// |
| +// The whole header is scanned before the max-age is honored, so a |
| +// "max-age=600, private" (directives in either order, and "private" in its |
| +// field-name form private="Set-Cookie") is refused rather than cached because |
| +// max-age happened to come first. |
| func parseMaxAge(cc string) (time.Duration, bool) { |
| - for _, part := range strings.Split(cc, ",") { |
| - part = strings.TrimSpace(part) |
| - if strings.EqualFold(part, "no-store") || strings.EqualFold(part, "no-cache") { |
| + var ttl time.Duration |
| + found := false |
| + for name, arg := range ccDirectives(cc) { |
| + switch name { |
| + case "no-store", "no-cache", "private": |
| return 0, false |
| - } |
| - if v, ok := strings.CutPrefix(strings.ToLower(part), "max-age="); ok { |
| - secs, err := strconv.Atoi(v) |
| + case "max-age": |
| + secs, err := strconv.Atoi(strings.TrimSpace(arg)) |
| if err != nil { |
| return 0, false |
| } |
| - return time.Duration(secs) * time.Second, true |
| + ttl, found = time.Duration(secs)*time.Second, true |
| + } |
| + } |
| + if !found { |
| + return 0, false |
| + } |
| + return ttl, true |
| +} |
| + |
| +// hasCCDirective reports whether cc carries the named directive. |
| +func hasCCDirective(cc, want string) bool { |
| + for name := range ccDirectives(cc) { |
| + if name == want { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// ccDirectives iterates a Cache-Control header as (lowercased name, argument) |
| +// pairs; the argument is "" for a bare directive. One parse shape for every |
| +// directive question this file asks. |
| +func ccDirectives(cc string) func(func(string, string) bool) { |
| + return func(yield func(string, string) bool) { |
| + for _, part := range strings.Split(cc, ",") { |
| + name, arg, _ := strings.Cut(strings.TrimSpace(part), "=") |
| + if !yield(strings.ToLower(strings.TrimSpace(name)), arg) { |
| + return |
| + } |
| } |
| } |
| - return 0, false |
| } |
| // writeCached replays a cached response verbatim onto w. |
diff --git a/internal/edge/edge_test.go b/internal/edge/edge_test.go| index a14968d3f..1ad2320c6 100644 |
| --- a/internal/edge/edge_test.go |
| +++ b/internal/edge/edge_test.go |
| @@ -2,8 +2,11 @@ package edge |
| import ( |
| "bufio" |
| + "bytes" |
| "context" |
| + "encoding/json" |
| "errors" |
| + "fmt" |
| "io" |
| "net" |
| "net/http" |
| @@ -11,6 +14,7 @@ import ( |
| "os" |
| "path/filepath" |
| "strings" |
| + "sync/atomic" |
| "syscall" |
| "testing" |
| "time" |
| @@ -577,8 +581,14 @@ func TestServeHTTPNeverCachesNon2xx(t *testing.T) { |
| } |
| } |
| -func TestServeHTTPVaryAcceptLanguageProducesSeparateEntries(t *testing.T) { |
| +// varyBackendEdge wires an Edge in front of a backend that echoes the request's |
| +// Accept-Language and counts its own invocations — the counter is what proves a |
| +// second request was answered from cache rather than re-proxied, which asserting |
| +// on response bodies alone cannot do (a miss echoes the same body a hit would). |
| +func varyBackendEdge(t *testing.T, host string, hits *atomic.Int64) *Edge { |
| + t.Helper() |
| backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + hits.Add(1) |
| w.Header().Set("Cache-Control", "max-age=60") |
| w.Header().Set("Vary", "Accept-Language") |
| w.WriteHeader(200) |
| @@ -587,7 +597,7 @@ func TestServeHTTPVaryAcceptLanguageProducesSeparateEntries(t *testing.T) { |
| t.Cleanup(backend.Close) |
| reg := openReg(t) |
| - if err := reg.Upsert(registry.Route{Host: "i18n.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| t.Fatalf("upsert: %v", err) |
| } |
| e, err := New(reg, nil) |
| @@ -595,19 +605,214 @@ func TestServeHTTPVaryAcceptLanguageProducesSeparateEntries(t *testing.T) { |
| t.Fatalf("New: %v", err) |
| } |
| e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + return e |
| +} |
| + |
| +// getLang issues a GET carrying Accept-Language: lang (omitted entirely when |
| +// lang is "") and returns the body. |
| +func getLang(t *testing.T, e *Edge, url, lang string) string { |
| + t.Helper() |
| + req := httptest.NewRequest("GET", url, nil) |
| + if lang != "" { |
| + req.Header.Set("Accept-Language", lang) |
| + } |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, req) |
| + return rec.Body.String() |
| +} |
| + |
| +// A Vary-carrying response must be both separated per variant AND actually |
| +// re-served from cache on a repeat request. The backend-invocation count is |
| +// the load-bearing assertion: pre-fix, ServeHTTP looked entries up with an |
| +// empty VaryValue while maybeStore stored them under varyValue(request, vary), |
| +// so every request was a miss — 4 backend hits, not 2 — while the bodies still |
| +// differed per language, which is why the old body-comparison assertion passed |
| +// with Vary support entirely broken. |
| +func TestServeHTTPVaryAcceptLanguageProducesSeparateEntries(t *testing.T) { |
| + var hits atomic.Int64 |
| + e := varyBackendEdge(t, "i18n.example.com", &hits) |
| + const url = "http://i18n.example.com/event" |
| + |
| + for _, want := range []struct{ lang, body string }{ |
| + {"en", "lang=en"}, {"en", "lang=en"}, {"fr", "lang=fr"}, {"fr", "lang=fr"}, |
| + } { |
| + if got := getLang(t, e, url, want.lang); got != want.body { |
| + t.Fatalf("Accept-Language %q: body = %q, want %q", want.lang, got, want.body) |
| + } |
| + } |
| + |
| + if n := hits.Load(); n != 2 { |
| + t.Fatalf("backend invoked %d times across 4 requests (en, en, fr, fr), want 2 — the repeat of each language must be served from cache", n) |
| + } |
| +} |
| - reqEn := httptest.NewRequest("GET", "http://i18n.example.com/event", nil) |
| - reqEn.Header.Set("Accept-Language", "en") |
| - recEn := httptest.NewRecorder() |
| - e.ServeHTTP(recEn, reqEn) |
| +// The core regression: a repeat of an identical request against a |
| +// Vary-carrying response must hit. Here no Accept-Language is sent at all, so |
| +// both requests resolve to the same (empty-valued) variant. |
| +func TestServeHTTPVaryRepeatIdenticalRequestHitsCache(t *testing.T) { |
| + var hits atomic.Int64 |
| + e := varyBackendEdge(t, "repeat.example.com", &hits) |
| + const url = "http://repeat.example.com/event" |
| - reqFr := httptest.NewRequest("GET", "http://i18n.example.com/event", nil) |
| - reqFr.Header.Set("Accept-Language", "fr") |
| - recFr := httptest.NewRecorder() |
| - e.ServeHTTP(recFr, reqFr) |
| + if got := getLang(t, e, url, ""); got != "lang=" { |
| + t.Fatalf("first body = %q, want %q", got, "lang=") |
| + } |
| + if got := getLang(t, e, url, ""); got != "lang=" { |
| + t.Fatalf("second body = %q, want %q", got, "lang=") |
| + } |
| + if n := hits.Load(); n != 1 { |
| + t.Fatalf("backend invoked %d times across 2 identical requests, want 1 — the second must be a cache hit", n) |
| + } |
| +} |
| - if recEn.Body.String() == recFr.Body.String() { |
| - t.Fatalf("expected distinct cached responses per Accept-Language, got %q for both", recEn.Body.String()) |
| +// A crawler pumping distinct Vary values at one URL must not be able to fill |
| +// the cache with unreachable variants: the per-URL cap bounds how many a |
| +// single URL can occupy, evicting the oldest as new ones arrive. |
| +func TestServeHTTPVaryVariantCapBoundsEntriesPerURL(t *testing.T) { |
| + var hits atomic.Int64 |
| + e := varyBackendEdge(t, "flood.example.com", &hits) |
| + const url = "http://flood.example.com/event" |
| + |
| + langs := make([]string, 4*maxVaryVariantsPerURL) |
| + for i := range langs { |
| + langs[i] = fmt.Sprintf("xx-%d", i) |
| + getLang(t, e, url, langs[i]) |
| + } |
| + |
| + uk := CacheKey{Host: "flood.example.com", Method: "GET", Path: "/event"}.urlKey() |
| + e.cache.mu.Lock() |
| + n := len(e.cache.byURL[uk]) |
| + e.cache.mu.Unlock() |
| + if n > maxVaryVariantsPerURL { |
| + t.Fatalf("one URL holds %d cached variants, want at most %d", n, maxVaryVariantsPerURL) |
| + } |
| + |
| + // The newest variant survives; the oldest was evicted to make room. |
| + before := hits.Load() |
| + getLang(t, e, url, langs[len(langs)-1]) |
| + if hits.Load() != before { |
| + t.Fatal("the most recent Vary variant should still be cached") |
| + } |
| + getLang(t, e, url, langs[0]) |
| + if hits.Load() != before+1 { |
| + t.Fatal("the oldest Vary variant should have been evicted by the per-URL cap") |
| + } |
| +} |
| + |
| +// cacheProbeEdge wires an Edge whose backend answers with headers chosen per |
| +// path by hdr — the shape every "must not be cached" rule test needs. |
| +func cacheProbeEdge(t *testing.T, host string, hdr func(path string, h http.Header)) *Edge { |
| + t.Helper() |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + hdr(r.URL.Path, w.Header()) |
| + w.WriteHeader(200) |
| + w.Write([]byte("body for " + r.URL.Path)) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + return e |
| +} |
| + |
| +func cachedAt(e *Edge, host, path string) bool { |
| + _, ok := e.cache.Get(CacheKey{Host: host, Method: "GET", Path: path}) |
| + return ok |
| +} |
| + |
| +// Cache-Control: private means "for one visitor only" — caching it at the |
| +// shared edge and replaying it to everyone else is a cross-visitor leak. |
| +func TestServeHTTPRefusesCacheControlPrivate(t *testing.T) { |
| + e := cacheProbeEdge(t, "priv.example.com", func(path string, h http.Header) { |
| + if path == "/private" { |
| + h.Set("Cache-Control", "private, max-age=600") |
| + return |
| + } |
| + h.Set("Cache-Control", "max-age=600") |
| + }) |
| + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://priv.example.com/private", nil)) |
| + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://priv.example.com/shared", nil)) |
| + |
| + if cachedAt(e, "priv.example.com", "/private") { |
| + t.Fatal("a Cache-Control: private response must never be cached at the shared edge") |
| + } |
| + if !cachedAt(e, "priv.example.com", "/shared") { |
| + t.Fatal("an ordinary max-age response must still be cached") |
| + } |
| +} |
| + |
| +// A Set-Cookie response carries per-visitor state (a session, a CSRF token); |
| +// caching it would hand that cookie to every later visitor. |
| +func TestServeHTTPRefusesResponseWithSetCookie(t *testing.T) { |
| + e := cacheProbeEdge(t, "cookie2.example.com", func(path string, h http.Header) { |
| + h.Set("Cache-Control", "max-age=600") |
| + if path == "/session" { |
| + h.Set("Set-Cookie", "sid=abc123; Path=/; HttpOnly") |
| + } |
| + }) |
| + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://cookie2.example.com/session", nil)) |
| + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://cookie2.example.com/plain", nil)) |
| + |
| + if cachedAt(e, "cookie2.example.com", "/session") { |
| + t.Fatal("a response carrying Set-Cookie must never be cached — the cookie would be replayed to other visitors") |
| + } |
| + if !cachedAt(e, "cookie2.example.com", "/plain") { |
| + t.Fatal("a response without Set-Cookie must still be cached") |
| + } |
| +} |
| + |
| +// A response to an Authorization-bearing request is presumed private (RFC 9111 |
| +// §3.5) unless the app explicitly marks it public. |
| +func TestServeHTTPRefusesAuthorizedRequestUnlessPublic(t *testing.T) { |
| + e := cacheProbeEdge(t, "auth.example.com", func(path string, h http.Header) { |
| + if path == "/public" { |
| + h.Set("Cache-Control", "public, max-age=600") |
| + return |
| + } |
| + h.Set("Cache-Control", "max-age=600") |
| + }) |
| + authGet := func(path string) { |
| + req := httptest.NewRequest("GET", "http://auth.example.com"+path, nil) |
| + req.Header.Set("Authorization", "Bearer secret-token") |
| + e.ServeHTTP(httptest.NewRecorder(), req) |
| + } |
| + authGet("/account") |
| + authGet("/public") |
| + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://auth.example.com/anon", nil)) |
| + |
| + if cachedAt(e, "auth.example.com", "/account") { |
| + t.Fatal("a response to an Authorization-bearing request must not be cached without an explicit Cache-Control: public") |
| + } |
| + if !cachedAt(e, "auth.example.com", "/public") { |
| + t.Fatal("Cache-Control: public overrides the Authorization concern (RFC 9111 §3.5) — this response should be cached") |
| + } |
| + if !cachedAt(e, "auth.example.com", "/anon") { |
| + t.Fatal("an unauthenticated request's ordinary max-age response must still be cached") |
| + } |
| +} |
| + |
| +// An app asking for a year of freshness would make a single lost purge |
| +// effectively permanent; the edge clamps every TTL to its own ceiling so a |
| +// missed purge always self-heals within a bounded window (spec decision 4). |
| +func TestServeHTTPClampsMaxAgeToCeiling(t *testing.T) { |
| + e := cacheProbeEdge(t, "ttl.example.com", func(path string, h http.Header) { |
| + h.Set("Cache-Control", "max-age=31536000") |
| + }) |
| + e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "http://ttl.example.com/event", nil)) |
| + |
| + resp, ok := e.cache.Get(CacheKey{Host: "ttl.example.com", Method: "GET", Path: "/event"}) |
| + if !ok { |
| + t.Fatal("expected the response to be cached") |
| + } |
| + if resp.TTL != DefaultCacheMaxTTL { |
| + t.Fatalf("cached TTL = %s, want it clamped to %s", resp.TTL, DefaultCacheMaxTTL) |
| } |
| } |
| @@ -704,6 +909,91 @@ func (f activatorFunc) RetireExcept(keep map[string]bool) { |
| // No-op for tests that don't need retirement tracking. |
| } |
| +// TestCacheBeforeWakeFullCycleOverPurgeSocket is the full cycle with the REAL |
| +// purge channel in it: the same cold-miss → cached-repeat → purge → fresh |
| +// sequence as TestCacheBeforeWakeFullCycle, except the purge arrives the way a |
| +// spawned instance actually sends it — a JSON POST /purge over the unix |
| +// control socket ListenPurge binds (the socket whose path the activator hands |
| +// the child as CARLOS_CONTROL_SOCKET). The sibling test below drives the |
| +// in-process cache API, which cannot catch a broken socket, encoding, or |
| +// host-scoping; this one proves the whole channel from "instance calls purge" |
| +// to "edge cache entry is gone." |
| +func TestCacheBeforeWakeFullCycleOverPurgeSocket(t *testing.T) { |
| + body := "version 1" |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + w.WriteHeader(200) |
| + w.Write([]byte(body)) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + wakes := 0 |
| + act := activatorFunc(func(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool { |
| + wakes++ |
| + proxy.ServeHTTP(w, r) |
| + return true |
| + }) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "sock-cycle.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready", Hibernate: true}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, act) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes) |
| + e.EnableCache(cache) |
| + |
| + // The control socket the activator would create for this instance and |
| + // hand it as CARLOS_CONTROL_SOCKET. |
| + controlSocket := filepath.Join(t.TempDir(), "instance.sock.control") |
| + pl, err := ListenPurge(controlSocket, "sock-cycle.example.com", cache) |
| + if err != nil { |
| + if errors.Is(err, syscall.EPERM) || strings.Contains(err.Error(), "operation not permitted") { |
| + t.Skipf("unix-socket listeners not permitted in this environment: %v", err) |
| + } |
| + t.Fatalf("ListenPurge: %v", err) |
| + } |
| + defer pl.Close() |
| + |
| + get := func() string { |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("GET", "http://sock-cycle.example.com/event", nil)) |
| + return rec.Body.String() |
| + } |
| + |
| + if got := get(); got != "version 1" { |
| + t.Fatalf("first request body = %q, want %q", got, "version 1") |
| + } |
| + if wakes != 1 { |
| + t.Fatalf("expected exactly 1 wake for the cold request, got %d", wakes) |
| + } |
| + if got := get(); got != "version 1" || wakes != 1 { |
| + t.Fatalf("second request body = %q wakes = %d, want %q and no second wake", got, wakes, "version 1") |
| + } |
| + |
| + // The write happens, and the instance purges its own page over the control |
| + // socket — exactly the request a real instance makes. |
| + body = "version 2" |
| + payload, _ := json.Marshal(purgeRequest{Path: "/event", Query: ""}) |
| + resp, err := dialPurgeSocket(t, controlSocket).Post("http://unix/purge", "application/json", bytes.NewReader(payload)) |
| + if err != nil { |
| + t.Fatalf("purge POST over control socket: %v", err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("purge status = %d, want 200", resp.StatusCode) |
| + } |
| + |
| + if got := get(); got != "version 2" { |
| + t.Fatalf("post-purge request body = %q, want %q — the purge never reached the cache through the socket", got, "version 2") |
| + } |
| + if wakes != 2 { |
| + t.Fatalf("expected the post-purge request to miss and wake once more, wakes = %d", wakes) |
| + } |
| +} |
| + |
| // TestCacheBeforeWakeFullCycle exercises the sequence the whole feature |
| // exists for: a cold miss wakes the instance and populates the cache; a |
| // repeat request hits the cache and does NOT wake the instance again; a |