Skip to content

[Security] onReadRequest/onWriteRequest reply to the payload's from, so any node can be made to dial an attacker-named host and buffer full CRDT snapshots in a Connection.pending queue that is never drained, never capped and never reclaimed #723

Description

@pathosDev

Component: src/crdt/DistributedData.ts
Severity (assessment): MEDIUM
CWE: CWE-406 (Insufficient Control of Network Message Volume) / CWE-918
Related: #574, #121, #140

Both request handlers compute the reply destination as NodeAddress.fromJSON(message.from) — a peer-supplied host/port with no check that it is the socket peer or a cluster member — and hand it to Transport.send, which opens an outbound connection to any address on first use. If the target completes the TCP handshake but never sends the actor-ts hello-ack, connection.peer stays null and every queued message accumulates in connection.pending, which is only ever drained by a hello-ack.

Exploit walkthrough

Preconditions: one peer able to send ddata-read-request frames, and knowledge of a key holding a sizeable CRDT (or the ability to plant one first via gossip).

Memory exhaustion (primary): the attacker sends N frames of ~150 bytes each, all with from = { systemName:'x', host:'<a host that accepts TCP but never speaks the protocol>', port:80 } and key = 'big-key'. Each one appends a full toJSON() snapshot of big-key to connection.pending. Nothing drains it — the target never sends hello-ack — and nothing bounds it. With a 5 MB CRDT and 200 requests, the victim has queued 1 GB of unreachable frames from 30 KB of attacker traffic. Amplification factor ≈ (CRDT size / 150 bytes).

Egress / probing (secondary): each distinct forged from triggers openOutbound, so the victim node will TCP-dial any host:port the attacker names, from inside the cluster's network position. Failed connects are cleaned up (byPeer.delete in the catch at src/cluster/Transport.ts:186-188), so this is a connect-probe primitive rather than a leak, but the choice of destination is entirely attacker-controlled.

Data routing (tertiary): if the attacker names a host it does control which does speak the protocol, the victim ships the full plaintext CRDT for the requested key there — under mTLS the TLS handshake would fail, so this leg is mitigated by the documented production posture; the memory-exhaustion leg is not, because it does not require the response to be delivered.

Evidence — src/crdt/DistributedData.ts

src/crdt/DistributedData.ts:759-770:

  private onReadRequest(message: DDataReadRequestMessage): void {
    const local = this.view.state.get(message.key);
    const sender = NodeAddress.fromJSON(message.from);
    const response: DDataReadResponseMessage = {
      t: 'ddata-read-response',
      from: this.cluster.selfAddress.toJSON(),
      pendingId: message.pendingId,
      key: message.key,
      value: local ? (local.toJSON() as CrdtJson) : null,
    };
    this.cluster.transport.send(sender, response as unknown as WireMessage);
  }

(onWriteRequest, src/crdt/DistributedData.ts:736-743, does the same with a small ack.)

src/cluster/Transport.ts:115-123 — the undrained queue:

  send(to: NodeAddress, message: WireMessage): void {
    if (this.stopped) return;
    const connection = this.byPeer.get(to.toString()) ?? this.openOutbound(to);
    if (connection.peer && connection.socket) {
      connection.socket.write(encodeFrame(message));
    } else {
      connection.pending.push(message); // wait for hello / hello-ack
    }
  }

src/cluster/Transport.ts:151-176 — openOutbound dials whatever host/port it is given, connection.pending is spliced only in the hello-ack branch (src/cluster/Transport.ts:267-271).

Why the existing guard does not cover it

The transport's hello handshake means Cluster._onWire already has the authenticated peer address available (src/cluster/Cluster.ts:341, src/cluster/Transport.ts:277) — DistributedData.start (src/crdt/DistributedData.ts:270-272) simply discards it, so the correct address is one parameter away at every one of these call sites. Transport.disconnect and onClose prune byPeer, but neither is reachable for a half-open connection whose peer never handshakes. There is no per-peer rate limit on inbound ddata frames and no bound on Connection.pending.

Suggested fix

Reply to the authenticated connection address, not message.from: thread from through the _onWire handler and use it as the destination in onReadRequest/onWriteRequest, treating a payload from that disagrees as a dropped frame. Independently, cap Connection.pending in Transport.send (drop-oldest with a warning past a few hundred frames or a byte budget) so no wire handler can be turned into an unbounded queue.

Relationship to existing issues

Adjacent to #574, #121, #140, but a distinct mechanism. Verified, and the transport leg is worse than described. onReadRequest and onWriteRequest both compute the reply destination as NodeAddress.fromJSON(message.from) and hand it to transport.send (DistributedData.ts:736-743, 759-770); Transport.send opens an outbound connection to any address and pushes to connection.pending when connection.peer is null (Transport.ts:115-123, 151-193); pending is spliced only in the hello-ack branch (Transport.ts:269-270); and onClose (280-285) deletes byPeer only if (connection.peer), so a half-open connection that never handshakes is never reclaimed at all. Kept separate from finding idx 140 rather than merged: different handlers, a different security property (reflected egress plus unbounded queue growth versus quorum-vote integrity), and an independent transport-layer defect — the missing bound on Connection.pending — that survives any fix to the payload-from trust. Neighbours: #574 and #121 are the same discard-the-authenticated-from pattern at other call sites (Receptionist, ClusterClientReceptionist), and existing #140 is the adjacent unbounded-growth issue in the same request path, but that one is about pendingWrites/pendingReads on the originator under local burst load ('the map can hold N entries between issuing N concurrent updateAsync calls and their timers firing') — a different map, locally driven, with a caller-side cap as its fix. MEDIUM held: the memory-amplification leg needs no reply delivery and therefore works under mTLS.

