Back to Blog
high SEVERITY3 min read

MapManager.get() Race Condition Duplicates API Requests

The MapManager's `get(mapUid, cache)` method used a check-then-act pattern that permitted multiple concurrent requests to pass the cache miss check simultaneously, triggering redundant API calls and risking cache corruption. The fix introduces a `_pending` promise map to deduplicate in-flight fetches for identical map UIDs.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published October 8, 2026•Reviewed October 8, 2026

Answer Summary

The first-party MapManager class, specifically its `get(mapUid, cache)` method, was vulnerable in all versions prior to the fix commit. An attacker with concurrent access could trigger duplicate API requests for the same resource, increasing load costs and potentially causing cache state corruption through concurrent writes. The fix adds a `_pending` Map that stores in-flight promises keyed by `mapUid`, returning existing promises for concurrent requests instead of spawning new fetches. This vulnerability is classified under CWE-362 (Race Condition).

Vulnerability at a Glance

cweCWE-362
fixPromise deduplication via _pending Map for in-flight requests
riskDuplicate API requests, cache corruption, resource exhaustion
languageJavaScript (Node.js)
root causeNon-atomic cache check followed by async fetch allowed interleaving
vulnerabilityRace Condition (Check-Then-Act)

Affected Versions

Affected not applicable (first-party code) — all versions prior to fix commit
Fixed in not applicable (first-party code) — see linked pull request
Ecosystem N/A
CVE / GHSA not assigned
CWE CWE-362 (Race Condition)

The Vulnerability Explained

The MapManager.get(mapUid, cache) method implemented a classic check-then-act race condition. Here's the vulnerable pattern:

async get(mapUid, cache = this.client.options.cache.enabled){
    if (cache && this._cache.has(mapUid)) {
        return this._cache.get(mapUid);
    } else {
        return await this._fetch(mapUid, cache);
    }
}

The problem manifests in the gap between the else branch's entry and the await completing. Consider two concurrent calls to get('stadium01', true) when the cache is empty:

  1. Thread A: this._cache.has('stadium01') returns false
  2. Thread B: this._cache.has('stadium01') returns false (still empty)
  3. Thread A: begins _fetch('stadium01', true)
  4. Thread B: begins _fetch('stadium01', true) — duplicate request

Both threads now hit the external API for identical data. Worse, depending on _fetch's implementation and the cache's concurrency model, the two responses could race to write into this._cache, potentially causing:
- Overwrite of fresher data with stale data
- Corrupted cache entries from interleaved writes
- Memory pressure from storing duplicate objects

This is particularly damaging for a map management service where mapUid values like 'stadium01' represent large, expensive-to-fetch terrain data.

The Fix

The patch introduces promise deduplication through a _pending Map:

// Added to constructor
this._pending = new Map();

// Modified get() method
async get(mapUid, cache = this.client.options.cache.enabled){
    if (cache && this._cache.has(mapUid)) {
        return this._cache.get(mapUid);
    } else if (this._pending.has(mapUid)) {
        return await this._pending.get(mapUid);
    } else {
        const promise = this._fetch(mapUid, cache)
            .finally(() => this._pending.delete(mapUid));
        this._pending.set(mapUid, promise);
        return await promise;
    }
}

The key insight: promises are reference-equal and awaitable by multiple consumers. By storing the in-flight promise in _pending before awaiting it, subsequent callers receive the identical promise object. The .finally() cleanup ensures that once any caller's await completes (success or failure), the entry is removed, allowing retry of failed fetches.

This transforms the failure mode from "unbounded duplicate requests" to "exactly one request per concurrent batch, cleanly bounded."

Key Takeaways

  • Promise-returning methods need deduplication for cache misses: Any async getter with a cache should consider whether concurrent callers for the same uncached key will spawn redundant work. The _pending Map pattern is a robust, language-native solution in JavaScript.

  • Check-then-act over async boundaries is always racy: The if (!cached) { await fetch() } pattern is fundamentally unsound when multiple executions may interleave. Atomic test-and-set operations or promise deduplication are required.

  • Cleanup belongs in finally, not after await: The fix uses .finally(() => this._pending.delete(mapUid)) rather than deleting after the await returns. This prevents leaked entries on fetch failures and ensures the map stays bounded even under error conditions.

  • Defence-in-depth has value even for "unexploitable" patterns: The PR description notes this was "defence-in-depth at src/managers/MapManager.js:30 rather than a vulnerability I can show is exploitable." Race conditions in cache layers often manifest as subtle production issues (cost spikes, latency outliers) rather than security breaches, making proactive fixes worthwhile.

