Skip to content

[Security] Messages discarded by a bounded mailbox's overflow policy are never dead-lettered — unlike every other loss path in the framework — so the sender, payload, MDC and trace context of the lost envelope are destroyed and only a counter remains #773

Description

@pathosDev

Component: src/mailbox/BoundedMailbox.ts
Severity (assessment): LOW

The drop-head arm captures the evicted envelope solely to decide whether to increment a counter, then discards it; the drop-new arm never even sees the envelope again. Neither reaches system.deadLetters.

Exploit walkthrough

Not directly exploitable; it is the observability gap that makes several of the higher findings undetectable in production. The dropped envelope carries sender, message, the MDC context and the tracing trace context (src/internal/Mailbox.ts:5-31) — everything needed to attribute the loss — and all of it is thrown away. actor_mailbox_dropped_total{class,path,reason} records that a drop happened but not what was lost, so an operator cannot tell a shed telemetry sample from a dropped Terminated (the HIGH finding above) or a dropped ProducerSend carrying an unsettled confirmation callback (the MEDIUM finding above). An attacker exploiting either of those leaves no forensic trace beyond a counter that looks like normal load shedding.

The same applies to BackoffSupervisor's stash overflow, which drops the oldest buffered message with only a log line and no dead letter (src/pattern/BackoffSupervisor.ts:328-333) — and emits one log.warn per dropped message, so a sustained flood against a supervisor in a backoff window is also a log-amplification vector.

Evidence — src/mailbox/BoundedMailbox.ts

src/mailbox/BoundedMailbox.ts:43-59 —

.with('drop-head', () => {
  const dropped = super.removeOldest();
  if (dropped !== undefined) {
    this.droppedCount++;
    this.onDrop?.('drop-head');
  }
  super.enqueue(env);
})
.with('drop-new', () => {
  this.droppedCount++;
  this.onDrop?.('drop-new');
})

Contrast with every other loss path, which does dead-letter:
src/internal/ActorCell.ts:441-448 (deadLetterStash), 749-751 (for (const env of this.mailbox.drainUser()) this.system.deadLetters.tell(new DeadLetter(...))), 529-531 and 546-548 (tells to a terminated cell).
The framework's own rationale for why silent loss is unacceptable — src/internal/ActorCell.ts:432-437: "That is the worst shape a lost message can take … and 'I told an actor and nothing happened, anywhere' is unfalsifiable from the outside. Dead-lettering costs nothing and makes it visible."

Why the existing guard does not cover it

droppedCount, the onDrop hook and the actor_mailbox_dropped_total metric with {class, path, reason} labels (src/internal/ActorCell.ts:827-834) are real observability and are documented in docs/operations/tuning/mailbox-sizing.mdx:131-138. The docs are also honest that drop-head means "silent message loss" (mailbox-sizing.mdx:30). The gap is that the framework's own stated standard — dead-letter it so the loss is falsifiable — is applied to the stash and to termination drains but not to overflow.

Suggested fix

Give BoundedMailbox a reference to (or a callback into) the dead-letter path and route every dropped envelope there, preserving sender/context/trace. Do the same for BackoffSupervisor's stash eviction, and rate-limit or aggregate its per-drop log.warn so an overflow cannot amplify into a log flood.

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 at src/mailbox/BoundedMailbox.ts:43-59. The drop-head arm binds super.removeOldest() to dropped and uses it solely for the !== undefined check that gates droppedCount++ / onDrop; the envelope is then unreferenced. The drop-new arm never sees the envelope at all. Neither arm reaches system.deadLetters, and I verified the contrast the finding draws: src/internal/ActorCell.ts:441-448 dead-letters the stash, :749-751 dead-letters the drained mailbox on termination, and :529-531 / :546-548 dead-letter any tell to a terminated cell — so overflow is the one loss path in the framework that does not. The comment at :427-437 does state the project's rationale for why silent loss is unacceptable. The lost fields are real: src/internal/Mailbox.ts:5-31 shows sender, context (MDC), trace and enqueuedAtMs all travelling on the envelope. src/pattern/BackoffSupervisor.ts:328-333 confirmed verbatim — stash.shift() with a log.warn and no dead letter.

Holding LOW rather than raising it: this is an observability gap, not an exploitable defect, and drop-head being the default mailbox policy (src/util/Constants.ts:96) is what keeps it above INFORMATIONAL — every actor in a default deployment loses envelopes with no recoverable record. Corrections applied above about the existing metric/hook, the cost of the proposed fix on the sender's stack, and the overstated log-amplification framing.

Correction applied: (1) "Only a counter remains" understates the existing signal: both actor_mailbox_dropped_total{class,path,reason} (src/internal/ActorCell.ts:827-834) and the onDrop hook fire on every drop. What is destroyed is the payload and its attribution (sender / MDC / trace), not the fact of the drop — the issue should be framed as "no forensic record", not "no signal". (2) The suggested fix needs a caveat the issue must carry: BoundedMailbox.enqueue runs on the sender's stack, and DeadLetterRef.tell publishes synchronously to every event-stream subscriber (src/internal/DeadLetterRef.ts:22-36). Routing every shed envelope there converts load-shedding into per-message work under exactly the pressure the bound exists to absorb; a sampled, rate-limited or opt-in dead-letter path is the defensible shape. (3) The BackoffSupervisor "log-amplification vector" framing is overstated — it is one log.warn per message, and only while the stash is already full inside a backoff window; that is the same exposure as any per-message log line, not a distinct amplifier.

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