You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[Security] The cluster wire protocol carries no credential and dispatchEnvelope resolves any to path from the root cell, so any peer that completes hello can address /system framework actors directly #964
Component:src/cluster/Transport.ts, src/cluster/Cluster.ts, src/ActorSystem.ts Severity (assessment): HIGH CWE: CWE-306 (Missing Authentication for Critical Function)
The cluster handshake has no credential: hello is a self-asserted NodeAddress and TcpTransport.onMessage accepts it, records connection.peer from the payload and answers hello-ack. Nothing in Protocol.ts carries a shared secret, a cluster cookie or a signature, remote.tcp.host defaults to 0.0.0.0, and remote.tls.enabled is a dead key that nothing in src/ reads (#591). Once a peer is accepted, Cluster.dispatchEnvelope resolves the envelope's to path through ActorSystem._resolvePath, which walks from rootCell with no guardian scope — so /system/... is exactly as reachable as /user/.... Every framework actor lives there: shard coordinators, shard regions, singleton managers, the pub-sub mediator, the DistributedData replica, the DevTools hub. Those actors are the framework's control plane, and their message contracts were written on the assumption that only the framework speaks them.
Exploit walkthrough
Attacker position: remote unauthenticated, anyone who can open a TCP connection to the cluster port (2552 by default, bound on 0.0.0.0 unless the operator overrides it).
Dial the port and send one hello frame naming any NodeAddress. There is no secret to guess — onMessage sets connection.peer from the frame and replies hello-ack. From that moment handleWire treats every frame from the socket as coming from a cluster member.
Send an envelope frame whose to is a /system path, e.g. actor-ts://<system>/system/cluster/sharding/coordinator-<type> or .../system/cluster/pubsub/mediator.
dispatchEnvelope misses the per-path handler only when the path is not a registered framework endpoint; when it is, step 1 hands the body to that framework actor's handler directly. When it is not, step 2 resolves the path against the actor tree and tells the raw body to whatever cell sits there.
The same walk is reachable a second way through ClusterClientReceptionist, whose path parser explicitly lists system as an accepted leading segment.
Evidence — src/cluster/Transport.ts:304-345
src/cluster/Transport.ts:304-345privateonMessage(connection: Connection,message: WireMessage): void{if(message.kind==='hello'){constpeer=NodeAddress.fromJSON(message.self);constpeerKey=peer.toString();// Security: reject a duplicate-identity hello on a different// socket. Without this, a second connection claiming the// same address as an existing peer would *overwrite* the// byPeer map — every outbound message intended for the// legitimate peer would then be routed to the attacker's// socket. See `tests/multi-node/cluster-security.test.ts` for// the exploit walkthrough.//// The one case that is *not* a hijack is a crossing dial: both// nodes dialled each other at the same moment, so each holds an// un-acked outbound under the other's key and — comparing// identity alone — would reject the other's legitimate hello.// Neither dial then ever gets its `hello-ack`, and the pair stays// split for the process's lifetime (#697). An *established*// peer connection is still never displaced; only our own// unfinished dial gives way, and which side gives way is decided// by address order so the two nodes cannot both stand down.constexisting=this.byPeer.get(peerKey);if(existing&&existing!==connection){if(!this.crossingDialYieldsTo(existing,peerKey)){this.log.warn(`hello hijack rejected: peer ${peerKey} already has an active connection; `+`closing the new socket`,);this.dropConnection(connection);return;}this.log.debug(`crossing dial with ${peerKey}: retiring our outbound, keeping theirs`,);this.dropConnection(existing);}connection.peer=peer;this.byPeer.set(peerKey,connection);constack: HelloAcknowledgmentMessage={kind: 'hello-ack',self: this.self.toJSON()};connection.socket?.write(encodeFrame(ack));return;}
The only check is that the claimed address is not already taken. An unclaimed address is admitted unconditionally.
Evidence — src/cluster/Cluster.ts:692-709
src/cluster/Cluster.ts:692-709// 1. Explicit per-path handler (pub-sub mediator, singleton manager,// sharding coordinator, …).constperPath=this._envelopeHandlersByPath.get(message.to);if(perPath){perPath(decoded,from);return;}// 2. Resolve the target path locally and deliver directly — covers the// case where a RemoteActorRef rebuilt from a WireActorRef targets an// arbitrary user-spawned actor (no extension routing). This also// happens to be functionally identical to sharding's own// dispatchEnvelope for region paths (both end in `ref.tell(body)`).constsegs=parsePathSegments(decoded.to);if(segs.length>0){constrefOpt=this.system._resolvePath(segs);if(refOpt.isSome()){refOpt.value.tell(decoded.bodyasnever);return;}}
The comment says "an arbitrary user-spawned actor". The code says any actor.
Evidence — src/ActorSystem.ts:386-396
src/ActorSystem.ts:386-396/** @internal — walk the actor tree and return the ref at `segments`. */_resolvePath(segments: ReadonlyArray<string>): Option<ActorRef>{if(segments.length===0)returnsome(this.rootCell.self);letcell: ActorCell<unknown>=this.rootCell;for(constsegofsegments){const child =cell._findChildCell(seg);if(!child)returnnone;cell=child;}returnsome(cell.self);}
The walk starts at rootCell, so ['system', …] and ['user', …] are the same kind of lookup. There is no scope parameter and no caller supplies one.
src/cluster/ClusterClientReceptionist.ts:185-186/** Guardian names a path may start with; anything else is relative to `/user`. */constGUARDIAN_SEGMENTS=['user','system']asconst;
The per-path handler table is not a whitelist. It is a fast path. Missing it falls through to the full-tree walk rather than to a rejection.
The internal mark is not a boundary.ActorCell._internal excludes tooling actors from tracing; it gates nothing on delivery.
Suggested fix
Give _resolvePath an explicit scope and make the cluster's envelope dispatch use /user only: _resolvePath(segments, { root: 'user' }), or a dedicated _resolveUserPath. A remote peer addressing a framework actor must go through a registered per-path handler, never through the tree walk. Same for ClusterClientReceptionist — drop 'system' from GUARDIAN_SEGMENTS; a cluster client has no business addressing the control plane.
Make the fallback loud: an envelope whose to resolves outside /user and has no registered handler should be dropped with a warning naming the peer, not delivered.
remote.tcp.host defaulting to 0.0.0.0 should at minimum log a warning at bind time when no TLS and no secret is configured.
Acceptance criteria
A wire envelope addressed at /system/... from a peer is not delivered by the path-resolution fallback; it is dropped and warned about.
ClusterClientReceptionist refuses paths that resolve outside /user.
Framework actors under /system stay reachable by their registered per-path handlers (sharding, singleton, pub-sub, DD regression tests green).
A test asserts that _resolvePath used on the remote path cannot escape /user, including via ..-style or empty segments.
A cluster admission credential exists that does not require TLS, and a node started with neither TLS nor credential logs a warning naming the bind address.
docs/.../cluster/ states plainly that, without TLS plus identity binding plus a credential, the cluster port is a trust boundary equivalent to a shell.
Adjacent issues: #912 (mTLS admits a node but does not verify which node — the identity half of the same trust gap), #591 (remote.tls.enabled is a dead key), #712 and #574/#582/#719/#723 (individual framework actors trusting payload-supplied identity — this issue is why those payloads can arrive at all), #121 (ClusterClientenvelope.from spoofing).
Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (v0.13.0) and re-verified before filing: reproduced by execution. A real ActorSystem and Cluster were built with a stub Transport fed exactly the frames TcpTransport hands Cluster.handleWire after a hello, with a framework actor spawned under /system/cluster/sharding via _spawnSystemActor and an ordinary actor under /user:
framework actor spawned at : actor-ts://payments/system/cluster/sharding/coordinator-orders
user actor spawned at : actor-ts://payments/user/orders
injecting `envelope` frames from an unauthenticated peer payments@198.51.100.9:2552
delivered:
actor-ts://payments/system/cluster/sharding/coordinator-orders <- {"kind":"attacker-frame",…}
actor-ts://payments/user/orders <- {"kind":"attacker-frame",…}
_resolvePath, directly:
user/orders -> RESOLVES
system -> RESOLVES
system/cluster -> RESOLVES
system/cluster/sharding/coordinator-orders -> RESOLVES
nope -> no such path
The stub transport stands in for a socket rather than for the handshake logic: onMessage's acceptance of an unauthenticated hello was confirmed by reading Transport.ts:304-344, where the only rejection is a duplicate address. The /system reachability — the part that is novel next to #912 — is the part that ran.
Part of the production-readiness review batch — tracked in #913.
Component:
src/cluster/Transport.ts,src/cluster/Cluster.ts,src/ActorSystem.tsSeverity (assessment): HIGH
CWE: CWE-306 (Missing Authentication for Critical Function)
The cluster handshake has no credential:
hellois a self-assertedNodeAddressandTcpTransport.onMessageaccepts it, recordsconnection.peerfrom the payload and answershello-ack. Nothing inProtocol.tscarries a shared secret, a cluster cookie or a signature,remote.tcp.hostdefaults to0.0.0.0, andremote.tls.enabledis a dead key that nothing insrc/reads (#591). Once a peer is accepted,Cluster.dispatchEnveloperesolves the envelope'stopath throughActorSystem._resolvePath, which walks fromrootCellwith no guardian scope — so/system/...is exactly as reachable as/user/.... Every framework actor lives there: shard coordinators, shard regions, singleton managers, the pub-sub mediator, the DistributedData replica, the DevTools hub. Those actors are the framework's control plane, and their message contracts were written on the assumption that only the framework speaks them.Exploit walkthrough
Attacker position: remote unauthenticated, anyone who can open a TCP connection to the cluster port (2552 by default, bound on
0.0.0.0unless the operator overrides it).helloframe naming anyNodeAddress. There is no secret to guess —onMessagesetsconnection.peerfrom the frame and replieshello-ack. From that momenthandleWiretreats every frame from the socket as coming from a cluster member.envelopeframe whosetois a/systempath, e.g.actor-ts://<system>/system/cluster/sharding/coordinator-<type>or.../system/cluster/pubsub/mediator.dispatchEnvelopemisses the per-path handler only when the path is not a registered framework endpoint; when it is, step 1 hands the body to that framework actor's handler directly. When it is not, step 2 resolves the path against the actor tree andtells the raw body to whatever cell sits there.node/region/hostedShardsin the payload rather than the authenticated envelope sender, letting one peer seize every shard of a type or evict another node's region #712 seizes shards from the payload'snode/hostedShards); this issue is the reachability that makes those paths addressable in the first place, and it is not limited to the paths that happen to have a filed abuse.The same walk is reachable a second way through
ClusterClientReceptionist, whose path parser explicitly listssystemas an accepted leading segment.Evidence —
src/cluster/Transport.ts:304-345The only check is that the claimed address is not already taken. An unclaimed address is admitted unconditionally.
Evidence —
src/cluster/Cluster.ts:692-709The comment says "an arbitrary user-spawned actor". The code says any actor.
Evidence —
src/ActorSystem.ts:386-396The walk starts at
rootCell, so['system', …]and['user', …]are the same kind of lookup. There is no scope parameter and no caller supplies one.Evidence —
src/cluster/ClusterClientReceptionist.ts:185-186Evidence —
src/config/reference.ts:36-47Why the existing guard does not cover it
remote.tls.enabledis a documented key that nothing reads ([Security]remote.tls.enabledHOCON key is documented but dead — nothing in src/ reads it, so operators believe TLS is on when it is not #591); TLS has to be passed programmatically. Even with it,TcpBackend.ts:104refuses to host an mTLS listener on Deno at all (Deno.listenTlshas no client-certificate request), so a Deno node cannot authenticate anyone.helloidentity is not bound to the TLS peer certificate, so mTLS admits a node but never verifies which node it is #912 is exactly that: thehelloaddress is never checked against the peer certificate, so mTLS admits a node without establishing which one it is. [Security] The clusterhelloidentity is not bound to the TLS peer certificate, so mTLS admits a node but never verifies which node it is #912 fixes who you are; this issue is what you may address once you are in, and neither implies the other — a legitimate but compromised peer, or a peer admitted through a wildcard cert, gets the same/systemreach.validateWireFramechecks the frame's shape ([Security] Decoded wire frames are cast toWireMessagewith no shape validation, so anullframe or a nullNodeAddressDatafield null-derefs in the unguarded dispatch loop #705, [Security] Three extension wire handlers dereference payload fields with no shape guard, so a frame with a missingfromthrows a TypeError out of the unprotected frame-dispatch loop #711);tois a free string.internalmark is not a boundary.ActorCell._internalexcludes tooling actors from tracing; it gates nothing on delivery.Suggested fix
_resolvePathan explicit scope and make the cluster's envelope dispatch use/useronly:_resolvePath(segments, { root: 'user' }), or a dedicated_resolveUserPath. A remote peer addressing a framework actor must go through a registered per-path handler, never through the tree walk. Same forClusterClientReceptionist— drop'system'fromGUARDIAN_SEGMENTS; a cluster client has no business addressing the control plane.envelopewhosetoresolves outside/userand has no registered handler should be dropped with a warning naming the peer, not delivered.hello/hello-ackexchange, gives a non-TLS deployment an admission control it currently does not have at all. Pair it with [Security] The clusterhelloidentity is not bound to the TLS peer certificate, so mTLS admits a node but never verifies which node it is #912 so that when TLS is on, the certificate is the identity.remote.tcp.hostdefaulting to0.0.0.0should at minimum log a warning at bind time when no TLS and no secret is configured.Acceptance criteria
envelopeaddressed at/system/...from a peer is not delivered by the path-resolution fallback; it is dropped and warned about.ClusterClientReceptionistrefuses paths that resolve outside/user./systemstay reachable by their registered per-path handlers (sharding, singleton, pub-sub, DD regression tests green)._resolvePathused on the remote path cannot escape/user, including via..-style or empty segments.docs/.../cluster/states plainly that, without TLS plus identity binding plus a credential, the cluster port is a trust boundary equivalent to a shell.Adjacent issues: #912 (mTLS admits a node but does not verify which node — the identity half of the same trust gap), #591 (
remote.tls.enabledis a dead key), #712 and #574/#582/#719/#723 (individual framework actors trusting payload-supplied identity — this issue is why those payloads can arrive at all), #121 (ClusterClientenvelope.fromspoofing).Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (
v0.13.0) and re-verified before filing: reproduced by execution. A realActorSystemandClusterwere built with a stubTransportfed exactly the framesTcpTransporthandsCluster.handleWireafter ahello, with a framework actor spawned under/system/cluster/shardingvia_spawnSystemActorand an ordinary actor under/user:The stub transport stands in for a socket rather than for the handshake logic:
onMessage's acceptance of an unauthenticatedhellowas confirmed by readingTransport.ts:304-344, where the only rejection is a duplicate address. The/systemreachability — the part that is novel next to #912 — is the part that ran.Part of the production-readiness review batch — tracked in #913.