Skip to content

[Security] ClusterClient envelope.from spoofing reroutes replies #121

Description

@pathosDev

Severity / Size

  • Severity: MEDIUM — an attacker who can send a cluster-client-envelope frame to a cluster node can route the reply to any other node in the cluster by forging the envelope's from field. Combined with askId predictability ([Security] ClusterClient askId predictability via Date.now()+counter #120), this becomes a way to mis-deliver replies cross-client and to leak the responses of one client to another node.
  • Size: M (~2d).
  • Threat model: a malicious or compromised cluster peer; or a network-injection attacker who can deliver frames to a cluster node's transport. The hello-handshake hijack defense (9c3b005) closed the byPeer-overwrite path, but the receptionist still trusts envelope contents at the application layer.

Affected files

  • src/cluster/ClusterClientReceptionist.ts:100-143_onWire('cluster-client-envelope') handler. Decodes env.from and uses it directly as the reply destination without cross-checking against the TCP socket the frame arrived on.
  • src/cluster/ClusterClientReceptionist.ts:157-168sendReply() uses cluster.transport.send(to, ...) where to is the attacker-supplied from address.
  • src/cluster/Cluster.ts:407-411 — the _onWire dispatcher. Passes only the msg to the wire-handler callback; does not pass the from: NodeAddress of the actual TCP peer. This is the structural gap.

Background

