Back to Blog
high SEVERITY8 min read

Unbounded Map in createLoginRateLimiter Exhausts API Memory

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 li

O
By Orbis AppSec
•Published September 28, 2026•Reviewed September 28, 2026

Answer Summary

The affected code is the first-party rate-limiting module behind `createLoginRateLimiter` and `createMutationRateLimiter` in the console API; no published package or version range is involved. An attacker sending requests from rotating IPv6 addresses or with random client identifiers could add unlimited entries to the in-memory `Map` backing each limiter — entries that are never swept — driving the Node process into an out-of-memory crash and taking the whole API offline. The fix bounds each limiter with a `maxTrackedKeys` option (default 5000), validated by `trackedEntryLimit`, and evicts the oldest tracked key through `evictOldestIfFull` before inserting a new one, while never evicting the protected global counter key. This is CWE-400 (Uncontrolled Resource Consumption); no CVE or GHSA identifier has been assigned.

Vulnerability at a Glance

cweCWE-400
fixAdded `maxTrackedKeys` (default 5000), `trackedEntryLimit` validation, and `evictOldestIfFull` FIFO eviction that skips the global counter key
riskA flood of unique rate-limit keys grows the tracking Map without bound until the API server is killed by OOM
languageJavaScript (Node.js, ES modules)
root cause`new Map()` used as unbounded, never-swept storage keyed by attacker-controlled client identifiers
vulnerabilityUncontrolled resource consumption (memory exhaustion) in in-memory rate limiter state

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:

  1. 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 }.
  2. Never reuse an address. Each entry is now unreachable garbage that the limiter still holds a strong reference to.
  3. 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 Map keyed by a client identifier is unbounded storage controlled by the client; windowMs and blockMs decide 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_BYTES plus 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 to createMutationRateLimiter().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' internal attempts and requests maps, 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 maxTrackedKeys bound (default 5000) and evictOldestIfFull, 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.

Prevention and further reading

Frequently Asked Questions

Can an attacker escape a login block by flooding 5000 unique keys through `evictOldestIfFull`?

Yes, in principle — eviction is FIFO on insertion order, so a long-lived `blockedUntil` entry can be pushed out by a large flood of fresh keys. That is why the `__global__` key is explicitly protected: `globalMaxAttempts = 32` still throttles the aggregate attempt rate even when a per-key record is evicted.

Why does `trackedEntryLimit` reject values below 2 and fall back to 5000?

With a limit of 0 or 1 the map has no room for a tracked key alongside the protected global counter, so eviction would thrash or misbehave on every insert. Flooring at 2 (and rejecting `NaN`/non-finite input) guarantees at least one tracked slot plus the global counter.

Does this change affect the per-file `MAX_VIDEO_UPLOAD_BYTES` check on the video upload endpoint?

No — the per-file byte cap is unchanged. What changed is that the mutation limiter which throttles concurrent upload requests can no longer be OOM'd out of existence by a flood of unique keys, so the cap is actually enforceable under attack.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #235

Related Articles

high

js-yaml 4.3.1 Denial of Service: Malformed Input Hangs YAML Parser

A denial of service vulnerability in js-yaml versions 4.3.1 and earlier allows attackers to hang the YAML parser indefinitely by providing specially crafted malformed input. The fix, released in versions 4.3.2 and 3.15.2, patches the parsing logic to prevent unbounded processing. Upgrading is recommended for all applications parsing untrusted YAML data.

high

Express `app.get('*')` Wildcard Handler Path Traversal in watch.js

A first-party Express server's wildcard route handler used `req.url.indexOf('font.woff2')` to gate access to a font file, allowing attackers to bypass the substring check with crafted paths. The fix replaces the catch-all handler with explicit route registration.

critical

Updater.parseUpdate() CWE-494: Unsigned Metadata Download

The parseUpdate function in the Updater component extracted download URLs from remote server responses without cryptographic verification, enabling supply chain attacks via compromised or spoofed update servers. The fix adds strict URL validation requiring HTTPS and a trusted hostname before accepting any update metadata.

high

brace-expansion DoS: Exponential Backtracking in Nested Brace Patterns

A critical vulnerability in brace-expansion allows attackers to cause denial of service by submitting specially crafted patterns with nested braces. The exponential-time complexity in pattern expansion creates a computationally expensive path that can freeze applications processing user-controlled input.

high

CVE-2026-67213: nanoid customAlphabet Infinite Loop Fix

nanoid, a widely-used ID generator pulled in transitively through postcss and vitepress, had an infinite-loop bug in its `customAlphabet` code path before version 5.1.6. This PR pins the entire dependency tree to nanoid 5.1.16 via a pnpm override so no transitive consumer can resolve back to the vulnerable 3.3.16 release.

high

nanoid 3.3.11 Integer Overflow: Predictable ID Generation

An integer overflow in nanoid 3.3.11's internal randomness generation causes the library to fall back to predictable ID sequences, undermining the cryptographic guarantees of its supposedly unguessable identifiers. The fix upgrades the dependency tree to patched versions 3.3.12 or 5.1.11.