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).
- 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).
- Meanwhile the mailbox fills to capacity with new traffic.
- 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.
Component:
src/internal/Mailbox.tsSeverity (assessment): LOW
BoundedMailboxoverrides onlyenqueue; the baseprependUserperformsthis.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).ActorCell.stash()correctly enforces its own cap (DEFAULT_STASH_CAPACITY = 1024, throwingStashOverflowError, src/internal/ActorCell.ts:406-408).unstashAll(), up to 1024 envelopes are unshifted onto an already-full queue with no policy consultation: arejectmailbox never throws, adrop-head/drop-newmailbox never drops or counts, anddroppedCount/actor_mailbox_dropped_totalunder-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
mailboxCapacityagainst 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.tssrc/internal/Mailbox.ts:48-51 —
src/mailbox/BoundedMailbox.tsoverridesenqueue(line 40) and nothing else — there is noprependUseroverride 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):Why the existing guard does not cover it
ActorCell.stash()enforces_stashCapacity = 1024and throwsStashOverflowError, 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 bothfinalizeTerminationandonRecreate), so nothing vanishes.PriorityMailboxDOES overrideprependUserto re-enter throughenqueue(src/mailbox/PriorityMailbox.ts:61-65), demonstrating the correct shape —BoundedMailboxsimply did not follow it.Suggested fix
Either make
Mailbox.prependUsera template that routes each envelope through an overridable single-envelope hook, or add aprependUseroverride toBoundedMailboxthat 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 replaceunshift(...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 barethis.userQueue.unshift(...envs), and src/mailbox/BoundedMailbox.ts overrides onlyenqueue(40-65) — I read the whole file; there is noprependUseroverride. So every envelope re-entering via prepend skips thesize >= capacitycheck, thematch(this.overflow)policy dispatch, thedroppedCountincrement and theonDropcallback that feedsactor_mailbox_dropped_total(wired at src/internal/ActorCell.ts:164).The reachable caller is the standard one:
ActorCell.unstashAll(415-423) handsthis.mailbox.prependUser(drained)the whole stash buffer, whichstash()caps atDEFAULT_STASH_CAPACITY = 1024(65, 102, 406-408). The default mailbox for every actor is aBoundedMailbox(ActorCell.ts:161-165), so the exposure is not limited to opt-in configurations. Net effect: arejectmailbox never throwsMailboxFullError, adrop-head/drop-newmailbox never drops or counts, and the queue sits at up tocapacity + 1024.PriorityMailboxdemonstrates the intended shape — it overridesprependUserand reinserts throughenqueue(src/mailbox/PriorityMailbox.ts:61-65) with an explicit comment about re-computing priority — which makes the omission inBoundedMailboxlook 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: 10can become 1034.Correction applied: One structural detail the suggested fix should account for:
Mailbox.userQueueisprivate(src/internal/Mailbox.ts:38), so aBoundedMailbox.prependUseroverride cannot manipulate the queue directly — it must either route throughenqueue/super.prependUseror the base class must grow a protected hook.PriorityMailboxsidesteps this because it maintains its ownorderedarray. Also worth stating explicitly: the secondprependUsercaller, 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.