Skip to content

[Security] DevTools federation collector stores unbounded peer reports keyed by an attacker-controlled address string #593

Description

@pathosDev

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: lowNice-to-have / niche / demand-drivenproduction-goalBlocks or defines the path to production readinesssecuritySecurity-relevant — see severity label for impact tierseverity: lowMinor / informational / mitigated-by-design

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions