]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[CritFix] mime_parser: bound message/rfc822 recursion depth
authorVsevolod Stakhov <vsevolod@rspamd.com>
Tue, 21 Jul 2026 16:01:25 +0000 (17:01 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Tue, 21 Jul 2026 16:01:25 +0000 (17:01 +0100)
A chain of bare "Content-Type: message/rfc822" wrappers recursed through
rspamd_mime_parse_message's own MESSAGE branch without ever tripping the
max_nested (64) limit. When descending into an embedded message the
parser allocates a fresh runtime (nst) and seeds nst->nesting from the
parent's value *before* incrementing, then increments the parent runtime
st which is not the one carried into the recursion. The multipart branch
and rspamd_mime_process_multipart_node bump the descended-through runtime
(and push it onto the stack so the recursive call's cleanup pops and
decrements it), but the message branch did neither, so nst->nesting
stayed at its initial value at every level and the entry guard never
fired.

The result was recursion bounded only by message size (~34 bytes per
level): deeply nested messages exhaust the worker stack (remote,
unauthenticated DoS via HTTP submission or SMTP/milter delivery), and the
per-level boundary pre-scan made even non-crashing depths quadratic in
CPU. The earlier S/MIME fix (f6536945) only covered the pkcs7-mime
re-entry path and did not address this.

Push npart onto nst->stack and bump nst->nesting in the message branch,
mirroring the multipart branch; the recursive call's existing cleanup
balances both. Bounding the depth to max_nested also caps the number of
preprocess passes, eliminating the quadratic-CPU vector.

Add a Lua unit regression test that feeds a 500-level message/rfc822
chain and asserts the parsed part count stays capped near max_nested.

Reported by @gronke.

src/libmime/mime_parser.c
test/lua/unit/task.lua

index 7b2ec9d3ced1195fc9bca91da62c12a0aa29eec3..97a9e3eb858f3b96cdbe70bbac44ea249d4d55d1 100644 (file)
@@ -1908,6 +1908,19 @@ rspamd_mime_parse_message(struct rspamd_task *task,
                ret = rspamd_mime_parse_multipart_part(task, npart, nst, err);
        }
        else if (sel->flags & RSPAMD_CONTENT_TYPE_MESSAGE) {
+               /*
+                * We descend into an embedded message through nst, so the nesting
+                * limit must be accounted against nst (the runtime that is carried
+                * into the recursion), not st. Mirror the multipart branch above and
+                * rspamd_mime_process_multipart_node: push onto the stack and bump
+                * nst->nesting so the recursive call's cleanup (see below) pops and
+                * decrements it symmetrically. Without this a chain of message/rfc822
+                * parts recurses unbounded because max_nested is never reached, which
+                * exhausts the worker stack.
+                */
+               g_ptr_array_add(nst->stack, npart);
+               nst->nesting++;
+
                if ((ret = rspamd_mime_parse_normal_part(task, npart, nst, sel, err)) == RSPAMD_MIME_PARSE_OK) {
                        npart->part_type = RSPAMD_MIME_PART_MESSAGE;
                        ret = rspamd_mime_parse_message(task, npart, nst, err);
index 9a0b98122b2c027d2330f5f2d190ec9f01648653..5fabb130bd77ed798add3a0b1e7b0089843c68f7 100644 (file)
@@ -168,6 +168,27 @@ Thank you,
     task:destroy()
   end)
 
+  test("Process mime nesting: deep message/rfc822 chain is bounded", function()
+    -- Regression for the unbounded message/rfc822 recursion DoS: a long chain
+    -- of bare "Content-Type: message/rfc822" wrappers must not recurse past
+    -- the parser's max_nested limit (64). Before the fix the nesting counter
+    -- was copied to the new parser runtime before being incremented, so the
+    -- limit never fired and the parser recursed once per level (stack
+    -- exhaustion / quadratic CPU). The bound is observable as the number of
+    -- parsed MIME parts: capped near max_nested rather than growing with the
+    -- chain length.
+    local depth = 500
+    local msg = string.rep('Content-Type: message/rfc822\n\n', depth) ..
+      'Subject: poc\n\nInner body http://nested.example.com/\n'
+    local res, task = rspamd_task.load_from_string(msg, rspamd_config)
+    assert_true(res, "failed to load message")
+    task:process_message()
+    local parts = task:get_parts()
+    assert_true(#parts <= 128,
+      string.format("nesting not bounded: %d parts for a %d-level chain", #parts, depth))
+    task:destroy()
+  end)
+
   test("Part URLs are not deduplicated across MIME parts", function()
     local msg = table.concat {
       hdrs,