Cross-account Sieve redirect loop fills the entire disk within minutes (unprivileged DoS, amplified by RocksDB blob GC since v0.16.10)

Issue Description

Two unprivileged local accounts with reciprocal user Sieve redirect rules (A forwards to B, B forwards to A — a typical vacation-forwarding setup created via a webmail rule builder) create an infinite internal mail loop. No existing loop-control mechanism catches it: the Sieve engine’s redirect loop avoidance and MAXREDIRECTS operate per script execution, so each hop is a single legal redirect, and the cycle only exists across the two accounts. RFC 5228 §4.2 requires loop control measures; per-script control exists, cross-account control does not.

The consequence is unbounded database growth: every loop iteration writes the full message blob again (twice, due to “keep”), and since v0.16.10 each iteration additionally re-triggers RocksDB blob garbage collection, whose compactions rewrite ~270 MB blob files continuously — the loop and the GC feed each other. In our incident the store grew until the disk was full. Effectively an unprivileged DoS: two ordinary user accounts can take down the entire server. On our HDD-backed storage the episode ended in RocksDB corruption after an unclean stop and required a restore from backup.

Expected Behavior

The server detects that a message is circulating between local accounts via redirect and terminates the loop after a small number of hops, delivering locally instead of redirecting again — analogous to Exchange’s auto-forward limit. The existing duplicate (RFC 7352) tracking infrastructure could serve this: if the same Message-ID re-enters an account via redirect within a short window, deliver locally.

Actual Behavior

The message circulates indefinitely; the database grows without bound (~400 writes/s sustained, ~70 MB flushes every ~4 s on an otherwise idle server) until the disk is full and the server fails. “keep” additionally deposits a copy in both mailboxes on every iteration. Client-side mitigations (guard headers in generated scripts) cannot be enforced, since any ManageSieve/JMAP client or hand-written script bypasses them.

Reproduction Steps

  1. Create two local accounts A and B, no special permissions.

  2. Upload this user Sieve script to A (and the mirror image to B, redirecting to A):

    if header :contains “From” “@” {
    redirect “[email protected]”;
    keep;
    }

  3. Send one external email (ideally with a large attachment) to either account.

  4. Observe the message looping between A and B; database size and write load grow unbounded until the disk is full.

Relevant Log Output

Loop signature — identical ~70 MB memtable flushes every ~4 s on an otherwise idle server (sequence numbers grew from 144,001,813 to 144,091,184 in ~3.5 min, ~400 writes/s, no client traffic):

2026/07/03-23:58:22.968203 EVENT_LOG_v1 {"job": 40, "event": "flush_started", "num_memtables": 1, "total_num_input_entries": 14, "num_deletes": 0, "total_data_size": 70504793, "flush_reason": "Write Buffer Full"}
2026/07/03-23:59:17.282108 EVENT_LOG_v1 {"job": 59, "event": "flush_started", "num_memtables": 1, "total_num_input_entries": 14, "num_deletes": 0, "total_data_size": 70790381, "flush_reason": "Write Buffer Full"}

14 entries producing a ~70 MB write buffer means these are almost pure message blobs (large mails/attachments), the same handful of messages re-written on every loop iteration.

Each batch of L0 flushes crosses the threshold and triggers a compaction that rewrites ~270 MB blob files — this is the RocksDB blob GC introduced in v0.16.10, so the loop and the GC continuously feed each other:

2026/07/03-23:59:13.237058 [t] Level summary: files[4 0 0 0 0 2 19] max score 1.00, estimated pending compaction bytes 67265454
2026/07/03-23:59:13.237213 EVENT_LOG_v1 {"job": 58, "event": "compaction_started", "cf_name": "t", "compaction_reason": "LevelL0FilesNum", "files_L0": [25590, 25587, 25581, 25574], "score": 1}
2026/07/03-23:59:13.993786 EVENT_LOG_v1 {"job": 58, "event": "blob_file_creation", "file_number": 25592, "total_blob_count": 54, "total_blob_bytes": 271340696, "status": "OK"}
2026/07/03-23:59:14.775375 EVENT_LOG_v1 {"job": 58, "event": "blob_file_creation", "file_number": 25594, "total_blob_count": 54, "total_blob_bytes": 271465528, "status": "OK"}

For scale, blob files are ~270 MB each and were being produced roughly one per ~0.8 s during the sustained loop (file numbers climbed 25318 → 25596+ in minutes), i.e. hundreds of MB/s of blob rewrites driven entirely by the two-message loop.

Stalwart Version

v0.16.x

Installation Method

Binary (Linux)

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

Additional Context

Single node, LXC container on ZFS (HDD pool at the time of the incident — which made the sustained compaction pressure worse, but the unbounded growth itself is storage-independent). The rules were created via a JMAP webmail client’s visual rule builder; the generated catch-all condition (From contains “@”) matches every message, including forwarded ones. The issue is client-independent: any ManageSieve/JMAP client or hand-written script produces the same result.

Happy to provide full RocksDB LOG files, the second account’s script, or test a patched build against the reproduction.

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

Two follow-up points after trying to build an admin-side mitigation.

1. Scope is worse than a two-account loop.

This isn’t limited to a reciprocal A↔B pair. A three-way chain (A→B, B→C, C→A) loops identically, and each rule is individually legitimate. An administrator cannot detect or prevent this: the rules are user-created, not visible or controllable centrally, and any unprivileged user can trigger it accidentally by setting a vacation-forward while a colleague in their chain has done the same (e.g. overlapping vacations). Sender- or target-based guards can’t catch longer chains, since no one redirects directly back to their own predecessor. Only message-level detection (Received-count or duplicate Message-ID) works regardless of chain length. This is an unprivileged DoS against the datastore with no administrative countermeasure.

2. Open question that decides whether any admin-side mitigation is even possible.

I tried to add a loop guard as a system Sieve script at the DATA stage (Received :count and duplicate). But this only works if looping messages actually pass through that stage. So: does an internal redirect (“redirect”, with or without “keep”) re-enter the DATA stage / system Sieve interpreter on each hop, or does it bypass it and go straight to outbound delivery?

If it bypasses DATA, a system script there never sees the looping messages, and neither a Received-count nor a duplicate check placed there can break the loop — which would mean the only possible fix is server-side loop detection at the redirect delivery path itself. This matters especially for “redirect without keep” rules, where the message is forwarded on without ever being stored locally.

Happy to test whatever mitigation you consider correct, but I’d rather not experiment with loop-triggering on production after this incident.

Fixed in v0.16.13 which will be released this week.