How Orbis AppSec Detected This

  • Source: The mapUid parameter passed to MapManager.get()
  • Sink: The _fetch(mapUid, cache) call invoked after a non-atomic cache miss check
  • Missing control: No synchronization mechanism prevented multiple concurrent executions from passing the !this._cache.has(mapUid) check simultaneously
  • CWE: CWE-362 (Concurrent Execution using Shared Resource with Improper Synchronization)
  • Fix: Introduced a _pending Map to store in-flight promises, ensuring all concurrent requests for the same mapUid await a single shared promise

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 MapManager.get() race condition exemplifies how async JavaScript code inherits classical concurrency problems. The vulnerability wasn't exotic—just two lines of sequential logic that became interleaving hazards under load. The fix demonstrates that promise-based languages have elegant solutions: by treating in-flight requests as first-class values in a Map, the code achieves exactly-once semantics without locks or complex state machines. For developers building cached async APIs, the _pending pattern belongs in your standard toolkit.

Prevention and further reading

Frequently Asked Questions

Does the fix change the return type of `get(mapUid, cache)` when multiple callers request the same uncached map?

No, the return type remains `Promise<TMMap>`. The fix ensures concurrent callers receive the same promise instance rather than independent promises that each trigger separate `_fetch` calls.

Is the `_pending.delete(mapUid)` in the `finally` block safe if the promise rejects?

Yes, `finally` executes regardless of resolution or rejection, ensuring cleanup. This prevents memory leaks from accumulated entries and allows subsequent requests to retry failed fetches.

Why was `this._pending.has(mapUid)` checked after `this._cache.has(mapUid)` rather than before?

The cache check remains first because cached responses are synchronous and cheapest. The `_pending` check only matters after a cache miss, when an async fetch would otherwise begin.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #234

Related Articles

critical

Lampa Desktop Auto-Update Heuristic Bypass: Execution of Unverified

Lampa Desktop's auto-update mechanism downloaded JavaScript and CSS from `raw.githubusercontent.com` using only heuristic validation—file size thresholds and string pattern matching—that attackers could trivially satisfy. The fix introduces cryptographic integrity verification by cross-referencing Git blob hashes from the GitHub Contents API, ensuring downloaded code matches the repository's authoritative state before execution.

high

adm-zip 0.6.0 Preserves SUID Bits From ZIPs: CVE-2026-102282

The `adm-zip` dependency resolved to 0.6.0 in this project's dependency tree, a version affected by CVE-2026-102282: during extraction it applies the Unix permission bits stored in each ZIP entry's external file attributes verbatim, including the setuid (`04000`), setgid (`02000`), and sticky bits. An attacker who controls an archive passed to `extractAllTo()` or `extractEntryTo()` can therefore have the extractor create a setuid binary owned by whatever user the extraction process runs as. The

high

requestInput() Type Confusion: NaN and Object Bypass in JavaScript

The `requestInput()` utility function lacked validation on its `type` parameter and failed to handle `NaN` results from float conversions, creating a type confusion weakness. An attacker could supply malformed inputs that propagate unhandled `NaN` values or unexpected object types through the type system. The fix adds explicit guards against `NaN` type parameters and rejects non-primitive type values.

critical

No Rate Limit on /api/uploads/presign Enables DoS

The `/api/uploads/presign` endpoint accepted unlimited concurrent requests to generate storage presigned URLs, giving an attacker a free lever to exhaust storage-provider quotas and server resources. The fix adds an `express-rate-limit` middleware capping each client to 30 requests per minute on that route.

high

CVE-2026-54673: builder-util-runtime Leaks Auth Headers on Redirect

electron-updater and electron-builder rely on builder-util-runtime to fetch update manifests and artifacts over HTTP. A flaw in that shared HTTP executor allowed credential headers attached to the original update-feed request to be re-sent after a redirect, exposing them to any host the redirect pointed to. The project fixes this by upgrading builder-util-runtime to 9.7.0 and collapsing a duplicate, older copy of the package that electron-updater had pinned on its own.

high

file_md5() MD5 Digest Lets Agent Docs Collide (CWE-328)

A first-party maintenance script used `hashlib.md5()` inside a `file_md5(path)` helper to decide whether the per-vendor agent instruction documents in a repository were byte-equivalent. Because MD5 is collision-broken, two meaningfully different documents could be crafted to produce the same digest and pass the "these files are in sync" gate. The fix replaces the MD5 call with `hashlib.sha256()` over the same `path.read_bytes()` input.