Skip to content

[Security] EventStream subscriptions survive the subscriber's termination, so the list grows without bound and each publish costs O(N) dead-letter republishes — and the docs point at a cleanup that does not exist #763

Description

@pathosDev

Component: src/EventStream.ts
Severity (assessment): LOW
CWE: CWE-401 (missing release of memory after effective lifetime)

EventStream.subs is an append-only array keyed by ActorRef; the only removals are explicit unsubscribe calls. ActorCell.finalizeTermination cleans up watchers, watched targets, timers, stash and mailbox, but never touches the event stream. Every publish therefore keeps iterating the dead entries, and each subscriber.tell(event) to a terminated cell allocates a DeadLetter and re-enters EventStream.publish through DeadLetterRef, so one publish with N dead subscribers of that channel costs N extra publishes, each of which rescans the whole (growing) list.

Exploit walkthrough

No attacker required, but the growth rate is attacker-influenced in any request-per-actor design. Preconditions: the documented pattern of subscribing in preStart (the very first example on the event-stream page does exactly that) combined with short-lived actors — one actor per request, per connection, per saga. Steps: each request spawns an actor that subscribes and is stopped when the request ends; nothing removes the subscription. After N requests the bus holds N dead subscriptions, and since the framework itself publishes ActorStarted/ActorStopped/DeadLetter on every spawn and stop, each subsequent request pays O(N) list scans plus O(N) dead-letter allocations for the matching channel — a steadily worsening CPU and allocation cost plus an unbounded retained-memory footprint (each subscription pins an ActorRef, which pins its ActorCell). A remote client that can drive request volume drives the degradation.

Evidence — src/EventStream.ts

src/EventStream.ts:42 — private subs: Subscription[] = []; and src/EventStream.ts:102-121 — publish iterates every entry, with removal only in unsubscribe.
src/internal/ActorCell.ts:729-779 — finalizeTermination clears _watchers and _watching but contains no eventStream.unsubscribe(this.self).
src/internal/ActorCell.ts:528-537 — a tell to a terminated cell becomes this.system.deadLetters.tell(new DeadLetter(...)), and src/internal/DeadLetterRef.ts:22-36 republishes it on the same bus.
The docs acknowledge no auto-unsubscribe but describe a reclamation that does not exist — docs/src/content/docs/fundamentals/event-stream.mdx: "The bus doesn't auto-unsubscribe stopped refs… or rely on the dead-letter cleanup if you don't mind the noise."
Probe against the real runtime — 50 short-lived actors that subscribe in preStart and are then stopped:

live actors that subscribed: 0, subscriptions still held: 50

Why the existing guard does not cover it

DeadLetterRef does prevent the infinite loop (if (message instanceof DeadLetter && message.message instanceof DeadLetter) return;, src/internal/DeadLetterRef.ts:30) — the amplification is bounded at one extra hop per dead subscriber, not unbounded recursion. subscribe de-duplicates predicate-free (subscriber, channel) pairs, so a single actor re-subscribing does not multiply. Neither guard bounds the number of distinct dead subscribers.

Suggested fix

Unsubscribe on termination: call this.system.eventStream.unsubscribe(this.self) from ActorCell.finalizeTermination (the cell already runs the analogous cleanup for death-watch). Cheaper alternative: have publish prune a subscription whose LocalActorRef cell reports isTerminated(), or hold subscribers weakly. Either way the docs' "rely on the dead-letter cleanup" line should be corrected.

Verification status

Found in the second, independent whole-framework security re-audit of 2026-08-02 (v0.12.0) — a fresh pass run without reference to the first wave's findings, then triaged against the existing tracker and adjudicated by verifiers instructed to refute it.

Verifier note

Confirmed against source. EventStream.subs is a plain append-only array (src/EventStream.ts:42); the only removals are in unsubscribe (src/EventStream.ts:89-99), which nothing in the framework calls on termination — a repo-wide grep for eventStream.unsubscribe returns exactly one hit, in src/devtools/internal/EventStreamProbe.ts:69. ActorCell.finalizeTermination (src/internal/ActorCell.ts:729-779) cancels timers, runs postStop, dead-letters the stash and mailbox, clears _watchers (765) and _watching (771), and notifies the parent — it never touches the event stream.

The amplification checks out. A tell to a terminated cell hits postUserEnvelope (src/internal/ActorCell.ts:545-549), which becomes deadLetters.tell(new DeadLetter(...)); DeadLetterRef.tell (src/internal/DeadLetterRef.ts:22-36) then re-publishes on the same bus, and the guard at line 30 only drops a nested DeadLetter. So one publish with N dead subscribers on a channel costs N extra full scans of the subscription list — O(N × |subs|), degenerating to O(N²) when the dead subscribers dominate the list. The list also pins each ActorRef, which pins its ActorCell, so this is retained memory, not just CPU.

The documented-pattern precondition is real: the very first example on docs/src/content/docs/fundamentals/event-stream.mdx (lines 18-34) subscribes from preStart, and the caution box at lines 155-166 tells readers they may "rely on the dead-letter cleanup if you don't mind the noise" — there is no such cleanup. DeadLetterRef breaks the delivery loop; it never removes a subscription. That sentence should be corrected regardless of whether the auto-unsubscribe is implemented.

Severity held at LOW: no attacker primitive, the behaviour is partly documented, and the working mitigation (unsubscribe in postStop) exists. The substance is the false mitigation plus the unbounded, self-amplifying growth under the framework's own documented subscribe-in-preStart idiom.

Correction applied: Line ranges are slightly off: publish is EventStream.ts:102-122 (not 102-121) and installProcessHooks-style off-by-ones aside, the terminated-tell path is ActorCell.ts:528-537 (postUserMessage) and 545-549 (postUserEnvelope) — the latter is the one LocalActorRef.tell actually uses, so it is the live path. Also note the docs are not wholly silent: event-stream.mdx:161-163 explicitly states "The bus doesn't auto-unsubscribe stopped refs" and recommends unsubscribe in postStop, which is a working mitigation; only the trailing alternative ("or rely on the dead-letter cleanup") is fictitious.

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