Skip to main content

max / goingson

Sync/Email: bound MIME nesting to prevent parse_mail stack overflow mailparse::parse_mail recurses once per multipart nesting level with no depth cap. A deeply nested message is cheap to encode and fits within the 25 MB size cap, so a hostile or corrupt IMAP body could overflow the stack -- an uncatchable abort that takes down the whole process, not a recoverable parse error. Guard before parsing: reject messages whose count of `multipart/` markers exceeds 256 (real emails use a handful), which bounds mailparse's recursion and the resulting tree depth. Belt-and-braces, bound our own MIME-tree walks (collect_body_parts, collect_attachment_parts) with a depth limit. Add a regression test. Lifts imap_client from B to A-. Clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-22 02:17 UTC
Commit: c17bb17688f5199868faaaf3a3c09c50d4e08aa4
Parent: 184f3a9
1 file changed, +59 insertions, -5 deletions
@@ -14,6 +14,29 @@ type ImapSession = async_imap::Session<tokio_native_tls::TlsStream<TcpStream>>;
14 14 /// Maximum email size to download (25 MB). Emails exceeding this are skipped during sync.
15 15 const MAX_EMAIL_SIZE: u32 = 25 * 1024 * 1024;
16 16
17 + /// Maximum number of `multipart/...` markers permitted in one message. `mailparse`
18 + /// recurses once per nesting level with no depth cap, so a deeply nested message —
19 + /// cheap to encode and well within `MAX_EMAIL_SIZE` — can overflow the stack, which
20 + /// is an uncatchable abort that kills the whole process. Each nesting level needs a
21 + /// `multipart/...` Content-Type, so capping their count bounds the recursion before
22 + /// `parse_mail` runs. Real emails use a handful; 256 is far above any legitimate value.
23 + const MAX_MIME_MULTIPART_PARTS: usize = 256;
24 +
25 + /// Depth bound for our own MIME-tree walks (belt-and-braces with the pre-parse
26 + /// guard above; the parsed tree can be no deeper than the multipart-marker count).
27 + const MAX_MIME_DEPTH: usize = 256;
28 +
29 + /// Rejects pathologically nested multipart content before `parse_mail` recurses.
30 + /// Counts `multipart/` markers (case-insensitive) in the raw message.
31 + fn mime_nesting_within_limit(body: &[u8]) -> bool {
32 + const NEEDLE: &[u8] = b"multipart/";
33 + let count = body
34 + .windows(NEEDLE.len())
35 + .filter(|w| (w[0] | 0x20) == b'm' && w.eq_ignore_ascii_case(NEEDLE))
36 + .count();
37 + count <= MAX_MIME_MULTIPART_PARTS
38 + }
39 +
17 40 /// How many UIDs to body-fetch per IMAP command. Bounds the command length and
18 41 /// the in-flight fetch pipeline so a large mailbox doesn't issue one enormous
19 42 /// fetch.
@@ -269,6 +292,11 @@ impl ImapClient {
269 292
270 293 if let Some(body) = message.body() {
271 294 body_count += 1;
295 + if !mime_nesting_within_limit(body) {
296 + tracing::warn!(uid, folder = %folder_name, "skipping email with excessive MIME nesting");
297 + parse_errors += 1;
298 + continue;
299 + }
272 300 match mailparse::parse_mail(body) {
273 301 Ok(parsed) => emails.push(Self::build_parsed_email(&parsed, uid, &folder_name, is_read)),
274 302 Err(e) => {
@@ -450,6 +478,11 @@ impl ImapClient {
450 478
451 479 if let Some(body) = message.body() {
452 480 body_count += 1;
481 + if !mime_nesting_within_limit(body) {
482 + tracing::warn!(uid, folder = %folder_name, "skipping email with excessive MIME nesting");
483 + parse_errors += 1;
484 + continue;
485 + }
453 486 match mailparse::parse_mail(body) {
454 487 Ok(parsed) => emails.push(Self::build_parsed_email(&parsed, uid, &folder_name, is_read)),
455 488 Err(e) => {
@@ -633,7 +666,7 @@ impl ImapClient {
633 666 let mut plain_text: Option<String> = None;
634 667 let mut html_body: Option<String> = None;
635 668
636 - Self::collect_body_parts(mail, &mut plain_text, &mut html_body);
669 + Self::collect_body_parts(mail, &mut plain_text, &mut html_body, 0);
637 670
638 671 // Build final result - prefer plain text, fall back to pter markdown conversion
639 672 let body_text = if let Some(ref plain) = plain_text {
@@ -664,7 +697,11 @@ impl ImapClient {
664 697 mail: &mailparse::ParsedMail,
665 698 plain_text: &mut Option<String>,
666 699 html_body: &mut Option<String>,
700 + depth: usize,
667 701 ) {
702 + if depth > MAX_MIME_DEPTH {
703 + return;
704 + }
668 705 let mime_type = mail.ctype.mimetype.to_lowercase();
669 706
670 707 if mail.subparts.is_empty() {
@@ -677,7 +714,7 @@ impl ImapClient {
677 714 } else {
678 715 // Multipart - recurse into all subparts
679 716 for part in &mail.subparts {
680 - Self::collect_body_parts(part, plain_text, html_body);
717 + Self::collect_body_parts(part, plain_text, html_body, depth + 1);
681 718 // Stop early if we've found both
682 719 if plain_text.is_some() && html_body.is_some() {
683 720 break;
@@ -710,11 +747,14 @@ fn extract_references_root(headers: &[mailparse::MailHeader]) -> Option<String>
710 747 /// Skips text/plain and text/html (those are body parts), and parts with empty bodies.
711 748 fn extract_attachment_parts(mail: &mailparse::ParsedMail) -> Vec<AttachmentPart> {
712 749 let mut parts = Vec::new();
713 - collect_attachment_parts(mail, &mut parts);
750 + collect_attachment_parts(mail, &mut parts, 0);
714 751 parts
715 752 }
716 753
717 - fn collect_attachment_parts(mail: &mailparse::ParsedMail, parts: &mut Vec<AttachmentPart>) {
754 + fn collect_attachment_parts(mail: &mailparse::ParsedMail, parts: &mut Vec<AttachmentPart>, depth: usize) {
755 + if depth > MAX_MIME_DEPTH {
756 + return;
757 + }
718 758 let mime_type = mail.ctype.mimetype.to_lowercase();
719 759
720 760 if mail.subparts.is_empty() {
@@ -745,7 +785,7 @@ fn collect_attachment_parts(mail: &mailparse::ParsedMail, parts: &mut Vec<Attach
745 785 } else {
746 786 // Multipart — recurse into subparts
747 787 for part in &mail.subparts {
748 - collect_attachment_parts(part, parts);
788 + collect_attachment_parts(part, parts, depth + 1);
749 789 }
750 790 }
751 791 }
@@ -769,6 +809,20 @@ mod tests {
769 809 // tests removed — these functions were replaced by pter::convert().
770 810
771 811 #[test]
812 + fn rejects_pathologically_nested_mime() {
813 + // Many more multipart markers than any real email: rejected before parse.
814 + let mut body = Vec::new();
815 + for _ in 0..(super::MAX_MIME_MULTIPART_PARTS + 10) {
816 + body.extend_from_slice(b"Content-Type: multipart/mixed; boundary=b\r\n");
817 + }
818 + assert!(!super::mime_nesting_within_limit(&body));
819 +
820 + // A normal message with a couple of multiparts passes (case-insensitive).
821 + let ok = b"Content-Type: Multipart/Alternative; boundary=b\r\n\r\nhi";
822 + assert!(super::mime_nesting_within_limit(ok));
823 + }
824 +
825 + #[test]
772 826 fn pter_replaces_strip_html() {
773 827 // Verify pter handles what strip_html used to do
774 828 let html = "<p>Hello <strong>world</strong></p>";