Skip to content
51 changes: 47 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
};
Comment on lines 101 to 114

Copy link
Copy Markdown
Contributor Author

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=3600 to 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) on request.method === '\''GET'\''.

Extended reasoning...

The bug. missingSymbolCache is keyed purely by proxyReq.path / cacheKey (path + query), and the handler in http.createServer has no method dispatch at all — every request other than /health and the symbolicator redirect falls through to proxy.web(req, res, ...) regardless of verb. In the proxyReq hook's writeHead wrapper (src/index.ts:101-109), any upstream 403 — irrespective of the method that produced it — is unconditionally recorded via missingSymbolCache.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-Control header, 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 adds response.setHeader('Cache-Control', MISSING_CACHE_CONTROL) in that exact 403 branch (now public, 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 public Cache-Control will 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.

  1. A symbol genuinely exists at /electron/foo.pdb/ABCDEF/foo.pdb on the upstream store.
  2. A scanner (or a CORS preflight, or a stray non-GET client) sends OPTIONS or POST to that exact path.
  3. The proxy has no method check, so it forwards the request upstream via proxy.web.
  4. Azure Blob Storage responds 403 (anonymous non-GET against a real, read-only blob is unauthorized / CORS-rejected), independent of blob existence.
  5. The writeHead wrapper sees args[0] == 403, calls missingSymbolCache.set(proxyReq.path, true), rewrites to 404, and — new in this PR — sets Cache-Control: public, max-age=3600.
  6. Cloudflare, sitting in front of the origin, caches this 404 response for the path at the edge.
  7. Every subsequent legitimate GET for 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 touches req.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 call missingSymbolCache.set(...) when request.method === 'GET', and/or short-circuit non-GET/non-HEAD requests with a 405 before they ever reach proxy.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.

Expand All @@ -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'
});
Expand All @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The missingSymbolCache hit path (src/index.ts:156-158) returns a 404 directly without going through proxy.web, so it never gets the Access-Control-Allow-Origin/Access-Control-Allow-Methods headers that are set only inside proxy.on('proxyReq', ...). This CORS gap pre-dates the PR, but the PR now marks these 404s public, max-age=3600 and edge-cacheable, so a CORS-less 404 could populate Cloudflare's edge cache and be served to browser-based CORS clients for up to an hour. Consider setting the two CORS headers on the cache-hit path as well.

Extended reasoning...

The bug: CORS headers (Access-Control-Allow-Origin: * and Access-Control-Allow-Methods: GET) are set in exactly one place in this file: inside proxy.on('proxyReq', (proxyReq, request, response, options) => { ... }) at src/index.ts:95-96. That handler only fires when proxy.web(req, res, ...) is actually invoked. The negative-cache hit path in the main http.createServer handler:

if (missingSymbolCache.get(cacheKey)) {
  return res.writeHead(404, { 'Cache-Control': MISSING_CACHE_CONTROL }).end();
}

returns directly and never calls proxy.web, so it never runs proxyReq and never sets the CORS headers. Compare this to a live 403->404 conversion, which happens inside the writeHead wrapper installed by the proxyReq handler — that path does carry the CORS headers because they were already set earlier in the same handler, before the wrapped writeHead is even called.

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 Cache-Control: public, max-age=3600 to the cache-hit 404 (line 157), which is a signal that tells Cloudflare's edge it may cache and serve this exact response to future requests without touching the origin at all.

