Component: src/cluster/sharding/ShardRegion.ts
Severity (assessment): MEDIUM
CWE: CWE-940
sharding.HandOff is a coordinator-only directive, but the region executes it for any sender: it marks the shard handing-off, drops the entity bookkeeping, and calls shard.stop(), which terminates every entity actor under it. Nothing checks that the message came from the node hosting the coordinator, and no rebalanceInProgress-style correlation exists on the region side.
Exploit walkthrough
Attacker = any host that completes the hello handshake. It sends {$t:'sharding.HandOff', shardId: n} to actor-ts://<system>/system/cluster/sharding/region-<type> on each node, for n = 0…numShards-1. Each frame stops the shard actor: all live entities in it are terminated (in-memory state gone, in-flight messages in their mailboxes dropped), buffered traffic stalls while shardState is 'handing-off', and completeHandOff then deletes the region's cached shardHomes entry so the next message re-asks the coordinator — which, chained with finding 1, hands the region the attacker's own node as the new home. A few dozen bytes per shard, repeatable indefinitely, and the coordinator never learns anything is wrong because it ignores the unsolicited HandOffComplete.
Evidence — src/cluster/sharding/ShardRegion.ts:720
private onHandOff(message: HandOff): void {
const shardId = message.shardId;
const entityIds = Array.from(this.shardEntities.get(shardId) ?? []);
…
this.shardState.set(shardId, 'handing-off');
const ack: BeginHandOffAcknowledgment = { $t: 'sharding.BeginHandOffAcknowledgment', shardId };
this.tellCoordinator(ack);
…
this.forgetShardEntities(shardId);
const shard = this.shards.get(shardId);
if (!shard) { this.completeHandOff(shardId); return; }
this.handingOff.add(shardId);
shard.stop();
Why the existing guard does not cover it
Looked for a sender/origin check in handleShardingMessage (it dispatches purely on $t; this.sender is never consulted, and the remote delivery path Cluster.dispatchEnvelope → ref.tell(body) supplies no sender at all), for a handoff correlation token (the coordinator tracks rebalanceInProgress but sends nothing the region can verify — HandOff carries only shardId), and for a registered/leader precondition (the field this.registered exists but is only used for logging; nothing gates onHandOff on it). No test sends a sharding.HandOff to a region.
Suggested fix
Gate the coordinator-only variants (HandOff, ShardHome, RememberedEntities, RegisterAcknowledgment) on the message having arrived from the node the region currently believes hosts the coordinator, by dispatching the region through the from-carrying per-path envelope handler; drop and log anything else. A correlation nonce echoed from BeginHandOff would additionally stop a stale-but-authentic coordinator frame from being replayed.
Verification status
Found in the whole-framework security audit of 2026-08-01 (v0.12.0), then adjudicated by an independent verifier instructed to refute it.
Verifier note
ShardRegion.onHandOff (ShardRegion.ts:720-741) executes unconditionally: sets shardState to 'handing-off', calls forgetShardEntities(shardId) and shard.stop(). handleShardingMessage (473-494) dispatches on $t alone and never reads this.sender; the remote delivery path (Cluster.ts:651 refOpt.value.tell(decoded.body as never)) supplies no sender at all, so no origin check is even possible today. this.registered is set in onRegisterAcknowledgment (529) and never gates anything.
Correction applied: The impact is self-healing rather than persistent: completeHandOff (743-758) drops the cached home, so the next user message re-asks the coordinator and the shard is re-allocated and respawned. What the attacker gets is repeatable termination of in-memory entity state plus a routing stall — a cheap sustained DoS, not a takeover — which is a medium, not a high, especially next to finding 2 on the same wire-access precondition.
Component:
src/cluster/sharding/ShardRegion.tsSeverity (assessment): MEDIUM
CWE: CWE-940
sharding.HandOffis a coordinator-only directive, but the region executes it for any sender: it marks the shardhanding-off, drops the entity bookkeeping, and callsshard.stop(), which terminates every entity actor under it. Nothing checks that the message came from the node hosting the coordinator, and norebalanceInProgress-style correlation exists on the region side.Exploit walkthrough
Attacker = any host that completes the hello handshake. It sends
{$t:'sharding.HandOff', shardId: n}toactor-ts://<system>/system/cluster/sharding/region-<type>on each node, for n = 0…numShards-1. Each frame stops the shard actor: all live entities in it are terminated (in-memory state gone, in-flight messages in their mailboxes dropped), buffered traffic stalls whileshardStateis'handing-off', andcompleteHandOffthen deletes the region's cachedshardHomesentry so the next message re-asks the coordinator — which, chained with finding 1, hands the region the attacker's own node as the new home. A few dozen bytes per shard, repeatable indefinitely, and the coordinator never learns anything is wrong because it ignores the unsolicitedHandOffComplete.Evidence —
src/cluster/sharding/ShardRegion.ts:720Why the existing guard does not cover it
Looked for a sender/origin check in
handleShardingMessage(it dispatches purely on$t;this.senderis never consulted, and the remote delivery pathCluster.dispatchEnvelope→ref.tell(body)supplies no sender at all), for a handoff correlation token (the coordinator tracksrebalanceInProgressbut sends nothing the region can verify —HandOffcarries onlyshardId), and for aregistered/leader precondition (the fieldthis.registeredexists but is only used for logging; nothing gatesonHandOffon it). No test sends asharding.HandOffto a region.Suggested fix
Gate the coordinator-only variants (
HandOff,ShardHome,RememberedEntities,RegisterAcknowledgment) on the message having arrived from the node the region currently believes hosts the coordinator, by dispatching the region through thefrom-carrying per-path envelope handler; drop and log anything else. A correlation nonce echoed fromBeginHandOffwould additionally stop a stale-but-authentic coordinator frame from being replayed.Verification status
Found in the whole-framework security audit of 2026-08-01 (
v0.12.0), then adjudicated by an independent verifier instructed to refute it.Verifier note
ShardRegion.onHandOff (ShardRegion.ts:720-741) executes unconditionally: sets
shardStateto 'handing-off', callsforgetShardEntities(shardId)andshard.stop(). handleShardingMessage (473-494) dispatches on$talone and never readsthis.sender; the remote delivery path (Cluster.ts:651refOpt.value.tell(decoded.body as never)) supplies no sender at all, so no origin check is even possible today.this.registeredis set in onRegisterAcknowledgment (529) and never gates anything.Correction applied: The impact is self-healing rather than persistent: completeHandOff (743-758) drops the cached home, so the next user message re-asks the coordinator and the shard is re-allocated and respawned. What the attacker gets is repeatable termination of in-memory entity state plus a routing stall — a cheap sustained DoS, not a takeover — which is a medium, not a high, especially next to finding 2 on the same wire-access precondition.