Skip to content

[Security] decodeSingleRef builds a dialable NodeAddress from unvalidated wire fields, so a ref embedded in any message body makes the receiving node connect to an attacker-chosen host #936

Description

@pathosDev

Component: src/cluster/RefCodec.ts
Severity (assessment): HIGH
CWE: CWE-918 (server-side request forgery)

decodeSingleRef builds a dialable NodeAddress straight from wire fields, bypassing NodeAddress.fromJSON's validation, from a marker whose guard checks only that path, host, port and system are present. Embedding a crafted ref in any message body therefore makes the receiving node open a TCP (or TLS) connection to an address of the sender's choosing and write a length-prefixed frame to it — the moment the receiving actor uses the ordinary replyTo.tell(...) idiom.

Exploit walkthrough

Attacker position: a cluster peer, or anyone who can complete the hello handshake (which today carries no credential — see #912 and the transport-credential issue in this batch).

  1. Send any application message whose body contains
    {"$ref":"actor","path":"/user/x","host":"169.254.169.254","port":80,"system":"s"}.
  2. Cluster.dispatchEnvelope runs decodeRefs over the body (src/cluster/Cluster.ts:690), which turns that marker into a live RemoteActorRef.
  3. The receiving actor does what every documented handler does — replyTo.tell(response).
  4. RemoteActorRef.tell_sendEnvelopeTransport.sendopenOutbound(targetNode) dials the attacker-chosen host:port with no membership check.

The destination need not be a cluster member. Cloud metadata endpoints, internal admin ports and arbitrary internal services are all reachable from the node's network position.

Evidence — src/cluster/RefCodec.ts:128-129

src/cluster/RefCodec.ts:116-130
function decodeSingleRef(wire: WireActorRef, cluster: Cluster): ActorRef {
  if (wire.path === 'nobody' || !wire.host || !wire.port || !wire.system) {
    return Nobody;
  }
  const self = cluster.selfAddress;
  // Same node: hand back the local ref.  Actors that no longer exist fall
  // through to Nobody rather than constructing a dangling RemoteActorRef
  // back to ourselves.
  if (wire.host === self.host && wire.port === self.port && wire.system === self.systemName) {
    const segs = parsePathSegments(wire.path);
    return cluster.system._resolvePath(segs).getOrElse(Nobody);
  }
  const targetNode = new NodeAddress(wire.system, wire.host, wire.port);
  return new RemoteActorRef(targetNode, wire.path, cluster);
}

new NodeAddress(...) is the raw constructor — NodeAddress.fromJSON, which validates, is not on this path. Transport.send dials unconditionally (src/cluster/Transport.ts:145-147).

Why the existing guard does not cover it

This is the third instance of one defect class, and the two prior fixes do not reach it. #572 bound the heartbeat's from to the connection; #723 covered the CRDT reply path. Both fixed a specific field on a specific frame. decodeRefs runs over the body of every envelope, so any application message is a carrier, and no wire validation applies — WireValidation validates known frame kinds, not embedded ref markers.

Suggested fix

Resolve every wire-supplied address against something the node already trusts before it becomes dialable: require that the decoded NodeAddress is a current cluster member, or that it equals the authenticated peer that delivered the envelope. Route it through NodeAddress.fromJSON so the validation that already exists actually runs.

The broader move worth making once: a single helper that resolves any wire-supplied address against the authenticated connection would retire this class — #572, #574, #582, #712, #719, #723 and this issue are all the same bug at different call sites.

Acceptance criteria

  • A ref marker naming a non-member address does not produce a dialable RemoteActorRef.
  • Decoding goes through NodeAddress.fromJSON validation.
  • Transport.send refuses to dial an address that is not a known member.
  • A test embeds a hostile ref in a message body and asserts no outbound connection is attempted.

Verification status

Found in the ten-lens production-readiness review of 2026-08-05 (v0.13.0) and re-verified before filing: confirmed by reading — the raw NodeAddress construction and the absent validation were read verbatim in the current tree, along with the unconditional dial in Transport.send. No connection was actually made to any host during this review.

Part of the production-readiness review batch — tracked in #913.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: highTop priority — high impact, plan nextproduction-goalBlocks or defines the path to production readinesssecuritySecurity-relevant — see severity label for impact tierseverity: highSignificant impact, exploitable in standard threat model

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions