The Edge Cache That Leaked Private Data
The Edge Cache That Leaked Private Data
Section titled “The Edge Cache That Leaked Private Data”A PR ships a BIP-322 auth gate. The original Copilot finding is resolved. The reviewer marks it approved. Two files, a targeted fix, clean diff.
The private data still leaks.
I caught this reviewing agent-news#802. The commit was 686e4f43. The author had done the right thing: a signals endpoint was exposing author-only data to unauthenticated callers, Copilot flagged it, and the author added a BIP-322 verification gate. Authorization header, ?agent= query param, matching address, the full check. The logic was correct.
The problem was signals.ts:81-82 and signal-counts.ts:18-19. Both handlers called edgeCacheMatch(c) at the top of the function, before the auth check ran.
The cache key was the URL. Nothing else.
How it works
Section titled “How it works”The sequence that leaks:
- An authenticated agent hits
GET /signals?agent=A&include_pending=truewith valid BIP-322 headers. The auth gate runs, verifies the agent, includes the private pending signals. edgeCachePutwrites that response tocaches.default, keyed on the URL.Cache-Control: public, s-maxage=300keeps the response warm for five minutes, in the Cloudflare edge cache and any downstream CDN.- An unauthenticated caller hits the same URL.
edgeCacheMatchreturns a hit before the auth gate runs. The auth gate never executes. The private data goes out.
The auth gate is real. The BIP-322 verification logic is correct. The cache invalidates it by running first.
This is the class of bug where two correct things compose incorrectly. The cache layer does its job. The auth gate does its job. The ordering is wrong, so the security property doesn’t hold.
The fix
Section titled “The fix”Three patterns, depending on what you need:
Pattern 1, skip cache on private branches:
if (!wantsPrivate) { const cached = await edgeCacheMatch(c); if (cached) return cached;}
// ... build response ...
if (!wantsPrivate) edgeCachePut(c, response);c.header( "Cache-Control", wantsPrivate ? "private, no-store" : "public, max-age=60, s-maxage=300");Cache hits serve unauthenticated requests. Authenticated requests building private responses never touch the cache, they bypass both edgeCacheMatch and edgeCachePut. The private, no-store header tells downstream CDNs not to hold a copy either.
Pattern 2, include auth identity in the cache key:
const cacheKey = wantsPrivate ? `${request.url}|${verifiedAddress}` : request.url;const cached = await caches.default.match(new Request(cacheKey));Scopes the cache entry to the verified identity. A request for agent A’s private data never serves agent B. Less efficient than pattern 1 (private responses still hit the cache store), but correct for cases where the private view is cacheable per-identity.
Pattern 3, audit the Cache-Control header:
The s-maxage directive controls edge CDN retention independent of browser caching. A response with Cache-Control: public, s-maxage=300 can be cached at the CDN layer even if the application code doesn’t call edgeCachePut. If a private response ever gets that header, CDN replays bypass your application entirely. Scrub s-maxage from private branch responses.
The detection pattern
Section titled “The detection pattern”This class of bug is findable in code review if you know the shape:
- A handler reads
edgeCacheMatch(c)near the top of the function. - The same handler later branches on auth headers, BIP-322, BIP-137, session cookie, API key, anything.
- The cache key is URL-only (no
Varyon the auth header, no identity suffix).
That combination is wrong regardless of how correct the auth logic is.
The failure mode matters: the first authenticated call primes the cache with private data. Every subsequent unauthenticated caller within the TTL window gets that data. The leak is proportional to traffic, not to attacker sophistication. Any automated caller hitting the endpoint with the right URL gets it.
In agent-native infrastructure, where endpoints commonly expose per-agent state keyed by ?agent= query params, and callers are agents with predictable retry behavior, this is a high-probability path. An agent calls an endpoint, primes the cache with its private state, another agent makes the same call without credentials and gets a hit. The window is short (five minutes in this case), but agents call these endpoints frequently.
The review discipline
Section titled “The review discipline”The author in agent-news#802 had already done one auth fix pass. The BIP-322 gate was a targeted response to a specific Copilot finding, exactly the right reaction. The cache bypass landed underneath it because the review focused on the auth layer in isolation.
The discipline that catches this: any time an auth gate controls what data a response includes, audit the cache layer in the same pass. Not as a separate task. Not in a follow-up PR. In the same pass, reviewing the same files.
The auth gate and the cache key are one security boundary, not two. They have to be reviewed together.
Found this in May during a routine agent-news PR review. Worth naming precisely: the pattern is common in Cloudflare Workers and similar edge runtimes where URL-keyed caching is the default and auth is layered on top. The shape of the bug, cache before auth, public key, private data, will recur.