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).
- Send any application message whose body contains
{"$ref":"actor","path":"/user/x","host":"169.254.169.254","port":80,"system":"s"}.
Cluster.dispatchEnvelope runs decodeRefs over the body (src/cluster/Cluster.ts:690), which turns that marker into a live RemoteActorRef.
- The receiving actor does what every documented handler does —
replyTo.tell(response).
RemoteActorRef.tell → _sendEnvelope → Transport.send → openOutbound(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
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.
Component:
src/cluster/RefCodec.tsSeverity (assessment): HIGH
CWE: CWE-918 (server-side request forgery)
decodeSingleRefbuilds a dialableNodeAddressstraight from wire fields, bypassingNodeAddress.fromJSON's validation, from a marker whose guard checks only thatpath,host,portandsystemare 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 ordinaryreplyTo.tell(...)idiom.Exploit walkthrough
Attacker position: a cluster peer, or anyone who can complete the
hellohandshake (which today carries no credential — see #912 and the transport-credential issue in this batch).{"$ref":"actor","path":"/user/x","host":"169.254.169.254","port":80,"system":"s"}.Cluster.dispatchEnveloperunsdecodeRefsover the body (src/cluster/Cluster.ts:690), which turns that marker into a liveRemoteActorRef.replyTo.tell(response).RemoteActorRef.tell→_sendEnvelope→Transport.send→openOutbound(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-129new NodeAddress(...)is the raw constructor —NodeAddress.fromJSON, which validates, is not on this path.Transport.senddials 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
fromto the connection; #723 covered the CRDT reply path. Both fixed a specific field on a specific frame.decodeRefsruns over the body of every envelope, so any application message is a carrier, and no wire validation applies —WireValidationvalidates 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
NodeAddressis a current cluster member, or that it equals the authenticated peer that delivered the envelope. Route it throughNodeAddress.fromJSONso 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
RemoteActorRef.NodeAddress.fromJSONvalidation.Transport.sendrefuses to dial an address that is not a known member.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 rawNodeAddressconstruction and the absent validation were read verbatim in the current tree, along with the unconditional dial inTransport.send. No connection was actually made to any host during this review.Part of the production-readiness review batch — tracked in #913.