Verification status

Found in the second, independent whole-framework security re-audit of 2026-08-02 (v0.12.0) — a fresh pass run without reference to the first wave's findings, then triaged against the existing tracker and adjudicated by verifiers instructed to refute it.

Verifier note

Confirmed against the source; every cited line says what the evidence claims.

Citation check. onReadRequest at src/crdt/DistributedData.ts:759-770 does compute const sender = NodeAddress.fromJSON(message.from) and pass it to this.cluster.transport.send(sender, response) with value: local.toJSON(); onWriteRequest at 736-743 does the same for the ack. NodeAddress.fromJSON (src/cluster/NodeAddress.ts:29-31) is a bare constructor call — no host, port or membership validation. Transport.send (src/cluster/Transport.ts:115-123) resolves byPeer.get(to.toString()) ?? this.openOutbound(to) and pushes to connection.pending whenever connection.peer is null; openOutbound (151-193) registers the Connection in byPeer synchronously and dials whatever host/port it is handed; pending is spliced only in the hello-ack branch (269-270). All accurate.

Missing-guard check. I looked for the guard on every layer the finder could have missed. There is no cap of any kind on Connection.pending (grep for maxPending/pendingLimit/byte budget across src/ returns nothing) and no per-peer rate limit on inbound ddata frames. Cluster.handleWire -> onUnhandledWire (src/cluster/Cluster.ts:494-514) dispatches custom wire handlers with no membership check, so any peer past the hello handshake reaches these handlers. The authenticated peer really is available and really is discarded: _onWire's signature is (message: WireMessage, from: NodeAddress) (Cluster.ts:341), the dispatcher passes it (Cluster.ts:514), the transport supplies connection.peer from the handshake (Transport.ts:277), and the DistributedData registration at DistributedData.ts:270-272 takes (message) only. Per the audit's own calibration this is the legitimate class — payload-supplied identity used where the authenticated connection peer was in hand — not a restatement of 'the wire is unauthenticated'.

Reclamation check. onClose (Transport.ts:280-285) deletes from byPeer only if (connection.peer), so a connection that never handshakes is never removed even after its socket closes; a later send to the same address hits the stale entry and appends to pending again. TcpTransport.disconnect (125-130) would prune it, but it has no production caller anywhere in src/ — only the interface declaration and the in-memory/testkit no-ops. The finder's 'never reclaimed' claim holds.

Amplification check. Each toJSON() is a fresh allocation (e.g. src/crdt/GCounter.ts:57-59, src/crdt/ORSet.ts:164+), so N requests retain N independent snapshots rather than N references to one. The 16 MiB DEFAULT_MAX_FRAME_BYTES cap (src/cluster/Protocol.ts:162) is inbound-only and per-frame; nothing bounds the aggregate outbound queue. The ~150-byte request to full-snapshot response ratio is right.

Corrections applied (see corrections): the finder's 'failed connects are cleaned up' is wrong on Node, where NodeTcpBackend.connect never rejects — that strengthens rather than weakens the finding; and the mTLS argument needs a stalling rather than a refusing host on Bun/Deno.

Impact trimmed: the SSRF/exfiltration leg is blind (only a hello frame is written, and nothing returns to the attacker unless they control a host that completes the handshake), and under mTLS with rejectUnauthorized defaulting to true (src/runtime/tcp/NodeTcpBackend.ts:61, BunTcpBackend.ts:71) the data-routing leg is closed. The memory leg survives.

Severity MEDIUM held: a remote-triggerable unbounded queue that can OOM a node is real, but it requires the opt-in DistributedData extension to be running and a key holding a sizeable CRDT, and the strongest legs are a leak/DoS rather than integrity or disclosure. Below the HIGH bar this catalogue uses.

Correction applied: Two factual corrections to the finding text. (1) The claim that failed connects are cleaned up by the catch at Transport.ts:186-188 is runtime-dependent and false on Node.js. NodeTcpBackend.connect (src/runtime/tcp/NodeTcpBackend.ts:47-74) constructs the socket, registers listeners and returns it immediately — it never rejects on a connect failure; the failure surfaces asynchronously as the error/close events. So on Node the catch/byPeer.delete never fires for an unreachable forged address, onClose skips the delete because connection.peer is null, and the byPeer entry plus its pending array leak permanently. Bun (Bun.connect) and Deno (await Deno.connect) do reject, so on those two runtimes a refused connect is cleaned up as the finding describes. The distinct-forged-address variant is therefore a permanent unbounded-map leak on Node, not merely a connect probe. (2) 'works under mTLS' is correct but the stated mechanism should be sharpened: on Bun/Deno an attacker-named host that refuses TCP is cleaned up, so the mTLS variant needs a host that accepts TCP and stalls the TLS handshake — no connect timeout is configured anywhere in the backends, so the promise never settles and pending grows for the whole stall. On Node even a hard TLS-handshake failure leaves the entry. Also worth adding as a precondition: DistributedData is an opt-in extension — the wire handlers only exist after DistributedData.start(cluster) (src/crdt/DistributedData.ts:263-273), so a cluster that does not use CRDTs is not exposed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: mediumUseful, not urgentproduction-goalBlocks or defines the path to production readinesssecuritySecurity-relevant — see severity label for impact tierseverity: mediumModerate impact or requires specific conditions

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions