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.
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);
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,