From 59e35062e7cf1a250d6d5de77690e0154ee1011f Mon Sep 17 00:00:00 2001 From: Bill Thiede Date: Sun, 10 Aug 2025 19:03:15 -0700 Subject: [PATCH] server: handle application/zip for google dmarc --- Cargo.lock | 204 +++++++++++++++++++++++++++++++++++++++- server/Cargo.toml | 2 + server/src/nm.rs | 233 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 437 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b56f079..7078335 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if 1.0.1", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.7.8" @@ -143,6 +154,15 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33954243bd79057c2de7338850b85983a44588021f8a5fee574a8888c6de4344" +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arc-swap" version = "1.7.1" @@ -580,7 +600,7 @@ checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587" dependencies = [ "arrayref", "arrayvec 0.5.2", - "constant_time_eq", + "constant_time_eq 0.1.5", ] [[package]] @@ -755,6 +775,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bea8dcd42434048e4f7a304411d9273a411f647446c1234a65ce0554923f4cff" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "cacher" version = "0.2.0" @@ -889,6 +918,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.43" @@ -1035,6 +1074,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "core-foundation" version = "0.9.4" @@ -1406,6 +1451,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "deflate64" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" + [[package]] name = "der" version = "0.7.10" @@ -1427,6 +1478,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1697,6 +1759,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -2918,6 +2981,15 @@ dependencies = [ "serde", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array 0.14.7", +] + [[package]] name = "instant" version = "0.1.13" @@ -3101,6 +3173,7 @@ dependencies = [ "mailparse", "maplit", "memmap", + "quick-xml", "regex", "reqwest", "scraper", @@ -3115,6 +3188,7 @@ dependencies = [ "url", "urlencoding", "xtracing", + "zip", ] [[package]] @@ -3180,6 +3254,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + [[package]] name = "libc" version = "0.2.174" @@ -3198,6 +3278,26 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "liblzma" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0791ab7e08ccc8e0ce893f6906eb2703ed8739d8e89b57c0714e71bad09024c8" +dependencies = [ + "liblzma-sys", +] + +[[package]] +name = "liblzma-sys" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "libm" version = "0.2.15" @@ -3224,6 +3324,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-rs-sys" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221" +dependencies = [ + "zlib-rs", +] + [[package]] name = "libz-sys" version = "1.1.22" @@ -4071,6 +4180,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -4256,6 +4375,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppmd-rust" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c834641d8ad1b348c9ee86dec3b9840d805acd5f24daa5f90c788951a52ff59b" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -4387,6 +4512,16 @@ dependencies = [ "prost", ] +[[package]] +name = "quick-xml" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9845d9dccf565065824e69f9f235fafba1587031eda353c1f1561cd6a6be78f4" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.8" @@ -4923,7 +5058,7 @@ checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb" dependencies = [ "base64 0.13.1", "blake2b_simd", - "constant_time_eq", + "constant_time_eq 0.1.5", "crossbeam-utils 0.8.21", ] @@ -5442,6 +5577,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "siphasher" version = "1.0.1" @@ -7637,6 +7778,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] [[package]] name = "zerotrie" @@ -7671,6 +7826,51 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "zip" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aed4ac33e8eb078c89e6cbb1d5c4c7703ec6d299fc3e7c3695af8f8b423468b" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq 0.3.1", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.3.3", + "hmac 0.12.1", + "indexmap 2.10.0", + "liblzma", + "memchr", + "pbkdf2", + "ppmd-rust", + "sha1", + "time 0.3.41", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a" + +[[package]] +name = "zopfli" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/server/Cargo.toml b/server/Cargo.toml index 465ea15..0781af5 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -34,6 +34,7 @@ lol_html = "2.3.0" mailparse = "0.16.1" maplit = "1.0.2" memmap = "0.7.0" +quick-xml = { version = "0.38.1", features = ["serialize"] } regex = "1.11.1" reqwest = { version = "0.12.15", features = ["blocking"] } scraper = "0.23.1" @@ -50,6 +51,7 @@ urlencoding = "2.1.3" #xtracing = { git = "http://git-private.h.xinu.tv/wathiede/xtracing.git" } #xtracing = { path = "../../xtracing" } xtracing = { version = "0.3.2", registry = "xinu" } +zip = "4.3.0" [build-dependencies] build-info-build = "0.0.41" diff --git a/server/src/nm.rs b/server/src/nm.rs index 6bcff8e..de58bd7 100644 --- a/server/src/nm.rs +++ b/server/src/nm.rs @@ -1,14 +1,17 @@ use std::{ collections::{HashMap, HashSet}, fs::File, + io::Cursor, }; use letterbox_notmuch::Notmuch; use letterbox_shared::{compute_color, Rule}; use mailparse::{parse_content_type, parse_mail, MailHeader, MailHeaderMap, ParsedMail}; use memmap::MmapOptions; +use quick_xml::de::from_str as xml_from_str; use sqlx::{types::Json, PgPool}; use tracing::{error, info, info_span, instrument, warn}; +use zip::ZipArchive; use crate::{ compute_offset_limit, @@ -20,6 +23,7 @@ use crate::{ linkify_html, InlineStyle, Query, SanitizeHtml, Transformer, }; +const APPLICATION_ZIP: &'static str = "application/zip"; const IMAGE_JPEG: &'static str = "image/jpeg"; const IMAGE_PJPEG: &'static str = "image/pjpeg"; const IMAGE_PNG: &'static str = "image/png"; @@ -447,6 +451,42 @@ fn extract_body(m: &ParsedMail, part_addr: &mut Vec) -> Result extract_mixed(m, part_addr), MULTIPART_ALTERNATIVE => extract_alternative(m, part_addr), MULTIPART_RELATED => extract_related(m, part_addr), + APPLICATION_ZIP => { + // Try to extract DMARC XML from ZIP + if let Ok(zip_bytes) = m.get_body_raw() { + if let Ok(mut archive) = ZipArchive::new(Cursor::new(&zip_bytes)) { + for i in 0..archive.len() { + if let Ok(mut file) = archive.by_index(i) { + let name = file.name().to_lowercase(); + // Google DMARC reports are typically named like "google.com!example.com!...xml" + // and may or may not contain "dmarc" in the filename. + if name.ends_with(".xml") && (name.contains("dmarc") || name.starts_with("google.com!")) { + let mut xml = String::new(); + use std::io::Read; + if file.read_to_string(&mut xml).is_ok() { + match parse_dmarc_report(&xml) { + Ok(report) => { + return Ok(Body::html(format!( + "
Google DMARC report summary:
{}
", + report + ))); + } + Err(e) => { + return Ok(Body::html(format!( + "
Failed to parse DMARC report XML: {}
", + e + ))); + } + } + } + } + } + } + } + } + // If no DMARC report found, fall through to unhandled + extract_unhandled(m) + } _ => extract_unhandled(m), }; if let Err(err) = ret { @@ -1103,3 +1143,196 @@ fn find_tags<'a, 'b>(rules: &'a [Rule], headers: &'b [MailHeader]) -> (bool, Has } return (matched_rule, add_tags); } + +// Add this helper function to parse the DMARC XML and summarize it. +fn parse_dmarc_report(xml: &str) -> Result { + #[derive(Debug, serde::Deserialize)] + struct Feedback { + report_metadata: Option, + policy_published: Option, + record: Option>, + } + #[derive(Debug, serde::Deserialize)] + struct ReportMetadata { + org_name: Option, + email: Option, + report_id: Option, + date_range: Option, + } + #[derive(Debug, serde::Deserialize)] + struct DateRange { + begin: Option, + end: Option, + } + #[derive(Debug, serde::Deserialize)] + struct PolicyPublished { + domain: Option, + adkim: Option, + aspf: Option, + p: Option, + sp: Option, + pct: Option, + } + #[derive(Debug, serde::Deserialize)] + struct Record { + row: Option, + identifiers: Option, + auth_results: Option, + } + #[derive(Debug, serde::Deserialize)] + struct Row { + source_ip: Option, + count: Option, + policy_evaluated: Option, + } + #[derive(Debug, serde::Deserialize)] + struct PolicyEvaluated { + disposition: Option, + dkim: Option, + spf: Option, + reason: Option>, + } + #[derive(Debug, serde::Deserialize)] + struct Reason { + #[serde(rename = "type")] + reason_type: Option, + comment: Option, + } + #[derive(Debug, serde::Deserialize)] + struct Identifiers { + header_from: Option, + } + #[derive(Debug, serde::Deserialize)] + struct AuthResults { + dkim: Option>, + spf: Option>, + } + #[derive(Debug, serde::Deserialize)] + struct AuthDKIM { + domain: Option, + result: Option, + selector: Option, + } + #[derive(Debug, serde::Deserialize)] + struct AuthSPF { + domain: Option, + result: Option, + scope: Option, + } + + let feedback: Feedback = xml_from_str(xml) + .map_err(|e| ServerError::StringError(format!("DMARC XML parse error: {e}")))?; + let mut summary = String::new(); + if let Some(meta) = feedback.report_metadata { + if let Some(org) = meta.org_name { + summary += &format!("Reporter: {}
", org); + } + if let Some(email) = meta.email { + summary += &format!("Contact: {}
", email); + } + if let Some(rid) = meta.report_id { + summary += &format!("Report ID: {}
", rid); + } + if let Some(dr) = meta.date_range { + if let (Some(begin), Some(end)) = (dr.begin, dr.end) { + use chrono::{NaiveDateTime, TimeZone, Utc}; + let begin_dt = Utc.timestamp_opt(begin as i64, 0).single(); + let end_dt = Utc.timestamp_opt(end as i64, 0).single(); + summary += &format!("Date range: {} to {}
", + begin_dt.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or(begin.to_string()), + end_dt.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or(end.to_string()) + ); + } + } + } + if let Some(pol) = feedback.policy_published { + summary += "Policy Published:
    "; + if let Some(domain) = pol.domain { + summary += &format!("
  • Domain: {}
  • ", domain); + } + if let Some(adkim) = pol.adkim { + summary += &format!("
  • ADKIM: {}
  • ", adkim); + } + if let Some(aspf) = pol.aspf { + summary += &format!("
  • ASPF: {}
  • ", aspf); + } + if let Some(p) = pol.p { + summary += &format!("
  • Policy: {}
  • ", p); + } + if let Some(sp) = pol.sp { + summary += &format!("
  • Subdomain Policy: {}
  • ", sp); + } + if let Some(pct) = pol.pct { + summary += &format!("
  • Percent: {}
  • ", pct); + } + summary += "
"; + } + if let Some(records) = feedback.record { + summary += "Records:"; + for rec in records { + let mut row_html = String::new(); + let mut source_ip = String::new(); + let mut count = String::new(); + let mut header_from = String::new(); + let mut disposition = String::new(); + let mut dkim = String::new(); + let mut spf = String::new(); + if let Some(r) = &rec.row { + if let Some(ref s) = r.source_ip { + source_ip = s.clone(); + } + if let Some(c) = r.count { + count = c.to_string(); + } + if let Some(ref pe) = r.policy_evaluated { + if let Some(ref disp) = pe.disposition { + disposition = disp.clone(); + } + if let Some(ref d) = pe.dkim { + dkim = d.clone(); + } + if let Some(ref s) = pe.spf { + spf = s.clone(); + } + } + } + if let Some(ids) = &rec.identifiers { + if let Some(ref hf) = ids.header_from { + header_from = hf.clone(); + } + } + row_html += &format!(""; + summary += &row_html; + } + summary += "
Source IPCountHeader FromDispositionDKIMSPFAuth Results
{}{}{}{}{}{}", + source_ip, count, header_from, disposition, dkim, spf); + // Auth Results + let mut auths = String::new(); + if let Some(auth) = &rec.auth_results { + if let Some(dkims) = &auth.dkim { + for dkimres in dkims { + auths += &format!("DKIM: domain={} selector={} result={}
", + dkimres.domain.as_deref().unwrap_or(""), + dkimres.selector.as_deref().unwrap_or(""), + dkimres.result.as_deref().unwrap_or("") + ); + } + } + if let Some(spfs) = &auth.spf { + for spfres in spfs { + auths += &format!("SPF: domain={} scope={} result={}
", + spfres.domain.as_deref().unwrap_or(""), + spfres.scope.as_deref().unwrap_or(""), + spfres.result.as_deref().unwrap_or("") + ); + } + } + } + row_html += &auths; + row_html += "
"; + } + if summary.is_empty() { + summary = "No DMARC summary found.".to_string(); + } + Ok(summary) +}