From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id DE6CD1FF0E4 for ; Tue, 14 Jul 2026 10:47:44 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 31CAA21347; Tue, 14 Jul 2026 10:47:44 +0200 (CEST) From: Shannon Sterz To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox] fix #7814: send-mail: normalize LF line endings to CRLF endings Date: Tue, 14 Jul 2026 10:47:12 +0200 Message-ID: <20260714084719.68076-1-s.sterz@proxmox.com> X-Mailer: git-send-email 2.47.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1784018840981 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.318 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_LOW -0.7 Sender listed at https://www.dnswl.org/, low trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: LBVYEN6WJEK576356K5OBLQAOUPLIUW3 X-Message-ID-Hash: LBVYEN6WJEK576356K5OBLQAOUPLIUW3 X-MailFrom: s.sterz@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: RFC5321 Section 2.3.8 requires that mails do not contain bare CR or LF bytes. this patch normalizes unix style LF line endings to CRLF line endings. it also removes bare CR bytes. when text would be base64 encoded anyway, CR bytes will be dropped, to keep the behavior for encoded and non-encoded text aligned. however, since this code was already extensively tested against multiple MUAs, encoded LF line endings are kept as-is. also adds tests to encode this behavior. Signed-off-by: Shannon Sterz --- proxmox-sendmail/src/lib.rs | 246 ++++++++++++++++++++++++++++++++---- 1 file changed, 220 insertions(+), 26 deletions(-) diff --git a/proxmox-sendmail/src/lib.rs b/proxmox-sendmail/src/lib.rs index b3ff56e5..a73e8eb7 100644 --- a/proxmox-sendmail/src/lib.rs +++ b/proxmox-sendmail/src/lib.rs @@ -50,7 +50,10 @@ fn encode_base64_formatted>(raw: T) -> String { } fn encode_filename_formatted(filename: &str) -> String { - let encoded = format!("UTF-8''{}", utf8_percent_encode(filename, RFC5987SET)); + let encoded = format!( + "UTF-8''{}", + utf8_percent_encode(&filename.replace('\r', ""), RFC5987SET) + ); let format_prefix = |l| { if let Some(l) = l { @@ -63,6 +66,18 @@ fn encode_filename_formatted(filename: &str) -> String { format_encoded_text(&encoded, format_prefix, ";", 0, true) } +/// Strip bare CR bytes, than base64 encode. This is useful to make sure we treat encoded (header) +/// values the same as non-encoded one. Stripping CR bytes is necessary according to RFC 5321 Section +/// Section 2.3.8 (https://datatracker.ietf.org/doc/html/rfc5321#section-2.3.8). +fn strip_cr_base64>(raw: T) -> String { + let normalized: Vec = raw + .as_ref() + .iter() + .filter_map(|b| (*b != b'\r').then_some(*b)) + .collect(); + proxmox_base64::encode(&normalized) +} + /// Helper function used to format text to not exceed line limits imposed by standards such as RFC /// 2045 Section 6.8 that specifies that Base64 content transfer encoded text "must be represented /// in lines of no more than 76 characters each". It also allows taking into account any necessary @@ -166,11 +181,7 @@ impl Recipient { fn format_recipient(&self) -> String { if let Some(name) = &self.name { if !name.is_ascii() { - format!( - "=?utf-8?B?{}?= <{}>", - proxmox_base64::encode(name), - self.email - ) + format!("=?utf-8?B?{}?= <{}>", strip_cr_base64(name), self.email) } else { format!("{name} <{}>", self.email) } @@ -199,7 +210,7 @@ impl Attachment<'_> { if self.filename.is_ascii() { let _ = write!(attachment, "{}", &self.filename); } else { - let encoded = proxmox_base64::encode(&self.filename); + let encoded = strip_cr_base64(&self.filename); let prefix_fn = |line| { if Some(0) == line { "=?utf-8?B?" @@ -451,7 +462,10 @@ impl<'a> Mail<'a> { /// Forwards an email message to a given list of recipients. /// - /// `message` must be compatible with ``sendmail`` (the message is piped into stdin unmodified). + /// `message` must be compatible with ``sendmail``. The message is piped into `stdin` + /// unmodified. One exception is stripping bare CR bytes and normalizing LF line endings to + /// CRLF line endings in accordance with RFC 5321 Section 2.3.8 + /// (https://datatracker.ietf.org/doc/html/rfc5321#section-2.3.8). #[cfg(feature = "mail-forwarder")] pub fn forward( mailto: &[&str], @@ -485,11 +499,27 @@ impl<'a> Mail<'a> { .spawn() .with_context(|| "could not spawn sendmail process")?; + let capacity = message.iter().fold(0, |acc, e| match *e { + b'\r' => acc, + b'\n' => acc + 2, + _ => acc + 1, + }); + + let mut normalized_message: Vec = Vec::with_capacity(capacity); + + for byte in message { + match byte { + b'\r' => continue, + b'\n' => normalized_message.extend_from_slice(&[b'\r', b'\n']), + b => normalized_message.push(*b), + } + } + sendmail_process .stdin .take() .unwrap() - .write_all(message) + .write_all(&normalized_message) .with_context(|| "couldn't write to sendmail stdin")?; sendmail_process @@ -520,7 +550,9 @@ impl<'a> Mail<'a> { write!(mail, "\n--{file_boundary}--")?; } - Ok(mail) + // normalize line endings from `\n` to `\r\n` and drop bare `\r` according to RFC 5321 + // 2.3.8: https://datatracker.ietf.org/doc/html/rfc5321#section-2.3.8 + Ok(mail.replace('\r', "").replace('\n', "\r\n")) } fn format_header( @@ -561,7 +593,7 @@ impl<'a> Mail<'a> { writeln!( header, "Subject: =?utf-8?B?{}?=", - proxmox_base64::encode(&self.subject) + strip_cr_base64(&self.subject) )?; } else { writeln!(header, "Subject: {}", self.subject)?; @@ -571,7 +603,7 @@ impl<'a> Mail<'a> { writeln!( header, "From: =?utf-8?B?{}?= <{}>", - proxmox_base64::encode(&self.mail_author), + strip_cr_base64(&self.mail_author), self.mail_from )?; } else { @@ -606,17 +638,15 @@ impl<'a> Mail<'a> { header.push_str("Auto-Submitted: auto-generated;\n"); for (key, value) in &self.headers { - // Strip CR/LF so an interpolated key or value cannot inject extra header lines. - let key = key.replace(['\r', '\n'], ""); - let value = value.replace(['\r', '\n'], ""); + // Strip LF so an interpolated key or value cannot inject extra header lines. Bare CR + // will be stripped for non-ASCII text before encoding and for ASCII text globally + // before finishing formatting. + let key = key.replace('\n', ""); + let value = value.replace('\n', ""); if value.is_ascii() { writeln!(header, "{key}: {value}")?; } else { - writeln!( - header, - "{key}: =?utf-8?B?{}?=", - proxmox_base64::encode(&value) - )?; + writeln!(header, "{key}: =?utf-8?B?{}?=", strip_cr_base64(&value))?; } } @@ -653,7 +683,7 @@ impl<'a> Mail<'a> { body.push_str(&self.body_txt); } else { body.push_str("Content-Transfer-Encoding: base64\n\n"); - body.push_str(&encode_base64_formatted(&self.body_txt)); + body.push_str(&encode_base64_formatted(self.body_txt.replace('\r', ""))); } if let Some(html) = &self.body_html { @@ -666,7 +696,7 @@ impl<'a> Mail<'a> { body.push_str(html); } else { body.push_str("Content-Transfer-Encoding: base64\n\n"); - body.push_str(&encode_base64_formatted(html)); + body.push_str(&encode_base64_formatted(html.replace('\r', ""))); } write!(body, "\n--{html_boundary}--")?; @@ -1140,10 +1170,10 @@ Content-Transfer-Encoding: 7bit ]; let mail = Mail::new( - "Sender Näme", - "from@example.com", - "Subject Linë", - "Lorem Ipsum Dolor Sit\nAmët", + "Sender Nä\rme", + "from@example.\rcom", + "Subject Linë\r", + "Lorem Ipsum Dolor \rSit\nAmët", ) .with_recipient_and_name("Receiver Näme", "receiver@example.com") .with_attachment("deadbeef.bin", "application/octet-stream", &bin) @@ -1344,4 +1374,168 @@ ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz assert_eq!(suffixed, out); } + + #[test] + fn line_endings_are_normalized_when_not_encoded() { + let bin: [u8; 63] = [ + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, + 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0x0d, + ]; + + let mail = Mail::new( + "Se\rnder Name", + "fro\rm@example.com", + "Sub\rject Line", + "Lor\rem Ipsum Dolor Sit\nAmet", + ) + .with_recipient_and_name("R\receiver Name", "receive\rr@example.com") + .with_attachment("deadb\reef.bin", "application/octet-stream", &bin) + .with_html_alt("\n\t
\n\t\tLorem Ipsum Dolor Si\rt Amet\n\t
\n") + .with_header("X-CR-HEADE\r", "value\r"); + + let body = mail.format_mail(1732806251).expect("could not format mail"); + + assert!(body.is_ascii()); + + let expected = r#"Content-Type: multipart/mixed; + boundary="----_=_NextPart_001_1732806251" +MIME-Version: 1.0 +Subject: Subject Line +From: Sender Name +To: Receiver Name +Date: Thu, 28 Nov 2024 16:04:11 +0100 +Auto-Submitted: auto-generated; +X-CR-HEADE: value + +This is a multi-part message in MIME format. + +------_=_NextPart_001_1732806251 +Content-Type: multipart/alternative; boundary="----_=_NextPart_002_1732806251" +MIME-Version: 1.0 + +------_=_NextPart_002_1732806251 +Content-Type: text/plain; + charset="UTF-8" +Content-Transfer-Encoding: 7bit + +Lorem Ipsum Dolor Sit +Amet +------_=_NextPart_002_1732806251 +Content-Type: text/html; + charset="UTF-8" +Content-Transfer-Encoding: 7bit + + +
+		Lorem Ipsum Dolor Sit Amet
+	
+ +------_=_NextPart_002_1732806251-- +------_=_NextPart_001_1732806251 +Content-Type: application/octet-stream; + name="deadbeef.bin" +Content-Disposition: attachment; + filename*0*=UTF-8''deadbeef.bin +Content-Transfer-Encoding: base64 + +3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v +3q2+796tvu8N +------_=_NextPart_001_1732806251--"#; + + // manually compare here to ensure we split specifically only on `\r\n` line endings + + let lines1 = body.split("\r\n"); + let lines2 = expected.split("\n"); + + for ((i, line1), line2) in lines1.enumerate().zip(lines2) { + if !(line1.starts_with("Date:") && line2.starts_with("Date:")) { + assert_eq!(line1, line2, "lines at {i} do not match"); + } + } + + assert_eq!(body.lines().count(), expected.lines().count()); + } + + #[test] + fn line_endings_are_normalized_when_encoded() { + let bin: [u8; 63] = [ + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, + 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0x0d, + ]; + + let mail = Mail::new( + "Se\rnder Näme", + "fro\rm@example.com", + "Sub\rject Linë", + "Lor\rem Ipsum Dolor Sit\nAmët", + ) + .with_recipient_and_name("R\receiver Näme", "receive\rr@example.com") + .with_attachment("🐄💀.b\rin", "ima\rge/bmp", &bin) + .with_html_alt("\n\t
\n\t\tLorem Ipsum Dölor Si\rt Amet\n\t
\n") + .with_header("X-CR-HEADE\r", "valüe\r"); + + let body = mail.format_mail(1732806251).expect("could not format mail"); + + assert!(body.is_ascii()); + + let expected = r#"Content-Type: multipart/mixed; + boundary="----_=_NextPart_001_1732806251" +MIME-Version: 1.0 +Subject: =?utf-8?B?U3ViamVjdCBMaW7Dqw==?= +From: =?utf-8?B?U2VuZGVyIE7DpG1l?= +To: =?utf-8?B?UmVjZWl2ZXIgTsOkbWU=?= +Date: Thu, 28 Nov 2024 16:04:11 +0100 +Auto-Submitted: auto-generated; +X-CR-HEADE: =?utf-8?B?dmFsw7xl?= + +This is a multi-part message in MIME format. + +------_=_NextPart_001_1732806251 +Content-Type: multipart/alternative; boundary="----_=_NextPart_002_1732806251" +MIME-Version: 1.0 + +------_=_NextPart_002_1732806251 +Content-Type: text/plain; + charset="UTF-8" +Content-Transfer-Encoding: base64 + +TG9yZW0gSXBzdW0gRG9sb3IgU2l0CkFtw6t0 +------_=_NextPart_002_1732806251 +Content-Type: text/html; + charset="UTF-8" +Content-Transfer-Encoding: base64 + +PGh0bWwgbGFuZz0iZGUtYXQiPjxoZWFkPjwvaGVhZD48Ym9keT4KCTxwcmU+CgkJTG9yZW0g +SXBzdW0gRMO2bG9yIFNpdCBBbWV0Cgk8L3ByZT4KPC9ib2R5PjwvaHRtbD4= +------_=_NextPart_002_1732806251-- +------_=_NextPart_001_1732806251 +Content-Type: image/bmp; + name="=?utf-8?B?8J+QhPCfkoAuYmlu?=" +Content-Disposition: attachment; + filename*0*=UTF-8''%F0%9F%90%84%F0%9F%92%80.bin +Content-Transfer-Encoding: base64 + +3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v +3q2+796tvu8N +------_=_NextPart_001_1732806251--"#; + + // manually compare here to ensure we split specifically only on `\r\n` line endings + + let lines1 = body.split("\r\n"); + let lines2 = expected.split("\n"); + + for ((i, line1), line2) in lines1.enumerate().zip(lines2) { + if !(line1.starts_with("Date:") && line2.starts_with("Date:")) { + assert_eq!(line1, line2, "lines at {i} do not match"); + } + } + + assert_eq!(body.lines().count(), expected.lines().count()); + } } -- 2.47.3