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
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-168 — sendReply() 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:
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)=>{constenv=msgasunknownasClusterClientEnvelopeMsg;constclaimedFrom=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:
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
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.
Defense test: legitimate client sends envelope with matching from = its own identity; reply lands correctly.
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.
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).
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.
Severity / Size
cluster-client-envelopeframe to a cluster node can route the reply to any other node in the cluster by forging the envelope'sfromfield. Combined withaskIdpredictability ([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.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. Decodesenv.fromand uses it directly as the reply destination without cross-checking against the TCP socket the frame arrived on.src/cluster/ClusterClientReceptionist.ts:157-168—sendReply()usescluster.transport.send(to, ...)wheretois the attacker-suppliedfromaddress.src/cluster/Cluster.ts:407-411— the_onWiredispatcher. Passes only themsgto the wire-handler callback; does not pass thefrom: NodeAddressof the actual TCP peer. This is the structural gap.Background
In v0.8.0 (#86, commit
5567dc5) we addedClusterClientfor outside-in connectivity: external processes open a TCP connection to a cluster node, sendcluster-client-envelopeframes containing{ to: path, from: clientAddress, askId, body }, and the receptionist routes replies back viacluster.transport.send(env.from, reply).The
env.fromfield 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:50001and setsenv.from = 'client@10.0.0.42:50001'. Receptionist replies viatransport.send('client@10.0.0.42:50001', ...). The TCP transport'sbyPeermap (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 sendsenv.from = 'client@10.0.0.42:50001'(impersonating the legitimate client's identity). Receptionist replies toclient@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'sbyPeerentry. But that defense doesn't help here: both connections exist legitimately inbyPeer, and the receptionist freely picks the routing target via the envelope field.Exploit walkthrough
Setup: cluster of 3 nodes; one legitimate
ClusterClientatclient@10.0.0.42:50001is 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-envelopeframes withfrom: client@10.0.0.42:50001and variousaskIdvalues.Step 2 — predicted askId (depends on #120 being un-fixed): attacker predicts the next
askIdthe 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 predictedaskId. 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. CallssendReply(cluster, from=client@10.0.0.42:50001, ...).Step 5 — reply routes to the legitimate client:
transport.sendlooks upbyPeer['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
askIdto 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:
How the 8 already-landed security fixes inform this
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.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 onlymsg. Change to also receive the originatingNodeAddress:Cluster.handleWirealready has thefromparameter (line 407); just plumb it through.Then in
ClusterClientReceptionist: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.fromfrom the wire format entirely.Once Track 1 is in,
env.fromis redundant — the receptionist gets the canonical from-identity from the transport. In a follow-up version we can drop the field fromClusterClientEnvelopeMsg: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_totalso operators see spoofing attempts in dashboards.API surface
All current
_onWireconsumers (DistributedData, DistributedPubSub, ClusterClientReceptionist, etc.) need to accept the new second argument (they can ignore it if not needed).Backward compatibility
The
_onWiresignature 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
Exploit test (
tests/multi-node/cluster-client-envelope-spoofing.test.ts): build twoClusterClientinstances (A and B); A sends a forged envelope claimingfrom: B.identity; receptionist rejects the envelope; B never receives an unsolicited reply.Defense test: legitimate client sends envelope with matching
from = its own identity; reply lands correctly.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.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
frommatches socket identity by definition since the old client sent its own address).Regression: all v0.8.0
cluster-client.test.tstests still pass.Acceptance criteria
_onWiresignature updated to includefrom: NodeAddress. All internal consumers updated to accept the new arg.env.fromagainsttransportFrom; rejects mismatches.cluster_envelope_from_mismatch_totalmetric exposed.env.from) tracked as a follow-up issue; not part of this fix.