In v0.8.0 (#86, commit 5567dc5) we added ClusterClient for outside-in connectivity: external processes open a TCP connection to a cluster node, send cluster-client-envelope frames containing { to: path, from: clientAddress, askId, body }, and the receptionist routes replies back via cluster.transport.send(env.from, reply).

The env.from field is taken at face value. The receptionist doesn't ask the transport "which TCP socket did this envelope actually come in on?" — it just trusts the envelope contents. This is the gap:

  • A legitimate client connects from client@10.0.0.42:50001 and sets env.from = 'client@10.0.0.42:50001'. Receptionist replies via transport.send('client@10.0.0.42:50001', ...). The TCP transport's byPeer map (populated during the hello handshake on the same socket) routes the reply over the legitimate socket. Works.

  • A malicious peer connects from evil@10.0.0.99:50002, completes its own hello handshake, then sends env.from = 'client@10.0.0.42:50001' (impersonating the legitimate client's identity). Receptionist replies to client@10.0.0.42:50001. If the legitimate client's socket is still up (because both connections coexist), the reply lands on the legitimate client's socket — even though the request originated from the attacker.

The fix landed in 9c3b005 (hello-handshake hijack) was specifically about preventing one socket from overwriting another's byPeer entry. But that defense doesn't help here: both connections exist legitimately in byPeer, and the receptionist freely picks the routing target via the envelope field.

Exploit walkthrough

Setup: cluster of 3 nodes; one legitimate ClusterClient at client@10.0.0.42:50001 is interacting with a sharded actor. Attacker has cluster-peer-level access (compromised peer, or has frame-injection access to the cluster transport).

Step 1 — observation: attacker observes legitimate ClusterClient → cluster traffic. Sees cluster-client-envelope frames with from: client@10.0.0.42:50001 and various askId values.

Step 2 — predicted askId (depends on #120 being un-fixed): attacker predicts the next askId the legitimate client will use.

Step 3 — forge a request from the attacker's connection: attacker sends an envelope on its own socket but with env.from = client@10.0.0.42:50001 (legitimate client's address) and the predicted askId. Body: any request the attacker wants the legitimate client to "receive" the reply for.

Step 4 — receptionist processes the envelope: runs ask() on the target actor with the attacker's body. Gets a reply. Calls sendReply(cluster, from=client@10.0.0.42:50001, ...).

Step 5 — reply routes to the legitimate client: transport.send looks up byPeer['client@10.0.0.42:50001'] → the legitimate client's socket → reply sent there.

Step 6 — legitimate client receives the forged reply: matches the predicted askId to a pending ask (or doesn't have a pending ask but accepts it as a stale reply, depending on receiver hygiene). If matched, the legitimate client resolves a Promise with attacker-chosen data.

If askId-predictability is fixed (post-#120), Step 6's match fails — the legitimate client's askId map doesn't have the forged ID. Damage limited to log-noise. But envelope-from-spoofing also enables reply DoS: the attacker forges asks targeting from: legit-client, the legit client's TCP socket receives unsolicited reply frames, every one is processed (frame-decoded, looked up in pending, dropped) — burns CPU + bandwidth on the legit side.

Even after #120, this issue independently matters because:

  • It allows traffic redirection — replies meant for the attacker end up at the wrong endpoint.
  • It allows inbound DoS to any cluster client by forging asks on its behalf.
  • It allows an attacker that can decrypt the legit client's socket (TLS-MitM in a misconfigured setup) to see the replies to its forged requests.

How the 8 already-landed security fixes inform this

  • Hello-handshake hijack (9c3b005): the transport layer binds (TCP socket → claimed identity) via the hello. This issue is the application-layer equivalent: the receptionist trusts envelope.from instead of asking the transport "what's the socket-bound identity?" Same fix shape: bind state to the actual peer identity, not to claimed envelope fields.
  • Idempotency body-fingerprint (4cac92a): rejected re-use with a different body. Pattern: cross-check application-layer claims against a structural truth (here: the socket identity).

Fix design

Two complementary changes. Track 1 closes the structural gap; Track 2 makes the API hard to misuse going forward.

Track 1 — propagate from-identity through _onWire.

The Cluster._onWire(kind, handler) callback today receives only msg. Change to also receive the originating NodeAddress:

// src/cluster/Cluster.ts (current)
_onWire(kind: string, handler: (msg: WireMessage) => void): () => void;

// new signature
_onWire(kind: string, handler: (msg: WireMessage, from: NodeAddress) => void): () => void;

Cluster.handleWire already has the from parameter (line 407); just plumb it through.

Then in ClusterClientReceptionist:

this._unsubscribe = cluster._onWire('cluster-client-envelope', (msg, transportFrom) => {
  const env = msg as unknown as ClusterClientEnvelopeMsg;
  const claimedFrom = NodeAddress.fromJSON(env.from);

  // Cross-check: the envelope's claimed `from` MUST match the TCP socket's
  // peer identity.  An attacker on socket X cannot spoof socket Y's
  // identity by forging the envelope.
  if (!claimedFrom.equals(transportFrom)) {
    log.warn(`receptionist: rejecting envelope with from=${claimedFrom} ` +
             `arrived on socket=${transportFrom} (spoofing attempt or misconfiguration)`);
    return;
  }
  // ... existing handling, but use transportFrom (not env.from) for replies
});

After this fix, an attacker on a different socket cannot claim to be client@10.0.0.42:50001. Their envelope is rejected at the application layer; the legitimate client sees nothing.

Track 2 — drop env.from from the wire format entirely.

Once Track 1 is in, env.from is redundant — the receptionist gets the canonical from-identity from the transport. In a follow-up version we can drop the field from ClusterClientEnvelopeMsg:

// before
interface ClusterClientEnvelopeMsg {
  t: 'cluster-client-envelope';
  from: NodeAddressData;       // ← drop
  to: string;
  askId?: string;
  body: unknown;
}

// after
interface ClusterClientEnvelopeMsg {
  t: 'cluster-client-envelope';
  to: string;
  askId?: string;
  body: unknown;
}

This is a wire-format change. Land it in a later version with a version-byte negotiation, or accept-both-shapes for one release. Optional for Track 1.

Track 3 — metric counter.

cluster_envelope_from_mismatch_total so operators see spoofing attempts in dashboards.

API surface

// src/cluster/Cluster.ts — breaking change to the internal _onWire signature
_onWire(
  kind: string,
  handler: (msg: WireMessage, from: NodeAddress) => void,
): () => void;

All current _onWire consumers (DistributedData, DistributedPubSub, ClusterClientReceptionist, etc.) need to accept the new second argument (they can ignore it if not needed).

Backward compatibility

The _onWire signature change is internal (the leading underscore signals "framework-internal"). No public-API impact.

Wire-format compatibility is preserved in Track 1 — receivers tighten checks, senders unchanged. Track 2's drop-from-the-format is a separate follow-up.

Test plan

  1. Exploit test (tests/multi-node/cluster-client-envelope-spoofing.test.ts): build two ClusterClient instances (A and B); A sends a forged envelope claiming from: B.identity; receptionist rejects the envelope; B never receives an unsolicited reply.

  2. Defense test: legitimate client sends envelope with matching from = its own identity; reply lands correctly.

  3. DoS-attempt test: attacker socket sends 100 forged envelopes with from: legit-client-address; counter increments to 100; legit client's socket sees zero spurious replies.

  4. Wire-format-compat test: a receptionist with the new code receives a frame from an old-client without the cross-check field — verify it still works (envelope's from matches socket identity by definition since the old client sent its own address).

  5. Regression: all v0.8.0 cluster-client.test.ts tests still pass.

Acceptance criteria

  • _onWire signature updated to include from: NodeAddress. All internal consumers updated to accept the new arg.
  • ClusterClientReceptionist cross-checks env.from against transportFrom; rejects mismatches.
  • cluster_envelope_from_mismatch_total metric exposed.
  • Five new tests pass; existing cluster-client tests green.
  • Track 2 (drop env.from) tracked as a follow-up issue; not part of this fix.
  • Plan-doc + README "Known security caveats" updated on land.

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