Component: src/cluster/Cluster.ts
Severity (assessment): CRITICAL
Member.fromData copies data.status off the wire with no membership check against the seven legal MemberStatus values. mergeMember stores the member first, then calls emitStatusTransition, whose match(next.status)...exhaustive() throws Pattern matching error for any unknown string. The exception escapes handleWire into the transport's data callback, and because the poisoned member was already written to the map it is re-broadcast to every peer on the next gossip tick.
Exploit walkthrough
Attacker speaking the wire protocol to one node sends {t:'gossip', from:<any>, members:[{address:{systemName:'app',host:'h',port:60100}, status:'pwned', version:Date.now()}]}. I ran exactly this: the call threw Error: Pattern matching error: no pattern matches value "pwned" with the stack emitStatusTransition (Cluster.ts:967) → mergeMember (Cluster.ts:893) → onGossip (Cluster.ts:567) → handleWire (Cluster.ts:501) → Transport.ts:321, and the member map afterwards held the ghost with status='pwned'. In TcpTransport.onData only decoder.push(chunk) is inside the try/catch (Transport.ts:207-218); the for (const message of frames) this.onMessage(...) loop at line 219 is not, so the throw reaches raw.on('data', ...) in NodeTcpBackend.ts, i.e. an uncaught exception (Node's default is process exit). Worse, it is self-propagating: in my second probe I injected the frame at node A only, and node A's own gossipTick shipped the stored status:'pwned' member to node B, where the same throw escaped InMemoryTransport's queueMicrotask (Transport.ts:321) as an unhandled top-level error. One frame at one reachable node therefore crashes the whole cluster.
Evidence — src/cluster/Cluster.ts:823
// Cluster.ts:822-823
private mergeMember(data: MemberData): void {
const incoming = Member.fromData(data);
// Member.ts:52-60 — status passed through verbatim
static fromData(data: MemberData): Member {
return new Member(NodeAddress.fromJSON(data.address), data.status, data.version, data.roles ?? [], data.removedAt);
// Cluster.ts:885-894 — stored BEFORE the throwing call
if (!existing) {
this.members.set(incoming.address.toString(), incoming);
...
if (incoming.status !== 'joining') {
this.emitStatusTransition(new Member(incoming.address, 'joining', 0), incoming);
// Cluster.ts:953-967
match(next.status)
.with('up', () => { ... })
...
.with('joining', () => { /* transient; no event */ })
.exhaustive();
Why the existing guard does not cover it
I grepped src/ for any status allow-list (MEMBER_STATUS, isMemberStatus, validStatus) — none exists; MemberStatus is a pure compile-time union in Protocol.ts:26. mergeMember's hardening (version cap line 843, removedAt cap line 866) validates only the numeric fields; the JSON parse in FrameDecoder.push does no schema validation. No process.on('uncaughtException') handler exists anywhere in src/. tests/multi-node/cluster-security.test.ts pins NaN/Infinity/MAX_SAFE_INTEGER versions and bad removedAt, but nothing pins the status field.
Suggested fix
Validate at the wire boundary in Member.fromData (or a guard in mergeMember before the map write): keep a const MEMBER_STATUSES: ReadonlySet<string> derived from the union and drop the member with the same log.warn(...possible exploit) used for implausible versions. Additionally make emitStatusTransition non-fatal by giving the match an .otherwise(...) fallback, and wrap the for (const message of frames) this.onMessage(...) loop in Transport.ts:219 so a handler throw closes the connection instead of escaping the runtime's data callback.
Severity note
Labelled severity: critical even though the verifier below argued it down to high. Their argument is a relative one — that a neighbouring hole grants the same capability, so this door is not the only way into the room. That is true, and those neighbouring findings are catalogued as critical too; it is not an argument about this defect's own impact. Against the label's own definition — "confidentiality/integrity bypass with system-wide impact", the bar that #116 met — a single unauthenticated frame corrupting replicated cluster state qualifies. Retag if you read the boundary differently.
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
Member.fromData (Member.ts:52-60) passes data.status through verbatim; MemberStatus is a compile-time-only union (Protocol.ts:26-33) and FrameDecoder.push (Protocol.ts:209) does a bare JSON.parse with no schema check. mergeMember writes the member at Cluster.ts:886 before calling emitStatusTransition at 893, whose match(next.status)...exhaustive() (Cluster.ts:953-967) throws. I verified ts-pattern's behaviour directly (Pattern matching error: no pattern matches value "pwned") and then reproduced end-to-end: injecting a gossip frame with status:'pwned' threw out of handleWire and the map afterwards held Member(adv2@h:60100, pwned, v…). The escape path is real: TcpTransport.ts:219 for (const message of frames) this.onMessage(...) sits outside the try/catch that ends at 218, and NodeTcpBackend.ts:26/70 hand onData straight to raw.on('data', …) with no wrapper; no uncaughtException handler exists anywhere in src/.
Correction applied: The 'self-propagating, one frame kills the whole cluster' framing is runtime-dependent and should be split: on Node the throw reaches an unwrapped raw.on('data') handler, so the receiving node dies before it can re-gossip the poison; on Deno the read loop catches it (DenoTcpBackend.ts:114-121) and routes it to onError, so that node survives and does re-gossip the poisoned member to peers. Either way it is an unauthenticated remote DoS per node; the cluster-wide cascade only holds on runtimes that swallow the throw.
Second opinion (independent refuter, high/critical only)
Holds up on every axis I tried to break it on. (1) Shipped code: src/cluster/Cluster.ts + src/cluster/Member.ts, both exported via src/index.ts — not tests, not examples. (2) Attacker-reachable: Cluster._start (Cluster.ts:405) binds transport.setHandler directly to handleWire; TcpTransport.onMessage (Transport.ts:222-278) gates only on an unauthenticated hello frame, and TLS is optional with requestClientCert defaulting to false (NodeTcpBackend.ts:37, BunTcpBackend.ts:39). Any party that can reach the cluster port can send the frame. The "it's a trusted network" refutation fails on the codebase's own terms: mergeMember already carries two shipped 'Security: … possible exploit' guards (version cap at Cluster.ts:843-850, removedAt cap at 866-874), so gossip field content is explicitly in the project's threat model. (3) No guard elsewhere: MemberStatus is a bare type union (Protocol.ts:26-33); grep over src/cluster finds no runtime membership check, and Member.fromData (Member.ts:52-60) copies data.status verbatim. (4) Reproduced against a real Cluster built from src/index.js with a single gossip frame carrying status:'pwned' — the error 'Pattern matching error: no pattern matches value "pwned"' surfaced at process-level uncaughtException (nothing between emitStatusTransition and the transport callback catches), and the member map afterwards held Member(app@h:60100, pwned, v...), i.e. stored before the throw (set at Cluster.ts:886, throw at 893). (5) Self-propagation confirmed by code, not just by the finder's probe: gossipTick serializes Array.from(this.members.values()) wholesale (Cluster.ts:692), so the poisoned entry is rebroadcast on the next tick. (6) Escape path confirmed: TcpTransport.onData wraps only decoder.push in try/catch (Transport.ts:207-218); the frame-dispatch loop at line 219 is outside it, NodeTcpBackend.ts:26/70 registers raw.on('data', …) with no protection, and there is no uncaughtException/unhandledRejection handler anywhere in src/. Severity: I agree with high. One unauthenticated frame at one reachable node yields an uncaught exception (Node default: process exit) plus a stored, re-gossiped poison that reproduces the same throw on every peer — cluster-wide availability loss with no preconditions beyond network reachability. It is strictly worse than the version-cap exploit the project already shipped a fix for (that one pinned a single member; this one takes down every node). The mitigating consideration, which I weighed and rejected as a downgrade: an attacker with wire access can already inject envelope frames to arbitrary actor paths or forge leave, so the marginal capability gain is smaller than it looks — but no other wire message crashes all nodes from a single injection point, so it does not drop below high. Not critical, because it is availability-only (no code execution, no data disclosure) and the in-memory member map means a full cluster restart clears the poison absent a repeat injection.
Component:
src/cluster/Cluster.tsSeverity (assessment): CRITICAL
Member.fromDatacopiesdata.statusoff the wire with no membership check against the seven legalMemberStatusvalues.mergeMemberstores the member first, then callsemitStatusTransition, whosematch(next.status)...exhaustive()throwsPattern matching errorfor any unknown string. The exception escapeshandleWireinto the transport's data callback, and because the poisoned member was already written to the map it is re-broadcast to every peer on the next gossip tick.Exploit walkthrough
Attacker speaking the wire protocol to one node sends
{t:'gossip', from:<any>, members:[{address:{systemName:'app',host:'h',port:60100}, status:'pwned', version:Date.now()}]}. I ran exactly this: the call threwError: Pattern matching error: no pattern matches value "pwned"with the stackemitStatusTransition (Cluster.ts:967) → mergeMember (Cluster.ts:893) → onGossip (Cluster.ts:567) → handleWire (Cluster.ts:501) → Transport.ts:321, and the member map afterwards held the ghost withstatus='pwned'. InTcpTransport.onDataonlydecoder.push(chunk)is inside the try/catch (Transport.ts:207-218); thefor (const message of frames) this.onMessage(...)loop at line 219 is not, so the throw reachesraw.on('data', ...)in NodeTcpBackend.ts, i.e. an uncaught exception (Node's default is process exit). Worse, it is self-propagating: in my second probe I injected the frame at node A only, and node A's owngossipTickshipped the storedstatus:'pwned'member to node B, where the same throw escapedInMemoryTransport'squeueMicrotask(Transport.ts:321) as an unhandled top-level error. One frame at one reachable node therefore crashes the whole cluster.Evidence —
src/cluster/Cluster.ts:823Why the existing guard does not cover it
I grepped src/ for any status allow-list (
MEMBER_STATUS,isMemberStatus,validStatus) — none exists;MemberStatusis a pure compile-time union in Protocol.ts:26.mergeMember's hardening (version cap line 843,removedAtcap line 866) validates only the numeric fields; the JSON parse inFrameDecoder.pushdoes no schema validation. Noprocess.on('uncaughtException')handler exists anywhere in src/. tests/multi-node/cluster-security.test.ts pins NaN/Infinity/MAX_SAFE_INTEGER versions and badremovedAt, but nothing pins thestatusfield.Suggested fix
Validate at the wire boundary in
Member.fromData(or a guard inmergeMemberbefore the map write): keep aconst MEMBER_STATUSES: ReadonlySet<string>derived from the union and drop the member with the samelog.warn(...possible exploit)used for implausible versions. Additionally makeemitStatusTransitionnon-fatal by giving the match an.otherwise(...)fallback, and wrap thefor (const message of frames) this.onMessage(...)loop in Transport.ts:219 so a handler throw closes the connection instead of escaping the runtime's data callback.Severity note
Labelled
severity: criticaleven though the verifier below argued it down tohigh. Their argument is a relative one — that a neighbouring hole grants the same capability, so this door is not the only way into the room. That is true, and those neighbouring findings are catalogued as critical too; it is not an argument about this defect's own impact. Against the label's own definition — "confidentiality/integrity bypass with system-wide impact", the bar that #116 met — a single unauthenticated frame corrupting replicated cluster state qualifies. Retag if you read the boundary differently.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
Member.fromData(Member.ts:52-60) passesdata.statusthrough verbatim;MemberStatusis a compile-time-only union (Protocol.ts:26-33) andFrameDecoder.push(Protocol.ts:209) does a bareJSON.parsewith no schema check.mergeMemberwrites the member at Cluster.ts:886 before callingemitStatusTransitionat 893, whosematch(next.status)...exhaustive()(Cluster.ts:953-967) throws. I verified ts-pattern's behaviour directly (Pattern matching error: no pattern matches value "pwned") and then reproduced end-to-end: injecting a gossip frame withstatus:'pwned'threw out ofhandleWireand the map afterwards heldMember(adv2@h:60100, pwned, v…). The escape path is real: TcpTransport.ts:219for (const message of frames) this.onMessage(...)sits outside the try/catch that ends at 218, and NodeTcpBackend.ts:26/70 handonDatastraight toraw.on('data', …)with no wrapper; nouncaughtExceptionhandler exists anywhere in src/.Correction applied: The 'self-propagating, one frame kills the whole cluster' framing is runtime-dependent and should be split: on Node the throw reaches an unwrapped
raw.on('data')handler, so the receiving node dies before it can re-gossip the poison; on Deno the read loop catches it (DenoTcpBackend.ts:114-121) and routes it to onError, so that node survives and does re-gossip the poisoned member to peers. Either way it is an unauthenticated remote DoS per node; the cluster-wide cascade only holds on runtimes that swallow the throw.Second opinion (independent refuter, high/critical only)
Holds up on every axis I tried to break it on. (1) Shipped code: src/cluster/Cluster.ts + src/cluster/Member.ts, both exported via src/index.ts — not tests, not examples. (2) Attacker-reachable: Cluster._start (Cluster.ts:405) binds transport.setHandler directly to handleWire; TcpTransport.onMessage (Transport.ts:222-278) gates only on an unauthenticated
helloframe, and TLS is optional with requestClientCert defaulting to false (NodeTcpBackend.ts:37, BunTcpBackend.ts:39). Any party that can reach the cluster port can send the frame. The "it's a trusted network" refutation fails on the codebase's own terms: mergeMember already carries two shipped 'Security: … possible exploit' guards (version cap at Cluster.ts:843-850, removedAt cap at 866-874), so gossip field content is explicitly in the project's threat model. (3) No guard elsewhere: MemberStatus is a bare type union (Protocol.ts:26-33); grep over src/cluster finds no runtime membership check, and Member.fromData (Member.ts:52-60) copies data.status verbatim. (4) Reproduced against a real Cluster built from src/index.js with a single gossip frame carrying status:'pwned' — the error 'Pattern matching error: no pattern matches value "pwned"' surfaced at process-level uncaughtException (nothing between emitStatusTransition and the transport callback catches), and the member map afterwards held Member(app@h:60100, pwned, v...), i.e. stored before the throw (set at Cluster.ts:886, throw at 893). (5) Self-propagation confirmed by code, not just by the finder's probe: gossipTick serializes Array.from(this.members.values()) wholesale (Cluster.ts:692), so the poisoned entry is rebroadcast on the next tick. (6) Escape path confirmed: TcpTransport.onData wraps only decoder.push in try/catch (Transport.ts:207-218); the frame-dispatch loop at line 219 is outside it, NodeTcpBackend.ts:26/70 registers raw.on('data', …) with no protection, and there is no uncaughtException/unhandledRejection handler anywhere in src/. Severity: I agree with high. One unauthenticated frame at one reachable node yields an uncaught exception (Node default: process exit) plus a stored, re-gossiped poison that reproduces the same throw on every peer — cluster-wide availability loss with no preconditions beyond network reachability. It is strictly worse than the version-cap exploit the project already shipped a fix for (that one pinned a single member; this one takes down every node). The mitigating consideration, which I weighed and rejected as a downgrade: an attacker with wire access can already injectenvelopeframes to arbitrary actor paths or forgeleave, so the marginal capability gain is smaller than it looks — but no other wire message crashes all nodes from a single injection point, so it does not drop below high. Not critical, because it is availability-only (no code execution, no data disclosure) and the in-memory member map means a full cluster restart clears the poison absent a repeat injection.