Concrete walk-through of the failure mode:

  1. Client A requests /electron/foo.pdb/ABC123/foo.pdb for a symbol that doesn't exist. This is a cache miss, so it goes through proxy.web -> proxyReq -> upstream returns 403 -> rewritten to 404 with CORS headers set, and the origin process's missingSymbolCache now has an entry for this path (TTL 1h).
  2. A little later, but before Cloudflare's edge has cached anything for that URL at the specific PoP handling the next request (different Cloudflare PoP, or the edge cache simply hasn't been populated yet for that exact path), a second request for the same path lands on the same origin dyno (or one with a warm process, e.g. across a load-balanced pool that isn't sticky, or on the same dyno if there's only one). This request hits the missingSymbolCache.get(cacheKey) branch and returns a 404 with Cache-Control: public, max-age=3600 but no Access-Control-Allow-Origin header.
  3. Cloudflare's edge cache, honoring that Cache-Control, stores this CORS-less 404 as the canonical cached response for the path for up to an hour.
  4. Any browser-based client (e.g. a web-based crash-symbolication UI or dev tool doing a fetch() with CORS enforcement) that requests the same path within that hour gets the edge-cached CORS-less 404. Since there's no Access-Control-Allow-Origin header, the browser's CORS check fails and the fetch() promise rejects with a network/CORS error instead of resolving to a normal 404 response.

Why existing code doesn't prevent it: the CORS headers are hard-coded into the proxyReq event handler specifically, with no equivalent guard or shared helper for cache-hit responses. There is nothing stopping the negative-cache branch from being the one that "wins" and gets cached at the edge, since which of the two code paths executes for a given request is purely a function of in-memory cache state at that origin process, uncorrelated with Cloudflare's edge cache state for that same URL.

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 Access-Control-Allow-Origin: * and Access-Control-Allow-Methods: GET on the cache-hit response too, e.g.:

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 });
Expand Down
58 changes: 58 additions & 0 deletions test/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,64 @@ test('upstream non-403 errors are passed through and not cached as missing', asy
assert.equal(calls, 2);
});

test('negative-cache 404s carry a public Cache-Control header', async (t) => {
const { server } = await startProxy(t, {
handler: (req, res) => {
res.writeHead(403);
res.end();
},
});

const first = await request(server.port, '/missing/foo.pdb/abc/foo.pdb');
assert.equal(first.statusCode, 404);
assert.equal(first.headers['cache-control'], 'public, max-age=3600');

// Served from the negative cache without contacting upstream.
const second = await request(server.port, '/missing/foo.pdb/abc/foo.pdb');
assert.equal(second.statusCode, 404);
assert.equal(second.headers['cache-control'], 'public, max-age=3600');
});

test('successful 200s get a long immutable Cache-Control when upstream sends none', async (t) => {
const { server } = await startProxy(t, {
handler: (req, res) => {
res.writeHead(200);
res.end('SYMBOL-DATA');
},
});

const res = await request(server.port, '/foo/bar.pdb/abc/foo.pdb');
assert.equal(res.statusCode, 200);
assert.equal(res.headers['cache-control'], 'public, max-age=604800, immutable');
});

test('upstream Cache-Control on 200s is preserved', async (t) => {
const { server } = await startProxy(t, {
handler: (req, res) => {
res.writeHead(200, { 'cache-control': 'public, max-age=60' });
res.end('SYMBOL-DATA');
},
});

const res = await request(server.port, '/foo/bar.pdb/abc/foo.pdb');
assert.equal(res.statusCode, 200);
assert.equal(res.headers['cache-control'], 'public, max-age=60');
});

test('redirect responses are cacheable by Cloudflare only', async (t) => {
const server = await startSymbolServer({ targetHost: 'symbols.example.test' });
t.after(() => server.stop());

const res = await request(server.port, '/Foo/Bar', {
'user-agent': 'symbolicator/1.2.3',
});
assert.equal(res.statusCode, 302);
// Generic shared caches and browsers must never store the redirect...
assert.equal(res.headers['cache-control'], 'no-store');
// ...while Cloudflare (whose cache key separates the redirect cohort) may.
assert.equal(res.headers['cloudflare-cdn-cache-control'], 'public, max-age=3600');
});

test('proxy returns 500 with error ID when upstream is unreachable', async (t) => {
const server = await startSymbolServer({ targetHost: '127.0.0.1:1' });
t.after(() => server.stop());
Expand Down
Loading