Skip to content

[Security] BoundedMailbox does not override prependUser, so unstashAll() unshifts up to 1024 envelopes straight past the capacity check, the overflow policy and the drop accounting #772

Description

@pathosDev

Component: src/internal/Mailbox.ts
Severity (assessment): LOW

BoundedMailbox overrides only enqueue; the base prependUser performs this.userQueue.unshift(...envs) directly, so every message re-entering the mailbox from the stash bypasses the capacity check, the overflow policy and the drop accounting.

Exploit walkthrough

Preconditions: an actor that uses context.stash() / context.unstashAll() — the standard initialisation pattern — with a bounded mailbox (the default for every actor).

  1. The actor stashes messages while it initialises. ActorCell.stash() correctly enforces its own cap (DEFAULT_STASH_CAPACITY = 1024, throwing StashOverflowError, src/internal/ActorCell.ts:406-408).
  2. Meanwhile the mailbox fills to capacity with new traffic.
  3. On unstashAll(), up to 1024 envelopes are unshifted onto an already-full queue with no policy consultation: a reject mailbox never throws, a drop-head/drop-new mailbox never drops or counts, and droppedCount / actor_mailbox_dropped_total under-report.

The overshoot is bounded (capacity + 1024 for the default 10 000 mailbox), which is why this is LOW rather than a memory-exhaustion finding — the actor's memory ceiling is exceeded by a known, small margin rather than without limit. The real cost is that the mailbox's stated invariant ("a guaranteed memory ceiling", src/util/Constants.ts:78-83) is not actually an invariant, and an operator tuning mailboxCapacity against measured heap has a blind spot. A custom mailbox with a small capacity (new BoundedMailbox({capacity: 10})) is overshot by two orders of magnitude, as the probe shows.

Evidence — src/internal/Mailbox.ts

src/internal/Mailbox.ts:48-51 —

/** Put envelopes at the FRONT of the user queue, preserving their order. */
prependUser(envs: Array<Envelope<T>>): void {
  this.userQueue.unshift(...envs);
}

src/mailbox/BoundedMailbox.ts overrides enqueue (line 40) and nothing else — there is no prependUser override in the file.

Callers: src/internal/ActorCell.ts:415-423 (unstashAll()this.mailbox.prependUser(drained)) and src/internal/ActorCell.ts:500 (throttle 'pause' re-queue, which returns a message already counted so it is benign).

Executed probe (real modules, bun; new BoundedMailbox({capacity: 3, overflow: 'reject'}) filled to 3, then 500 envelopes prepended):

C: bounded mailbox capacity=3, size after prependUser = 503

Why the existing guard does not cover it

ActorCell.stash() enforces _stashCapacity = 1024 and throws StashOverflowError, which is what keeps the overshoot bounded — this is a real and effective second line of defence. The stash is also correctly dead-lettered on stop and restart (deadLetterStash, ActorCell.ts:441-448, called from both finalizeTermination and onRecreate), so nothing vanishes. PriorityMailbox DOES override prependUser to re-enter through enqueue (src/mailbox/PriorityMailbox.ts:61-65), demonstrating the correct shape — BoundedMailbox simply did not follow it.

Suggested fix

Either make Mailbox.prependUser a template that routes each envelope through an overridable single-envelope hook, or add a prependUser override to BoundedMailbox that applies the overflow policy from the tail (drop the newest queued messages to make room for the older stashed ones, which is the semantically correct direction for a prepend). Also replace unshift(...envs) with a non-spread splice so a large array cannot hit the engine's argument-count limit.

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. Mailbox.prependUser (src/internal/Mailbox.ts:48-51) is a bare this.userQueue.unshift(...envs), and src/mailbox/BoundedMailbox.ts overrides only enqueue (40-65) — I read the whole file; there is no prependUser override. So every envelope re-entering via prepend skips the size >= capacity check, the match(this.overflow) policy dispatch, the droppedCount increment and the onDrop callback that feeds actor_mailbox_dropped_total (wired at src/internal/ActorCell.ts:164).

The reachable caller is the standard one: ActorCell.unstashAll (415-423) hands this.mailbox.prependUser(drained) the whole stash buffer, which stash() caps at DEFAULT_STASH_CAPACITY = 1024 (65, 102, 406-408). The default mailbox for every actor is a BoundedMailbox (ActorCell.ts:161-165), so the exposure is not limited to opt-in configurations. Net effect: a reject mailbox never throws MailboxFullError, a drop-head/drop-new mailbox never drops or counts, and the queue sits at up to capacity + 1024.

PriorityMailbox demonstrates the intended shape — it overrides prependUser and reinserts through enqueue (src/mailbox/PriorityMailbox.ts:61-65) with an explicit comment about re-computing priority — which makes the omission in BoundedMailbox look like an oversight rather than a decision.

LOW is correct and the finding is honest about why: the overshoot is bounded by the stash cap, so this is not memory exhaustion. The substance is that the ceiling advertised at src/util/Constants.ts:79-83 ("trades the worst-case loss-of-messages for a guaranteed memory ceiling") is not actually an invariant, and the drop metric under-reports — which matters most for a small custom capacity, where capacity: 10 can become 1034.

Correction applied: One structural detail the suggested fix should account for: Mailbox.userQueue is private (src/internal/Mailbox.ts:38), so a BoundedMailbox.prependUser override cannot manipulate the queue directly — it must either route through enqueue/super.prependUser or the base class must grow a protected hook. PriorityMailbox sidesteps this because it maintains its own ordered array. Also worth stating explicitly: the second prependUser caller, the throttle 'pause' re-queue at src/internal/ActorCell.ts:500, passes back a single envelope that was already admitted and counted, so it is genuinely benign — the finding is right to exclude it.

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