From d563a2f2b1edc865674021bd977d9dcf69abf0b3 Mon Sep 17 00:00:00 2001 From: gaoflow Date: Mon, 27 Jul 2026 09:28:03 +0200 Subject: [PATCH 1/2] Unescape quoted-pairs inside quoted-strings Parameter values in Content-Type/Content-Disposition had their surrounding quotes stripped but the \" and \\ pairs inside them left as-is, so filename="a\"b.txt" came back as a\"b.txt. Two related spots: Display for SingleInfo/GroupInfo escaped " in a display name but not \, so a name holding a backslash round-tripped through addrparse with the backslash dropped; and the comment state ended on an escaped \), leaking the rest of the comment into the parsed address. Display names use the full RFC 5322 quoted-pair, matching what the quoted-name reader already does. Parameter values only undo \" and \\, which keeps the unescaped Windows paths that show up in real filename= parameters intact, the same line Python's email and Go's mime draw. --- src/addrparse.rs | 67 ++++++++++++++++++++++++++++++++++++++++++-- src/lib.rs | 72 ++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 131 insertions(+), 8 deletions(-) diff --git a/src/addrparse.rs b/src/addrparse.rs index 23120e9..0d31c1a 100644 --- a/src/addrparse.rs +++ b/src/addrparse.rs @@ -26,10 +26,16 @@ impl SingleInfo { } } +/// Escape a display name for use inside a quoted-string. Backslash must go +/// first, or the backslashes added for the quotes get escaped in turn. +fn escape_quoted(name: &str) -> String { + name.replace('\\', r"\\").replace('"', r#"\""#) +} + impl fmt::Display for SingleInfo { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if let Some(name) = &self.display_name { - write!(f, r#""{}" <{}>"#, name.replace('"', r#"\""#), self.addr) + write!(f, r#""{}" <{}>"#, escape_quoted(name), self.addr) } else { write!(f, "{}", self.addr) } @@ -55,7 +61,7 @@ impl GroupInfo { impl fmt::Display for GroupInfo { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, r#""{}":"#, self.group_name.replace('"', r#"\""#))?; + write!(f, r#""{}":"#, escape_quoted(&self.group_name))?; for (i, addr) in self.addrs.iter().enumerate() { if i == 0 { write!(f, " ")?; @@ -91,6 +97,7 @@ enum AddrParseState { Unquoted, NameWithEncodedWord, Comment, + CommentEscapedChar, } /// A simple wrapper around `Vec`. This is primarily here so we can @@ -620,10 +627,16 @@ fn addrparse_inner( } } } + AddrParseState::CommentEscapedChar => { + // The escaped item is discarded along with the rest of the comment. + state = AddrParseState::Comment; + } AddrParseState::Comment => { match hti { HeaderTokenItem::Char(c) => { - if c == ')' { + if c == '\\' { + state = AddrParseState::CommentEscapedChar; + } else if c == ')' { state = comment_return.take().unwrap(); } } @@ -656,6 +669,7 @@ fn addrparse_inner( | AddrParseState::AfterQuotedName | AddrParseState::BracketedAddr | AddrParseState::Comment + | AddrParseState::CommentEscapedChar | AddrParseState::NameWithEncodedWord => Err(MailParseError::Generic( "Address string unexpectedly terminated", )), @@ -918,6 +932,21 @@ mod tests { MailAddrList(vec![MailAddr::Single(tc)]) ); + let tc = + SingleInfo::new(Some(r"John \ Doe".to_string()), "john@doe.com".to_string()).unwrap(); + assert_eq!(tc.to_string(), r#""John \\ Doe" "#); + assert_eq!( + addrparse(&tc.to_string()).unwrap(), + MailAddrList(vec![MailAddr::Single(tc)]) + ); + + let tc = SingleInfo::new(Some(r#"a\b"c"#.to_string()), "john@doe.com".to_string()).unwrap(); + assert_eq!(tc.to_string(), r#""a\\b\"c" "#); + assert_eq!( + addrparse(&tc.to_string()).unwrap(), + MailAddrList(vec![MailAddr::Single(tc)]) + ); + let tc = SingleInfo::new(None, "foo@bar.com".to_string()).unwrap(); assert_eq!(tc.to_string(), r#"foo@bar.com"#); assert_eq!( @@ -954,6 +983,38 @@ mod tests { addrparse(&tc.to_string()).unwrap(), MailAddrList(vec![MailAddr::Group(tc)]) ); + + let tc = GroupInfo::new(r"group-with\backslash".to_string(), vec![]); + assert_eq!(tc.to_string(), r#""group-with\\backslash":;"#); + assert_eq!( + addrparse(&tc.to_string()).unwrap(), + MailAddrList(vec![MailAddr::Group(tc)]) + ); + } + + #[test] + fn parse_escaped_comment() { + // A quoted-pair inside a comment does not end it (RFC 5322 3.2.2). + for header in [ + r"x@y.com (a\)b)", + r"x@y.com (a\(b)", + r"x@y.com (a\\b)", + r"x@y.com (plain)", + r"x@y.com (a\)b) (c\)d)", + ] { + assert_eq!( + addrparse(header).unwrap(), + MailAddrList(vec![MailAddr::Single( + SingleInfo::new(None, "x@y.com".to_string()).unwrap() + )]), + "header: {}", + header + ); + } + + // An unterminated comment is still an error, not a silent truncation. + assert!(addrparse(r"x@y.com (a\)b").is_err()); + assert!(addrparse(r"x@y.com (ab\)").is_err()); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 6a1653c..43eb347 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1062,6 +1062,27 @@ fn split_semicolons_unquoted(content: &str) -> Vec<&str> { tokens } +/// Strip the quotes from a quoted parameter value and undo the `\"` and `\\` +/// quoted-pairs inside it (RFC 2045 Section 5.1). Any other `\x` is kept +/// verbatim, since unescaped Windows paths are common in real `filename` +/// parameters; Python's `email` and Go's `mime` draw the line in the same place. +fn unquote_param_value(value: &str) -> String { + if value.len() < 2 || !value.starts_with('"') || !value.ends_with('"') { + return value.to_string(); + } + let inner = &value[1..value.len() - 1]; + let mut out = String::with_capacity(inner.len()); + let mut chars = inner.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\\' && matches!(chars.peek(), Some('"') | Some('\\')) { + out.push(chars.next().unwrap()); + } else { + out.push(c); + } + } + out +} + /// Split a continuation segment key such as `name*3` into its base name and /// index. Returns `None` for keys without a trailing `*`. fn split_continuation_index(key: &str) -> Option<(&str, usize)> { @@ -1086,11 +1107,7 @@ fn parse_param_content(content: &str) -> ParamContent { .filter_map(|kv| { kv.find('=').map(|idx| { let key = kv[0..idx].trim().to_lowercase(); - let mut value = kv[idx + 1..].trim(); - if value.starts_with('"') && value.ends_with('"') && value.len() > 1 { - value = &value[1..value.len() - 1]; - } - (key, value.to_string()) + (key, unquote_param_value(kv[idx + 1..].trim())) }) }) .collect(); @@ -1817,6 +1834,51 @@ mod tests { assert_eq!(parsed.params["name"], "\""); } + #[test] + fn test_parameter_quoted_pairs() { + // (parameter list, expected value of the `filename` parameter) + let cases = [ + // `\"` and `\\` are quoted-pairs and lose their backslash. + (r#"attachment; filename="a\"b.txt""#, r#"a"b.txt"#), + (r#"attachment; filename="a\\b.txt""#, r"a\b.txt"), + (r#"attachment; filename="a\\b\"c""#, r#"a\b"c"#), + (r#"attachment; filename="a\\""#, r"a\"), + (r#"attachment; filename="\\\\""#, r"\\"), + // A backslash before anything else is data, not an escape, so + // Windows paths written by older mailers survive intact. + ( + r#"attachment; filename="C:\dev\go\foo.txt""#, + r"C:\dev\go\foo.txt", + ), + (r#"attachment; filename="a\;b.txt""#, r"a\;b.txt"), + // Outside a quoted-string a backslash is never an escape. + (r"attachment; filename=a\b.txt", r"a\b.txt"), + (r#"attachment; filename=a\"b.txt"#, r#"a\"b.txt"#), + // Unrelated values are untouched. + (r#"attachment; filename="plain.txt""#, "plain.txt"), + ("attachment; filename=plain.txt", "plain.txt"), + ]; + for (input, expected) in cases { + assert_eq!( + parse_param_content(input).params["filename"], + expected, + "input: {}", + input + ); + } + + // Same values reached through the two public accessors. + let cd = parse_content_disposition(r#"attachment; filename="a\"b.txt""#); + assert_eq!(cd.params["filename"], r#"a"b.txt"#); + let ct = parse_content_type(r#"text/plain; name="a\\b"; boundary="x\"y""#); + assert_eq!(ct.params["name"], r"a\b"); + assert_eq!(ct.params["boundary"], r#"x"y"#); + + // A quoted-pair also survives the RFC 2231 continuation join. + let parsed = parse_param_content(r#"attachment; filename*0="a\\"; filename*1="b.txt""#); + assert_eq!(parsed.params["filename"], r"a\b.txt"); + } + #[test] fn test_parameter_value_continuations() { let parsed = From 208f596c872b866701d3352da697b21723f00320 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Mon, 3 Aug 2026 14:02:30 +0200 Subject: [PATCH 2/2] fix: don't treat an escaped closing quote as terminating a quoted param value --- src/lib.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 43eb347..7fe410f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1070,6 +1070,21 @@ fn unquote_param_value(value: &str) -> String { if value.len() < 2 || !value.starts_with('"') || !value.ends_with('"') { return value.to_string(); } + // A trailing `"` preceded by an odd run of backslashes is itself escaped + // (the second half of a `\"` quoted-pair), not the closing delimiter -- + // the value is an unterminated quoted-string (e.g. header content + // truncated right after the escape). Leave it untouched rather than + // silently treating the escaped quote as if it closed the string, which + // would drop the final backslash and produce a value the input never + // actually specified. + let trailing_backslashes = value[..value.len() - 1] + .chars() + .rev() + .take_while(|&c| c == '\\') + .count(); + if trailing_backslashes % 2 == 1 { + return value.to_string(); + } let inner = &value[1..value.len() - 1]; let mut out = String::with_capacity(inner.len()); let mut chars = inner.chars().peekable(); @@ -1867,6 +1882,16 @@ mod tests { ); } + // Header content truncated right after an escaped quote: the + // trailing `"` is the second half of the `\"` quoted-pair, not a + // real closing delimiter, so the value is an unterminated + // quoted-string. Left untouched (quotes and backslash intact) + // rather than treating the escape's own quote as if it closed the + // string, which would silently invent a value the input never + // specified. + let parsed = parse_param_content("attachment; filename=\"foo\\\""); + assert_eq!(parsed.params["filename"], "\"foo\\\""); + // Same values reached through the two public accessors. let cd = parse_content_disposition(r#"attachment; filename="a\"b.txt""#); assert_eq!(cd.params["filename"], r#"a"b.txt"#);