Component: src/devtools/cluster/Federation.ts
Severity (assessment): LOW
CWE: CWE-770
DevToolsFederation.onEnvelope accepts any devtools-node-report off the cluster wire and inserts it into this.reports keyed by body.figures.address — a free-form string from the sender — retaining the attached actors array wholesale. Entries for unknown addresses are only evicted after an hour, and only when poll() runs, so the map has no bound.
Exploit walkthrough
Attacker = a hostile peer on the cluster wire, against any DevTools-enabled clustered node (the collector is started on every such node, not just the one serving the UI — see DevToolsServer.ts:197-198 "Both run here because any node may end up being the one that serves"). The attacker sends a stream of envelopes to /devtools/collector with body = {kind:'devtools-node-report', round:1, figures:{address:'node-<counter>@a:1'}, actors:[…10 000 fabricated ActorNode objects…]}, using a fresh address each time. Every one creates a new map entry that forgetLongGoneNodes refuses to drop for CLUSTER_MEMBER_RETENTION_MS (1 hour), and the retained actors array is whatever size the attacker sent. Gain: heap exhaustion of a production node at the attacker's chosen rate; secondarily, the fabricated entries are served to the operator's dashboard as real peers and real actor trees via ActorTreeTap.peerTrees and DevToolsFederation.peers, poisoning the view an operator is using to diagnose an incident.
Evidence — src/devtools/cluster/Federation.ts:126
src/devtools/cluster/Federation.ts:120-131
```ts
private onEnvelope(body: unknown): void {
if (!isNodeReport(body)) return;
const address = body.figures.address;
if (typeof address !== 'string' || address.length === 0) return;
// A report from a round we have moved past is still the newest thing
// that node has said, so it is kept — only its age is what matters.
this.reports.set(address, {
figures: body.figures,
actors: body.actors ?? this.reports.get(address)?.actors ?? null,
receivedAtMs: Date.now(),
});
}
```
src/devtools/cluster/Federation.ts:141-148
```ts
private forgetLongGoneNodes(nowMs = Date.now()): void {
const known = new Set(this.cluster.getMembers().map((member) => member.address.toString()));
for (const [address, cached] of this.reports) {
if (known.has(address)) continue;
if (nowMs - cached.receivedAtMs < CLUSTER_MEMBER_RETENTION_MS) continue;
this.reports.delete(address);
}
}
```
with `CLUSTER_MEMBER_RETENTION_MS = 60 * 60 * 1000` (src/devtools/protocol/ClusterStreamFrames.ts:50).
Why the existing guard does not cover it
I looked for a cap on reports.size (none — the only eviction is forgetLongGoneNodes, which is called solely from poll() and skips anything younger than an hour), a membership check on the reporting address (none — known is used to keep members, not to reject non-members), and a size/shape cap on the payload (isNodeReport, NodeProtocol.ts:53-60, checks only kind, typeof round === 'number' and typeof figures === 'object'; actors is not validated at all). I also checked whether the transport's frame cap helps — DEFAULT_MAX_FRAME_BYTES limits a single frame but not the number of frames or the cumulative retained bytes. No test covers this path. This is adjacent to the tracked unbounded-map issues #138/#137/#139 but is a distinct map in a different subsystem, reachable with a different message type, and additionally injects fabricated data into the operator's dashboard.
Suggested fix
Key the map on the transport-supplied from: NodeAddress (available as the handler's second argument) rather than body.figures.address, and ignore reports whose source is not in cluster.getMembers(). Add a hard reports.size ceiling (evict oldest) and a cap on the accepted actors length, mirroring the caps MailboxSamplerTap/toWireValue already apply on the browser-facing side.
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
Reproduced by reading. Federation.onEnvelope (Federation.ts:120-131) keys this.reports on body.figures.address, a free-form wire string, after isNodeReport validates only kind, typeof round === 'number' and typeof figures === 'object' (NodeProtocol.ts:53-60) — actors is stored entirely unvalidated. The only eviction is forgetLongGoneNodes (Federation.ts:141-148), called solely from poll() (line 92), which skips any entry younger than CLUSTER_MEMBER_RETENTION_MS, and known is used to retain members, never to reject non-members. There is no reports.size ceiling and no cap on the actors array, and peers() (line 102-113) serves every cached entry to the dashboard, so fabricated rows do reach the operator view. Same downgrade as index 2: the write path is the cluster wire, which is documented as unauthenticated plain TCP inside a trusted network, and an attacker there already has arbitrary-actor tell (Cluster.ts:646-652); the collector also only runs when DevTools is attached with a cluster option (DevToolsServer.ts:191-199).
Correction applied: The unbounded-map and unvalidated-payload facts are correct. Severity should be low, not medium: reachable only from the cluster port, whose threat model the project already documents as requiring mTLS/network policy, and where the attacker holds stronger primitives than heap growth.
Component:
src/devtools/cluster/Federation.tsSeverity (assessment): LOW
CWE: CWE-770
DevToolsFederation.onEnvelopeaccepts anydevtools-node-reportoff the cluster wire and inserts it intothis.reportskeyed bybody.figures.address— a free-form string from the sender — retaining the attachedactorsarray wholesale. Entries for unknown addresses are only evicted after an hour, and only whenpoll()runs, so the map has no bound.Exploit walkthrough
Attacker = a hostile peer on the cluster wire, against any DevTools-enabled clustered node (the collector is started on every such node, not just the one serving the UI — see DevToolsServer.ts:197-198 "Both run here because any node may end up being the one that serves"). The attacker sends a stream of envelopes to
/devtools/collectorwithbody = {kind:'devtools-node-report', round:1, figures:{address:'node-<counter>@a:1'}, actors:[…10 000 fabricated ActorNode objects…]}, using a freshaddresseach time. Every one creates a new map entry thatforgetLongGoneNodesrefuses to drop forCLUSTER_MEMBER_RETENTION_MS(1 hour), and the retainedactorsarray is whatever size the attacker sent. Gain: heap exhaustion of a production node at the attacker's chosen rate; secondarily, the fabricated entries are served to the operator's dashboard as real peers and real actor trees viaActorTreeTap.peerTreesandDevToolsFederation.peers, poisoning the view an operator is using to diagnose an incident.Evidence —
src/devtools/cluster/Federation.ts:126Why the existing guard does not cover it
I looked for a cap on
reports.size(none — the only eviction isforgetLongGoneNodes, which is called solely frompoll()and skips anything younger than an hour), a membership check on the reporting address (none —knownis used to keep members, not to reject non-members), and a size/shape cap on the payload (isNodeReport, NodeProtocol.ts:53-60, checks onlykind,typeof round === 'number'andtypeof figures === 'object';actorsis not validated at all). I also checked whether the transport's frame cap helps —DEFAULT_MAX_FRAME_BYTESlimits a single frame but not the number of frames or the cumulative retained bytes. No test covers this path. This is adjacent to the tracked unbounded-map issues #138/#137/#139 but is a distinct map in a different subsystem, reachable with a different message type, and additionally injects fabricated data into the operator's dashboard.Suggested fix
Key the map on the transport-supplied
from: NodeAddress(available as the handler's second argument) rather thanbody.figures.address, and ignore reports whose source is not incluster.getMembers(). Add a hardreports.sizeceiling (evict oldest) and a cap on the acceptedactorslength, mirroring the capsMailboxSamplerTap/toWireValuealready apply on the browser-facing side.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
Reproduced by reading.
Federation.onEnvelope(Federation.ts:120-131) keysthis.reportsonbody.figures.address, a free-form wire string, afterisNodeReportvalidates onlykind,typeof round === 'number'andtypeof figures === 'object'(NodeProtocol.ts:53-60) —actorsis stored entirely unvalidated. The only eviction isforgetLongGoneNodes(Federation.ts:141-148), called solely frompoll()(line 92), which skips any entry younger thanCLUSTER_MEMBER_RETENTION_MS, andknownis used to retain members, never to reject non-members. There is noreports.sizeceiling and no cap on theactorsarray, andpeers()(line 102-113) serves every cached entry to the dashboard, so fabricated rows do reach the operator view. Same downgrade as index 2: the write path is the cluster wire, which is documented as unauthenticated plain TCP inside a trusted network, and an attacker there already has arbitrary-actortell(Cluster.ts:646-652); the collector also only runs when DevTools is attached with aclusteroption (DevToolsServer.ts:191-199).Correction applied: The unbounded-map and unvalidated-payload facts are correct. Severity should be low, not medium: reachable only from the cluster port, whose threat model the project already documents as requiring mTLS/network policy, and where the attacker holds stronger primitives than heap growth.