Skip to content

[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

Description

@pathosDev

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

  1. 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.
  2. 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.
  3. 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.
  4. Either way an unauthenticated party is now speaking the framework's internal protocol. The coordinator and region command shapes are already known to be trust-sensitive ([Security] ShardCoordinator derives region identity and shard ownership from node/region/hostedShards in 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's node/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 lists system as an accepted leading segment.

Evidence — src/cluster/Transport.ts:304-345

src/cluster/Transport.ts:304-345
  private onMessage(connection: Connection, message: WireMessage): void {
    if (message.kind === 'hello') {
      const peer = NodeAddress.fromJSON(message.self);
      const peerKey = 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.
      const existing = 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);
      const ack: 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, …).
    const perPath = 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)`).
    const segs = parsePathSegments(decoded.to);
    if (segs.length > 0) {
      const refOpt = this.system._resolvePath(segs);
      if (refOpt.isSome()) {
        refOpt.value.tell(decoded.body as never);
        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) return some(this.rootCell.self);
    let cell: ActorCell<unknown> = this.rootCell;
    for (const seg of segments) {
      const child = cell._findChildCell(seg);
      if (!child) return none;
      cell = child;
    }
    return some(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.

Evidence — src/cluster/ClusterClientReceptionist.ts:185-186

src/cluster/ClusterClientReceptionist.ts:185-186
/** Guardian names a path may start with; anything else is relative to `/user`. */
const GUARDIAN_SEGMENTS = ['user', 'system'] as const;

Evidence — src/config/reference.ts:36-47

src/config/reference.ts:36-47
  remote {
    # Bind address of this node.  Cluster.join reads these when its options
    # leave host/port unset, so a deployment can move the address into config.
    tcp {
      host = "0.0.0.0"
      port = 2552
    }
    tls {
      enabled = false   # DEAD KEY  not read by anything yet, see issue #591
    }
    max-frame-bytes = 16M   # per-frame wire cap; lower it on semi-trusted networks
  }

Why the existing guard does not cover it

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.
  • Track the credential separately from TLS: a shared cluster secret, validated in the hello/hello-ack exchange, gives a non-TLS deployment an admission control it currently does not have at all. Pair it with [Security] The cluster hello identity 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.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 (ClusterClient envelope.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.

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