Component: src/http/cache/RateLimit.ts
Severity (assessment): LOW
CWE: CWE-693
rateLimit, cached and idempotent all store their state in one Cache instance (every doc example passes the same ext.cache()), and the default backing store is an InMemoryCache bounded by a 10 000-entry LRU. Because cached/idempotent let an unauthenticated client mint an unbounded number of distinct keys, a client can push security-critical entries (rl: counters, idem: records) out of the store and reset them.
Exploit walkthrough
Unauthenticated attacker sends ~10 050 GETs to any endpoint wrapped in cached() with a distinct query value each time — the documented key shape is key: (request) => \search:${request.query.q}`— or the same number of POSTs with distinctIdempotency-Keyheader values. Each insert callsevictIfNeeded, which deletes the Map head. Reproduced end-to-end against the real modules: a client that rateLimit({max: 5})had already answered 429 goes back to 200 after the flood (rate limit fully reset, no waiting for the window), and a victim'sidem:victim-key` record is dropped so the victim's honest retry of the identical POST re-executes the handler a second time (charge counter went 1 -> 2). Gain: rate-limit bypass on demand, plus a forced double-execution of any exactly-once operation belonging to another user.
Evidence — src/http/cache/RateLimit.ts:65
src/http/cache/RateLimit.ts:62-65
const cacheKey = `${prefix}${userKey}`;
let count: number;
try {
count = await resolvedOptions.cache.incr(cacheKey, resolvedOptions.windowMs);
src/http/cache/IdempotencyKey.ts:104
const claimed = await resolvedOptions.cache.setIfAbsent(cacheKey, IN_FLIGHT_MARKER, ttlMs);
src/http/cache/ResponseCache.ts:71-72 (unbounded, request-derived key)
const userKey = await options.key(request);
const cacheKey = `${prefix}${userKey}`;
src/cache/InMemoryCache.ts:170-178 (the eviction, no exemption for security state)
private evictIfNeeded(incomingKey: string): void {
if (!Number.isFinite(this.maxEntries)) return;
if (this.store.has(incomingKey)) return;
while (this.store.size >= this.maxEntries) {
const lru = this.store.keys().next().value as string | undefined;
if (lru === undefined) break;
this.store.delete(lru);
}
}
src/cache/InMemoryCache.ts:22-27 (the claim this bound is the defence)
* Bounded by `maxEntries` (default 10 000): inserting a new key beyond the cap
* evicts the least-recently-used entry, so a flood of distinct keys — e.g.
* attacker-chosen `Idempotency-Key` or rate-limit keys — cannot grow the map
* without limit (security audit HTTP-2).
src/cache/CacheExtension.ts:62-71 — `cache(name = 'default')` memoises ONE instance, so `ext.cache()` in all three middleware examples is the same store.
Why the existing guard does not cover it
I looked for (a) separate stores per middleware — there are only key prefixes (rl:, rsp:, idem:) inside one shared instance, which is exactly why they collide in one LRU; (b) an eviction exemption or pinning for security-relevant keys in InMemoryCache.evictIfNeeded — it deletes the Map head unconditionally; (c) a per-client cap on how many distinct keys one caller may create — none in RateLimit.ts, ResponseCache.ts or IdempotencyKey.ts (the Idempotency-Key header value goes into the key raw and unbounded at IdempotencyKey.ts:64-75); (d) a regression test — tests/unit/cache pins maxEntries growth, and tests/unit/http/cache/*.test.ts pin functional replay/limit behaviour, but nothing pins that a key flood cannot evict a counter or an idempotency record. The maxEntries bound added for HTTP-2 is the guard I expected to help; it fixes OOM but is itself the mechanism here, because it trades unbounded memory for silent loss of protection state.
Suggested fix
Stop putting evictable and security-critical state in one store. Have rateLimit and idempotent resolve their own named caches (ext.cache('rate-limit') / ext.cache('idempotency')) rather than sharing cache('default'), and/or give InMemoryCache per-prefix quotas so rsp: inserts can only evict rsp: entries. Additionally cap the accepted Idempotency-Key (length + charset, mirroring KeyValidator) so one client cannot mint unlimited keys, and document that a shared evicting cache voids both the rate-limit and the exactly-once guarantee.
Verification status
Found in the whole-framework security audit of 2026-08-01 (v0.12.0), then adjudicated by an independent verifier instructed to refute it. Marked UNCERTAIN — whether this bites depends on how an application wires it up; see the verifier note below.
Verifier note
The mechanism exists but the exploit path as described does not hold under the documented composition, and nothing in shipped code is defective. InMemoryCache.evictIfNeeded (src/cache/InMemoryCache.ts:170-178) does evict the Map head unconditionally, and CacheExtension.cache() (src/cache/CacheExtension.ts:62-71) memoises one instance per name, so three middlewares using ext.cache() share one store. BUT: InMemoryCache.incr calls this.bump(key, entry) (line 93), and bump re-inserts the key at the Map TAIL (lines 157-162) — so the attacker's own rl:<ip> entry is the MOST-recently-used entry after every single request and can never be the eviction victim. In the composition the docs actually show (docs/src/content/docs/http/overview.mdx:117-127, rateLimit wrapping cached), each flood request also increments rl:<ip>, so the flood is itself capped at max per window AND keeps the counter pinned at the tail. The attack therefore requires an app-specific wiring the framework does not prescribe: a high-cardinality-key cached()/idempotent() endpoint that is NOT behind the same rateLimit yet shares the same Cache instance. The claim 'every doc example passes the same ext.cache()' is also overstated — docs/src/content/docs/http/middleware/rate-limit.mdx:16 and .../response-cache.mdx:15 each construct a separate new InMemoryCache(); only the JSDoc usage snippets (RateLimit.ts:31, ResponseCache.ts:24, IdempotencyKey.ts:28) all say ext.cache(). Note also that the same eviction pressure exists inside a dedicated rate-limit cache (an attacker with an IPv6 /64 mints unlimited rl: keys), so this is an inherent trade-off of bounded in-process rate limiting, not a defect introduced by key sharing. Worth a docs/JSDoc fix (steer to ext.cache('rate-limit') / ext.cache('idempotency')) — not a high-severity vulnerability.
Correction applied: Restated accurately: a bounded shared in-memory Cache means response-cache inserts can evict rate-limit counters and idempotency records belonging to OTHER keys — but not the flooding client's own rate-limit counter (it is bumped to MRU on every incr). Exploitation requires the application to (a) pass one Cache instance to several middlewares and (b) expose an attacker-key-minting cached/idempotent endpoint outside the rate limiter.
Component:
src/http/cache/RateLimit.tsSeverity (assessment): LOW
CWE: CWE-693
rateLimit,cachedandidempotentall store their state in oneCacheinstance (every doc example passes the sameext.cache()), and the default backing store is anInMemoryCachebounded by a 10 000-entry LRU. Becausecached/idempotentlet an unauthenticated client mint an unbounded number of distinct keys, a client can push security-critical entries (rl:counters,idem:records) out of the store and reset them.Exploit walkthrough
Unauthenticated attacker sends ~10 050 GETs to any endpoint wrapped in
cached()with a distinct query value each time — the documented key shape iskey: (request) => \search:${request.query.q}`— or the same number of POSTs with distinctIdempotency-Keyheader values. Each insert callsevictIfNeeded, which deletes the Map head. Reproduced end-to-end against the real modules: a client thatrateLimit({max: 5})had already answered 429 goes back to 200 after the flood (rate limit fully reset, no waiting for the window), and a victim'sidem:victim-key` record is dropped so the victim's honest retry of the identical POST re-executes the handler a second time (charge counter went 1 -> 2). Gain: rate-limit bypass on demand, plus a forced double-execution of any exactly-once operation belonging to another user.Evidence —
src/http/cache/RateLimit.ts:65Why the existing guard does not cover it
I looked for (a) separate stores per middleware — there are only key prefixes (
rl:,rsp:,idem:) inside one shared instance, which is exactly why they collide in one LRU; (b) an eviction exemption or pinning for security-relevant keys inInMemoryCache.evictIfNeeded— it deletes the Map head unconditionally; (c) a per-client cap on how many distinct keys one caller may create — none inRateLimit.ts,ResponseCache.tsorIdempotencyKey.ts(theIdempotency-Keyheader value goes into the key raw and unbounded at IdempotencyKey.ts:64-75); (d) a regression test — tests/unit/cache pinsmaxEntriesgrowth, and tests/unit/http/cache/*.test.ts pin functional replay/limit behaviour, but nothing pins that a key flood cannot evict a counter or an idempotency record. ThemaxEntriesbound added for HTTP-2 is the guard I expected to help; it fixes OOM but is itself the mechanism here, because it trades unbounded memory for silent loss of protection state.Suggested fix
Stop putting evictable and security-critical state in one store. Have
rateLimitandidempotentresolve their own named caches (ext.cache('rate-limit')/ext.cache('idempotency')) rather than sharingcache('default'), and/or giveInMemoryCacheper-prefix quotas sorsp:inserts can only evictrsp:entries. Additionally cap the acceptedIdempotency-Key(length + charset, mirroringKeyValidator) so one client cannot mint unlimited keys, and document that a shared evicting cache voids both the rate-limit and the exactly-once guarantee.Verification status
Found in the whole-framework security audit of 2026-08-01 (
v0.12.0), then adjudicated by an independent verifier instructed to refute it. Marked UNCERTAIN — whether this bites depends on how an application wires it up; see the verifier note below.Verifier note
The mechanism exists but the exploit path as described does not hold under the documented composition, and nothing in shipped code is defective. InMemoryCache.evictIfNeeded (src/cache/InMemoryCache.ts:170-178) does evict the Map head unconditionally, and CacheExtension.cache() (src/cache/CacheExtension.ts:62-71) memoises one instance per name, so three middlewares using
ext.cache()share one store. BUT: InMemoryCache.incr callsthis.bump(key, entry)(line 93), and bump re-inserts the key at the Map TAIL (lines 157-162) — so the attacker's ownrl:<ip>entry is the MOST-recently-used entry after every single request and can never be the eviction victim. In the composition the docs actually show (docs/src/content/docs/http/overview.mdx:117-127, rateLimit wrapping cached), each flood request also incrementsrl:<ip>, so the flood is itself capped atmaxper window AND keeps the counter pinned at the tail. The attack therefore requires an app-specific wiring the framework does not prescribe: a high-cardinality-keycached()/idempotent()endpoint that is NOT behind the same rateLimit yet shares the same Cache instance. The claim 'every doc example passes the same ext.cache()' is also overstated — docs/src/content/docs/http/middleware/rate-limit.mdx:16 and .../response-cache.mdx:15 each construct a separatenew InMemoryCache(); only the JSDoc usage snippets (RateLimit.ts:31, ResponseCache.ts:24, IdempotencyKey.ts:28) all sayext.cache(). Note also that the same eviction pressure exists inside a dedicated rate-limit cache (an attacker with an IPv6 /64 mints unlimitedrl:keys), so this is an inherent trade-off of bounded in-process rate limiting, not a defect introduced by key sharing. Worth a docs/JSDoc fix (steer toext.cache('rate-limit')/ext.cache('idempotency')) — not a high-severity vulnerability.Correction applied: Restated accurately: a bounded shared in-memory Cache means response-cache inserts can evict rate-limit counters and idempotency records belonging to OTHER keys — but not the flooding client's own rate-limit counter (it is bumped to MRU on every incr). Exploitation requires the application to (a) pass one Cache instance to several middlewares and (b) expose an attacker-key-minting cached/idempotent endpoint outside the rate limiter.