-
Notifications
You must be signed in to change notification settings - Fork 14
Add CDN cache headers and a byte-bounded negative cache #33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7a5d0c3
552d9f0
5dcfbc5
38877cc
c3e08a2
8eeac33
cc3b044
9f57a4e
9c6ed74
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,15 @@ const { PATH_PREFIX, TARGET_HOST } = process.env; | |
|
|
||
| assert(TARGET_HOST, 'TARGET_HOST is defined'); | ||
|
|
||
| // How long a "this symbol does not exist" answer stays valid, both in our | ||
| // in-memory cache and in any CDN/client honoring Cache-Control. Kept short-ish | ||
| // so symbols uploaded later (e.g. new releases) aren't hidden forever. | ||
| const MISSING_SYMBOL_TTL_SECONDS = 60 * 60; | ||
| const MISSING_CACHE_CONTROL = `public, max-age=${MISSING_SYMBOL_TTL_SECONDS}`; | ||
| // Symbol files are immutable for a given debug-id path, so hits can be cached | ||
| // aggressively by CDNs/clients. | ||
| const HIT_CACHE_CONTROL = 'public, max-age=604800, immutable'; | ||
|
|
||
| const TARGET_URL = url.format({ | ||
| protocol: 'https:', | ||
| slashes: true, | ||
|
|
@@ -35,8 +44,24 @@ for (const appName of APPS_TO_ALIAS) { | |
| REPLACEMENTS.push([/\/c:\\projects\\src\\out\\default\\/g, '/']); | ||
| REPLACEMENTS.push([/\/c%3a%5cprojects%5csrc%5cout%5cdefault%5c/g, '/']); | ||
|
|
||
| // Bound the negative cache by total bytes rather than entry count so its | ||
| // worst-case memory footprint stays predictable on a small dyno. In | ||
| // lru-cache@6, providing a `length` calculator makes `max` a total-length | ||
| // budget. Charging only the key's string length badly undercounts real heap: | ||
| // each entry also costs an lru-cache linked-list node, a Map entry, and V8 | ||
| // string/object headers. Measured with node --expose-gc on lru-cache@6 using | ||
| // representative 96-char keys filled to steady-state eviction: ~480-510 bytes | ||
| // of heapUsed per entry, i.e. roughly 384 bytes of overhead beyond the key | ||
| // itself. Charging key.length alone allowed ~350k entries and ~160 MiB of | ||
| // real heap against this 32 MiB budget; charging the measured overhead keeps | ||
| // a full cache at ~70k entries and ~34 MiB of measured heap. | ||
| const MISSING_CACHE_MAX_BYTES = 32 * 1024 * 1024; | ||
| const MISSING_CACHE_ENTRY_OVERHEAD_BYTES = 384; | ||
|
|
||
| const missingSymbolCache = new LRU<string, boolean>({ | ||
| max: 10000, | ||
| max: MISSING_CACHE_MAX_BYTES, | ||
| length: (_value, key) => (key as string).length + MISSING_CACHE_ENTRY_OVERHEAD_BYTES, | ||
| maxAge: MISSING_SYMBOL_TTL_SECONDS * 1000, | ||
| }); | ||
|
|
||
| function incomingPathToProxyPath(path: string): string { | ||
|
|
@@ -76,10 +101,14 @@ proxy.on('proxyReq', (proxyReq, request, response, options) => { | |
| const originalWriteHead = response.writeHead; | ||
| response.writeHead = (...args: [number, any]) => { | ||
| if (args[0] == 403) { | ||
| // Only genuine misses go in the negative cache. Hits and transport | ||
| // errors used to be stored as `false`, which answered no query the | ||
| // cache's absence wouldn't, but still consumed an LRU slot each. | ||
| missingSymbolCache.set(proxyReq.path, true); | ||
| args[0] = 404; | ||
| } else { | ||
| missingSymbolCache.set(proxyReq.path, false); | ||
| response.setHeader('Cache-Control', MISSING_CACHE_CONTROL); | ||
| } else if (args[0] == 200 && !response.getHeader('cache-control')) { | ||
| response.setHeader('Cache-Control', HIT_CACHE_CONTROL); | ||
| } | ||
| return originalWriteHead.apply(response, args); | ||
| }; | ||
|
|
@@ -90,6 +119,11 @@ proxy.on('error', (err, req, res) => { | |
|
|
||
| console.error('Error:', errorId, 'Request:', req.url, err); | ||
|
|
||
| // The client may already be gone (disconnected mid-proxy) by the time the | ||
| // upstream request fails; writing headers to a closed/finished response | ||
| // would throw. | ||
| if (res.destroyed || res.writableEnded || res.headersSent) return; | ||
|
|
||
| res.writeHead(500, { | ||
| 'Content-Type': 'text/plain' | ||
| }); | ||
|
|
@@ -114,11 +148,20 @@ http.createServer((req, res) => { | |
| host: TARGET_HOST, | ||
| pathname: cacheKey, | ||
| })); | ||
| // Only Cloudflare may cache these redirects: its cache key (electron/infra | ||
| // cache ruleset) separates the redirect cohort on both triggers of this | ||
| // branch, so a cached 302 cannot leak to ordinary clients. Generic shared | ||
| // caches and browsers key on URL alone, so they get no-store, while | ||
| // Cloudflare-CDN-Cache-Control — Cloudflare-specific, preferred by | ||
| // Cloudflare over Cache-Control, and not forwarded downstream — keeps the | ||
| // edge caching the redirect for an hour. | ||
| res.setHeader('Cache-Control', 'no-store'); | ||
| res.setHeader('Cloudflare-CDN-Cache-Control', MISSING_CACHE_CONTROL); | ||
| return res.writeHead(302).end(); | ||
| } | ||
|
|
||
| if (missingSymbolCache.get(cacheKey)) { | ||
| return res.writeHead(404).end(); | ||
| return res.writeHead(404, { 'Cache-Control': MISSING_CACHE_CONTROL }).end(); | ||
| } | ||
|
Comment on lines
156
to
158
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The Extended reasoning...The bug: CORS headers ( if (missingSymbolCache.get(cacheKey)) {
return res.writeHead(404, { 'Cache-Control': MISSING_CACHE_CONTROL }).end();
}returns directly and never calls Why this predates the PR but is worsened by it: this asymmetry between the two 404-producing code paths already existed before this PR — a cache hit has always returned a CORS-less 404 while a fresh 403 rewrite has always carried CORS headers. What the PR changes is caching behavior: it adds Concrete walk-through of the failure mode:
Why existing code doesn't prevent it: the CORS headers are hard-coded into the Impact: the primary consumers of this service are native symbol-server clients (symsrv.dll, Sentry's symbolicator) that don't perform CORS preflight/enforcement, so most traffic is unaffected — the response is a 404 either way and those clients only care about the status code. The practical exposure is limited to any browser-based tooling that queries this endpoint directly, but for those the failure mode is materially worse than a normal 404 (silent CORS rejection vs. a resolvable 404 response), and once poisoned into the edge cache the bad response persists for up to an hour with a public max-age. Fix: set if (missingSymbolCache.get(cacheKey)) {
return res.writeHead(404, {
'Cache-Control': MISSING_CACHE_CONTROL,
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
}).end();
}This is a small, low-risk addition since the same static header values are already applied unconditionally elsewhere in the file, showing the intent is for all responses (not just proxied ones) to be CORS-permissive. |
||
|
|
||
| proxy.web(req, res, { target: TARGET_URL }); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 The negative-cache key is derived from path+query only, never HTTP method, but the server proxies every method identically — so a single non-GET request (e.g. a stray POST/PUT, or a CORS-preflight OPTIONS) that Azure Blob rejects with 403 for a genuinely-existing symbol gets recorded as a miss and rewritten to 404. Previously that pollution was confined to one dyno's in-memory cache; this PR adds
Cache-Control: public, max-age=3600to that rewritten 404 (src/index.ts:107-109), so Cloudflare now caches and broadcasts the false-negative to every edge PoP for an hour, making a real symbol unavailable to all GET clients with no way to invalidate early. Consider gating the negative-cache write/read (and the 403->404 rewrite) onrequest.method === '\''GET'\''.Extended reasoning...
The bug.
missingSymbolCacheis keyed purely byproxyReq.path/cacheKey(path + query), and the handler inhttp.createServerhas no method dispatch at all — every request other than/healthand the symbolicator redirect falls through toproxy.web(req, res, ...)regardless of verb. In theproxyReqhook'swriteHeadwrapper (src/index.ts:101-109), any upstream 403 — irrespective of the method that produced it — is unconditionally recorded viamissingSymbolCache.set(proxyReq.path, true)and rewritten to 404.Why a non-GET 403 is realistic. Azure Blob Storage (the backing store referenced throughout the comments) returns 403, not 404, for anonymous non-GET operations against a path whose blob genuinely exists: write verbs (POST/PUT/DELETE) against a read-only public container are unauthorized, and a CORS-preflight OPTIONS without a matching storage-account CORS rule also comes back 403. Both happen independent of whether the target blob is actually present. Given the proxy advertises
Access-Control-Allow-Origin: *, a scanner, bot, or even a legitimate cross-origin browser fetch that triggers a preflight can hit an existing symbol path with a non-GET request and get a 403 from upstream.What this PR changes. Before this PR, that method-agnostic 403-to-404 rewrite already happened, but the resulting 404 carried no
Cache-Controlheader, so the pollution was bounded to whatever the single dyno's in-memory LRU held (with no TTL enforcement issue for HTTP caching, since nothing downstream would cache it). This PR addsresponse.setHeader('Cache-Control', MISSING_CACHE_CONTROL)in that exact 403 branch (nowpublic, max-age=3600), and the cache-hit path at the bottom of the handler (missingSymbolCache.get(cacheKey)) also now emits that header on the cached 404. Since Cloudflare fronts this origin (per the PR description, WAF + rate limit + edge cache), a 404 with a publicCache-Controlwill be cached and served at every edge PoP — this turns a previously per-dyno, memory-only false negative into a TTL-guaranteed, globally broadcast outage for a real symbol.Step-by-step proof.
/electron/foo.pdb/ABCDEF/foo.pdbon the upstream store.OPTIONSorPOSTto that exact path.proxy.web.writeHeadwrapper seesargs[0] == 403, callsmissingSymbolCache.set(proxyReq.path, true), rewrites to 404, and — new in this PR — setsCache-Control: public, max-age=3600.GETfor that exact symbol path, from any client anywhere, is now served the cached 404 by Cloudflare — the origin is never even reached again — for up to one hour, with no mechanism to purge it early.Why nothing today prevents this. There is no method check anywhere in
src/index.ts; the cache key construction (incomingPathToProxyPath) only normalizes case/aliasing of the path and never touchesreq.method; and the CORS headers set elsewhere (Access-Control-Allow-Methods: GET) are advisory to browsers only — they do not stop the server itself from proxying non-GET requests upstream or from trusting the 403 it gets back.Fix. Gate the negative-cache write (and ideally the 403->404 rewrite itself, or the whole proxy) on
request.method === 'GET'— e.g. only callmissingSymbolCache.set(...)whenrequest.method === 'GET', and/or short-circuit non-GET/non-HEAD requests with a 405 before they ever reachproxy.web. This is a small, low-risk change that removes the amplification entirely.I'm flagging this as normal severity rather than nit specifically because this PR is what makes the failure mode global and durable: pre-PR it was a bounded, single-dyno, best-effort in-memory artifact; post-PR it is a Cloudflare-wide, hour-long, unrecoverable-without-a-manual-purge outage for a real symbol, triggered by a single stray request. That's a concrete availability regression a real user (or CI job pulling symbols) could hit, not just a cosmetic gap.