max / goingson
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>"; |