Summary
The console API's createLoginRateLimiter and createMutationRateLimiter stored one Map entry per client key with no upper bound and no expiry sweep, so an attacker rotating source addresses or identifiers could grow those maps until the Node process hit an out-of-memory crash. The fix introduces a maxTrackedKeys option (default 5000), a trackedEntryLimit sanitizer, and an evictOldestIfFull helper that drops the oldest tracked key while protecting the shared global counter. The rate limiter that is supposed to absorb abuse — including floods of concurrent video uploads — can no longer be turned into the denial-of-service vector itself.
Introduction
This high-severity denial-of-service issue lived inside the defense, not in front of it. The console API exposes two rate-limiter factories — createLoginRateLimiter() and createMutationRateLimiter() — and both keep their counters in a plain Map keyed by whatever identifier the caller passes to check(key) or registerFailure(key). In practice that key is derived from the request: a client IP, a forwarded address, an account or device identifier.
That design has a sharp edge. The map grows by one entry for every distinct key the server has ever seen, and nothing ever removes entries for keys that never come back. There is per-key window logic (windowMs, blockMs) that decides whether a counter is stale when the key is looked up again, but there is no background sweep and no ceiling on attempts.size or requests.size. An attacker who can vary the key — trivial with an IPv6 /64, a proxy pool, or a client-supplied identifier — makes the limiter allocate forever.
The trigger for this review was the video upload path. That endpoint enforces a per-file cap via MAX_VIDEO_UPLOAD_BYTES and reads bodies in chunks, so a single request cannot blow up the heap. What it does not do on its own is bound concurrency — that is the mutation limiter's job (maxRequests = 20 per key, globalMaxRequests = 200). If the limiter's own state can be driven to OOM, the byte cap protects individual requests while the process that enforces it dies anyway.
Affected Versions
| Affected | not applicable (first-party code) — all revisions prior to the fix commit |
| Fixed in | not applicable (first-party code) — the commit adding maxTrackedKeys and evictOldestIfFull |
| Ecosystem | N/A (first-party Node.js / ES module code) |
| CVE / GHSA | not assigned |
| CWE | CWE-400: Uncontrolled Resource Consumption |
The Vulnerability Explained
Here is the shape of the vulnerable state, before the fix:
export function createLoginRateLimiter(options = {}) {
const {
maxAttempts = 8,
globalMaxAttempts = 32,
windowMs = 15 * 60 * 1000,
blockMs = 15 * 60 * 1000,
now = () => Date.now()
} = options;
const attempts = new Map();
And the write path that grows it:
const next = ...
? { count: 1, firstAttemptAt: timestamp, blockedUntil: 0 }
: { ...current, count: current.count + 1 };
if (next.count >= limit) next.blockedUntil = timestamp + blockMs;
attempts.set(key, next);
attempts.set(key, next) is the problematic line. There is no check on attempts.size before it, and no code path anywhere that deletes an entry whose window has lapsed without a matching second request. createMutationRateLimiter has the identical pattern with its own requests map and __global_mutations__ key.
Exploiting it
The attack does not need valid credentials or a valid upload. It needs distinct keys:
- Send a failed login for each of N unique source addresses. Every failure calls
registerFailure(key), which inserts a fresh record{ count: 1, firstAttemptAt, blockedUntil: 0 }. - Never reuse an address. Each entry is now unreachable garbage that the limiter still holds a strong reference to.
- Repeat. With key strings plus a three-field object and V8 map overhead, a conservative estimate is on the order of 150–250 bytes per entry — so a few million unique keys is several hundred megabytes of live heap, on top of normal working set.
The same works through the mutation limiter, which is hit by ordinary state-changing requests including uploads. There, the attacker gets a bonus: the per-key limit of maxRequests = 20 never engages, because each request uses a new key and therefore never accumulates. Only globalMaxRequests = 200 applies — enough to slow the flood but not to stop the map from growing, since the global counter is checked and the per-key entry is still written.
The end state is the Node process exceeding its heap limit and being killed. Because the limiter is shared infrastructure for the console API, that failure is not scoped to one endpoint: login, mutations, and video uploads all go down together. And because the blocked-state records live only in memory, a restart clears every active block, which is exactly the state an attacker brute-forcing logins wants.
The Fix
Three coordinated changes bound the state.
1. A FIFO eviction helper. Map iteration order is insertion order, so the first non-protected key is the oldest tracked key:
function evictOldestIfFull(map, maxEntries, protectedKey) {
if (map.size < maxEntries) return;
for (const key of map.keys()) {
if (key === protectedKey) continue;
map.delete(key);
return;
}
}
The protectedKey skip is the important detail. Each limiter keeps a shared aggregate counter under __global__ (login) or __global_mutations__ (mutations), and that counter is inserted early — so naive FIFO eviction would delete it first and destroy the only control that still works when per-key tracking is being churned. Skipping it keeps globalMaxAttempts = 32 and globalMaxRequests = 200 in force no matter how hard the key space is rotated.
2. A validated limit. The new maxTrackedKeys option defaults to 5000 and is passed through a sanitizer:
function trackedEntryLimit(value) {
const limit = Math.floor(Number(value));
return Number.isFinite(limit) && limit >= 2 ? limit : 5000;
}
A caller passing 0, 1, NaN, Infinity, or a string gets the safe default rather than a degenerate map that has no room for a tracked key beside the protected global counter.
3. Eviction wired into the insert path. In both factories, the write now looks like this:
if (!attempts.has(key)) evictOldestIfFull(attempts, entryLimit, globalKey);
attempts.set(key, next);
The !attempts.has(key) guard means updates to an existing counter — the normal case for a real client hammering login — never evict anything. Only new keys, the exact thing an attacker manufactures, can trigger eviction. Worst-case memory for each limiter is now a constant: roughly 5000 entries plus the global counter, on the order of a megabyte, regardless of whether the attacker sends a thousand unique keys or a billion.
The tradeoff, stated plainly
Eviction is FIFO, not LRU. Under a sustained flood of unique keys, a long-standing blockedUntil record can be evicted before it expires, which means a determined attacker can wash out their own per-key block by pushing ~5000 fresh keys through. That is a deliberate trade: a partially degraded per-key block with the global counter intact is strictly better than a process that OOMs and resets every block. Deployments that need stronger guarantees should raise maxTrackedKeys to fit their heap budget, move limiter state to a shared store with real TTLs, and keep enforcing key normalization (for example, bucketing IPv6 clients by prefix rather than by full address) so that a /64 cannot masquerade as 18 quintillion distinct clients.
One note on confidence: no automated test suite could be executed against this code during the fix, so the change is reviewed but not verified by CI. Treat it as a reviewed suggestion and add a regression test that inserts maxTrackedKeys + 1 distinct keys and asserts both the final map size and the survival of the global counter.
Key Takeaways
- An in-memory
Mapkeyed by a client identifier is unbounded storage controlled by the client;windowMsandblockMsdecide whether a counter is stale, not whether it is still resident. - Rate limiters are a favorite DoS target precisely because they must allocate before they can decide. Bound the tracking structure (
maxTrackedKeys) as well as the request rate. - When you add FIFO eviction to a map that also holds aggregate state, protect that key explicitly —
evictOldestIfFull(map, maxEntries, protectedKey)exists because__global__is inserted first and would otherwise be evicted first. - Guard eviction with
!map.has(key)so repeat clients updating an existing counter never pay the eviction cost, and only novel keys — the attacker's tool — can trigger it. - A per-request size cap like
MAX_VIDEO_UPLOAD_BYTESplus chunked reading solves single-request memory blowup and says nothing about concurrency; the concurrency control has to survive attack on its own.
How Orbis AppSec Detected This
- Source: the client identifier passed to
createLoginRateLimiter().registerFailure(key)/check(key)and tocreateMutationRateLimiter().check(key)— derived from request-controlled data such as the remote or forwarded address, or a client-supplied identifier. - Sink:
Map.prototype.set()on the limiters' internalattemptsandrequestsmaps, executed once per previously unseen key. - Missing control: no maximum entry count, no eviction policy, and no expiry sweep for keys that are never observed again — so cardinality of the key space, not request volume, determined heap usage.
- CWE: CWE-400: Uncontrolled Resource Consumption.
- Fix: Added a validated
maxTrackedKeysbound (default 5000) andevictOldestIfFull, which removes the oldest tracked key before inserting a new one while never evicting the protected global counter key.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
The per-request defenses in this API were sound: uploads were capped by MAX_VIDEO_UPLOAD_BYTES and streamed in chunks, logins were throttled to maxAttempts = 8 per key with a globalMaxAttempts = 32 backstop. The gap was that the bookkeeping for those throttles grew one entry per unique key, forever, so an attacker rotating addresses could crash the process that enforced every other limit — and clear all active blocks in the process.
The remedy is small and worth copying: bound the structure with an explicit, validated maxTrackedKeys; evict in insertion order; protect the aggregate counter from eviction; and only evict when the incoming key is genuinely new. Any counter you keep in process memory and key on attacker-controlled input needs all four.