From codex-security candidate verification. Ranked by real exploitability rather than by how the scan ordered them — and two of the six are recommended won't-fix, with reasons.
Highest amplification: canonical-reference resolver cache (candidate-96b469a344efc673)
src/lib/canonical-references/index.ts:232-247, :418-441. createCanonicalReferenceResolver creates a Map-backed cache with no max entries, max bytes, or TTL. Seller-supplied format_schema URIs are the intended input, and cloneResolved (:219-229) stores Uint8Array.from(body) + structuredClone(document) + text — roughly 3-4 MiB retained per unique reference against a DEFAULT_MAX_BODY_BYTES of 1 MiB. So ~1.4 GB of attacker transfer pins ~4 GB of buyer heap, keyed on attacker-varying URI + digest.
docs/guides/CANONICAL-REFERENCE-RESOLVER.md:62 documents the cache as "caller-owned … fresh per resolver", so the injectable-store pattern applies — but the zero-arg default is unbounded and that's what adopters get. Add maxEntries / maxBytes + TTL to the default.
A2A client cache has no eviction while its MCP sibling does (candidate-6d425da64771c133)
src/lib/protocols/a2a.ts:75-94. a2aClientCache is keyed by URL/auth/headers/signing-identity with no eviction outside a global closeConnections('a2a'). mcp-modern.ts:74 has MAX_CACHED_CONNECTIONS = 20 with evictLeastRecentlyUsed().
The asymmetry looks like an oversight rather than a decision: getOrCreateA2AClient already bypasses the cache for scoped fetchFn with a comment citing "short-lived closure identities do not grow the global cache without bound" — so growth was considered, just not capped on the main path. Mitigator: only successful agent-card discoveries are cached, so each entry costs a real round trip. Mirror the MCP LRU.
Missing timeout on the upstream helper (candidate-4b301f405c1f1118)
src/lib/server/upstream-helpers.ts:258-273. No AbortSignal, and res.text() buffers the whole body before truncating it for the error message. The missing timeout matters more than the missing size cap — a slow or hung upstream holds the connection indefinitely from inside a request handler. signal: AbortSignal.timeout(n) plus a byte-counted stream read.
Multi-host metadata Maps keyed on the Host header (candidate-6a8775efebc5c905) — recommend won't-fix, document instead
src/lib/server/serve.ts:355-401 memoizes function-form publicUrl, origin, and protected-resource metadata in three process-lifetime Maps. The key is genuinely attacker-controlled — serve.ts:892-908 returns the lowercased raw header with no allowlist, and the /.well-known/oauth-protected-resource branch that populates all three is explicitly auth-free (:402-410).
What stops it in practice: a resolver that throws or returns an invalid value never reaches .set(), so the adopter's own resolver is the de facto gate. Recommend an LRU cap only if cheap, but the real fix is a sentence in the JSDoc at :192-196 — which currently promises memoization without mentioning it is unbounded — telling adopters their multi-host resolver must reject unknown hosts.
Webhook idempotency-key store (candidate-b53f9915f7a988c3) — won't-fix as filed; the obvious fix is a correctness bug
src/lib/server/webhook-emitter.ts:57-64, :444-467. memoryWebhookKeyStore does retain every operation_id forever. But growth is linear with legitimate business volume at ~100 bytes/entry, and :41-49 is the documented "inject a durable backend" pattern.
The trap worth recording: the JSDoc calls key stability across retries "the load-bearing invariant the receiver-side dedup depends on." A naive LRU cap would evict a live operation and make resolveIdempotencyKey mint a new key on retry — converting a slow memory leak into silent duplicate-processing at the receiver. Only a TTL keyed to the retry/dedup window is safe here. Do not "fix" this with a max-entries cap.
Also
bin/adcp-async-handler.js:85-93 unbounded body buffering is filed with the CLI webhook auth work in #2426, since both live in the same function and want one edit.
From
codex-securitycandidate verification. Ranked by real exploitability rather than by how the scan ordered them — and two of the six are recommended won't-fix, with reasons.Highest amplification: canonical-reference resolver cache (
candidate-96b469a344efc673)src/lib/canonical-references/index.ts:232-247,:418-441.createCanonicalReferenceResolvercreates a Map-backed cache with no max entries, max bytes, or TTL. Seller-suppliedformat_schemaURIs are the intended input, andcloneResolved(:219-229) storesUint8Array.from(body)+structuredClone(document)+text— roughly 3-4 MiB retained per unique reference against aDEFAULT_MAX_BODY_BYTESof 1 MiB. So ~1.4 GB of attacker transfer pins ~4 GB of buyer heap, keyed on attacker-varying URI + digest.docs/guides/CANONICAL-REFERENCE-RESOLVER.md:62documents the cache as "caller-owned … fresh per resolver", so the injectable-store pattern applies — but the zero-arg default is unbounded and that's what adopters get. AddmaxEntries/maxBytes+ TTL to the default.A2A client cache has no eviction while its MCP sibling does (
candidate-6d425da64771c133)src/lib/protocols/a2a.ts:75-94.a2aClientCacheis keyed by URL/auth/headers/signing-identity with no eviction outside a globalcloseConnections('a2a').mcp-modern.ts:74hasMAX_CACHED_CONNECTIONS = 20withevictLeastRecentlyUsed().The asymmetry looks like an oversight rather than a decision:
getOrCreateA2AClientalready bypasses the cache for scopedfetchFnwith a comment citing "short-lived closure identities do not grow the global cache without bound" — so growth was considered, just not capped on the main path. Mitigator: only successful agent-card discoveries are cached, so each entry costs a real round trip. Mirror the MCP LRU.Missing timeout on the upstream helper (
candidate-4b301f405c1f1118)src/lib/server/upstream-helpers.ts:258-273. NoAbortSignal, andres.text()buffers the whole body before truncating it for the error message. The missing timeout matters more than the missing size cap — a slow or hung upstream holds the connection indefinitely from inside a request handler.signal: AbortSignal.timeout(n)plus a byte-counted stream read.Multi-host metadata Maps keyed on the
Hostheader (candidate-6a8775efebc5c905) — recommend won't-fix, document insteadsrc/lib/server/serve.ts:355-401memoizes function-formpublicUrl, origin, and protected-resource metadata in three process-lifetime Maps. The key is genuinely attacker-controlled —serve.ts:892-908returns the lowercased raw header with no allowlist, and the/.well-known/oauth-protected-resourcebranch that populates all three is explicitly auth-free (:402-410).What stops it in practice: a resolver that throws or returns an invalid value never reaches
.set(), so the adopter's own resolver is the de facto gate. Recommend an LRU cap only if cheap, but the real fix is a sentence in the JSDoc at:192-196— which currently promises memoization without mentioning it is unbounded — telling adopters their multi-host resolver must reject unknown hosts.Webhook idempotency-key store (
candidate-b53f9915f7a988c3) — won't-fix as filed; the obvious fix is a correctness bugsrc/lib/server/webhook-emitter.ts:57-64,:444-467.memoryWebhookKeyStoredoes retain everyoperation_idforever. But growth is linear with legitimate business volume at ~100 bytes/entry, and:41-49is the documented "inject a durable backend" pattern.The trap worth recording: the JSDoc calls key stability across retries "the load-bearing invariant the receiver-side dedup depends on." A naive LRU cap would evict a live operation and make
resolveIdempotencyKeymint a new key on retry — converting a slow memory leak into silent duplicate-processing at the receiver. Only a TTL keyed to the retry/dedup window is safe here. Do not "fix" this with a max-entries cap.Also
bin/adcp-async-handler.js:85-93unbounded body buffering is filed with the CLI webhook auth work in #2426, since both live in the same function and want one edit.