From afa05622c2840a26add3c511995bd291d662431e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:51:27 +0000 Subject: [PATCH 01/47] Initial plan From 5839a322563fd889e70d875fd6fad109534be733 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:56:11 +0000 Subject: [PATCH 02/47] Tighten DecodeEncoding to prevent header parameter injection Co-authored-by: mrek-msft <188900745+mrek-msft@users.noreply.github.com> --- .../src/System/Net/Mime/MimeBasePart.cs | 145 ++++++++++++++++-- .../Functional/ContentDispositionTest.cs | 33 ++++ .../tests/Functional/ContentTypeTest.cs | 49 ++++++ 3 files changed, 218 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs index ba15dd391ef78b..7dee2e21ddb1f0 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs @@ -86,27 +86,154 @@ internal static string DecodeHeaderValue(string? value) return newValue; } - // Detect the encoding: "=?encoding?BorQ?content?=" + // Detect the encoding: "=?charset?BorQ?content?=" // "=?utf-8?B?RmlsZU5hbWVf55CG0Y3Qq9C60I5jw4TRicKq0YIM0Y1hSsSeTNCy0Klh?="; // 3.5 // With the addition of folding in 4.0, there may be multiple lines with encoding, only detect the first: // "=?utf-8?B?RmlsZU5hbWVf55CG0Y3Qq9C60I5jw4TRicKq0YIM0Y1hSsSeTNCy0Klh?=\r\n =?utf-8?B??="; - internal static unsafe Encoding? DecodeEncoding(string? value) + // + // The entire value must consist of one or more well-formed RFC 2047 encoded-words + // separated by linear whitespace (folding); otherwise null is returned so callers + // do not treat attacker-controlled input as pre-encoded and pass it through unquoted. + internal static Encoding? DecodeEncoding(string? value) { if (string.IsNullOrEmpty(value)) { return null; } - ReadOnlySpan valueSpan = value; - Span subStrings = stackalloc Range[6]; - if (valueSpan.SplitAny(subStrings, "?\r\n") < 5 || - valueSpan[subStrings[0]] is not "=" || - valueSpan[subStrings[4]] is not "=") + ReadOnlySpan remainder = value; + ReadOnlySpan firstCharSet = default; + + while (true) { - return null; + // An encoded-word has the form "=?charset?encoding?text?=". + // Minimum possible length is "=?x?Q??=" (8 chars). + if (remainder.Length < 8 || remainder[0] != '=' || remainder[1] != '?') + { + return null; + } + + // charset = characters up to the next '?'. + int charSetLength = remainder.Slice(2).IndexOf('?'); + if (charSetLength <= 0) + { + return null; + } + ReadOnlySpan charSet = remainder.Slice(2, charSetLength); + + // Validate charset is an RFC 2047 token (no whitespace, controls, or tspecials). + if (!IsValidEncodedWordToken(charSet)) + { + return null; + } + + int encodingPos = 2 + charSetLength + 1; + if (encodingPos + 2 >= remainder.Length || remainder[encodingPos + 1] != '?') + { + return null; + } + char encoding = remainder[encodingPos]; + if (encoding is not ('B' or 'b' or 'Q' or 'q')) + { + return null; + } + + // Encoded text: terminated by "?=", and must not contain '?', whitespace, + // or any non-printable ASCII (per RFC 2047). + int dataStart = encodingPos + 2; + int dataEnd = -1; + for (int i = dataStart; i < remainder.Length - 1; i++) + { + char c = remainder[i]; + if (c == '?') + { + if (remainder[i + 1] == '=') + { + dataEnd = i; + break; + } + return null; + } + if (c <= ' ' || c >= 127) + { + return null; + } + } + if (dataEnd < 0) + { + return null; + } + + if (firstCharSet.IsEmpty) + { + firstCharSet = charSet; + } + + remainder = remainder.Slice(dataEnd + 2); + if (remainder.IsEmpty) + { + break; + } + + // Multiple encoded-words must be separated by linear whitespace (folding): + // an optional CRLF followed by one or more SP/HT, or just SP/HT. + int wsLength = 0; + if (remainder.Length >= 2 && remainder[0] == '\r' && remainder[1] == '\n') + { + wsLength = 2; + } + int wsEnd = wsLength; + while (wsEnd < remainder.Length && (remainder[wsEnd] == ' ' || remainder[wsEnd] == '\t')) + { + wsEnd++; + } + if (wsEnd == wsLength) + { + return null; + } + remainder = remainder.Slice(wsEnd); + if (remainder.IsEmpty) + { + break; + } } - return Encoding.GetEncoding(value[subStrings[1]]); + return Encoding.GetEncoding(firstCharSet.ToString()); + } + + private static bool IsValidEncodedWordToken(ReadOnlySpan token) + { + foreach (char c in token) + { + // RFC 2047 token: any CHAR except SPACE, CTLs, and especials + // (especials = "(" / ")" / "<" / ">" / "@" / "," / ";" / ":" / + // "\" / <"> / "/" / "[" / "]" / "?" / "." / "="). + if (c <= ' ' || c >= 127) + { + return false; + } + switch (c) + { + case '(': + case ')': + case '<': + case '>': + case '@': + case ',': + case ';': + case ':': + case '\\': + case '"': + case '/': + case '[': + case ']': + case '?': + case '.': + case '=': + return false; + } + } + return true; } internal static bool IsAscii(string value, bool permitCROrLF) diff --git a/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs b/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs index ff77e380603992..3cdc0a2312e348 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs @@ -344,5 +344,38 @@ public void CultureSensitiveSetDateViaProperty_ShouldPersistToProprtyAndParamete CultureInfo.CurrentCulture = origCulture; } } + + [Theory] + [InlineData("=?utf-8?Q?foo?=?evil\"; filename=\"foo.txt")] + [InlineData("=?utf-8?B?Zm9v?=\"; filename=\"foo.txt")] + [InlineData("=?utf-8?Q?foo\"; filename=\"foo.txt?=")] + [InlineData("=?utf-8?B?Zm9v?=trailing")] + public static void FileName_Set_InjectionPayload_IsEscapedAndNotInjected(string injection) + { + var cd = new ContentDisposition(); + cd.FileName = injection; + + Assert.Equal(injection, cd.FileName); + + var reparsed = new ContentDisposition(cd.ToString()); + Assert.Equal(injection, reparsed.FileName); + Assert.Equal(cd.Parameters.Count, reparsed.Parameters.Count); + } + + [Theory] + [InlineData("=?utf-8?B?SGVsbG8=?=")] + [InlineData("=?utf-8?Q?Hello=20world?=")] + [InlineData("=?utf-8?B?SGVsbG8=?=\r\n =?utf-8?B?d29ybGQ=?=")] + public static void FileName_Set_EncodedWord_RoundTripsUnchanged(string encoded) + { + var cd = new ContentDisposition(); + cd.FileName = encoded; + + string serialized = cd.ToString(); + Assert.Contains(encoded, serialized); + + var reparsed = new ContentDisposition(serialized); + Assert.Equal(encoded, reparsed.FileName); + } } } diff --git a/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs b/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs index 97a52b85bfaac0..c84692523abff3 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs @@ -169,5 +169,54 @@ public static void MediaType_Roundtrip() Assert.Equal("application/xml", ct.MediaType); Assert.Equal("us-ascii", ct.CharSet); } + + [Theory] + // A valid RFC 2047 encoded-word followed by attacker-controlled junk: the prefix + // alone must not put the value on the pass-through-unquoted path. + [InlineData("=?utf-8?Q?foo?=?evil\"; filename=\"foo.txt")] + [InlineData("=?utf-8?B?Zm9v?=\"; filename=\"foo.txt")] + // A would-be encoded-word containing an unescaped quote in the data section. + [InlineData("=?utf-8?Q?foo\"; filename=\"foo.txt?=")] + // Trailing content after the closing "?=" with no folding whitespace. + [InlineData("=?utf-8?B?Zm9v?=trailing")] + public static void Name_Set_InjectionPayload_IsEscapedAndNotInjected(string injection) + { + var ct = new ContentType("application/octet-stream"); + ct.Name = injection; + + // The "name" parameter must round-trip exactly through the Parameters dictionary + // and no extra parameters may appear in the serialized header. + Assert.Equal(injection, ct.Parameters["name"]); + Assert.Equal(1, ct.Parameters.Count); + + // Re-parsing the serialized form must recover the original name and produce + // exactly one parameter (i.e. no extra parameter was injected). + var reparsed = new ContentType(ct.ToString()); + Assert.Equal(1, reparsed.Parameters.Count); + Assert.Equal(injection, reparsed.Parameters["name"]); + } + + [Theory] + // Well-formed encoded-words must continue to be treated as already-encoded and pass + // through unchanged (the existing pre-encoded round-trip contract). + [InlineData("=?utf-8?B?SGVsbG8=?=")] + [InlineData("=?utf-8?Q?Hello=20world?=")] + [InlineData("=?utf-8?B?SGVsbG8=?=\r\n =?utf-8?B?d29ybGQ=?=")] + public static void Name_Set_EncodedWord_RoundTripsUnchanged(string encoded) + { + var ct = new ContentType("application/octet-stream"); + ct.Name = encoded; + + Assert.Equal(encoded, ct.Parameters["name"]); + Assert.Equal(1, ct.Parameters.Count); + + // ToString wraps the encoded-word in quotes but must not escape its contents. + string serialized = ct.ToString(); + Assert.Contains(encoded, serialized); + + var reparsed = new ContentType(serialized); + Assert.Equal(1, reparsed.Parameters.Count); + Assert.Equal(encoded, reparsed.Parameters["name"]); + } } } From ac451393a28cfa4cf7bcd66ababad3bfec4f5988 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:38:00 +0000 Subject: [PATCH 03/47] Use SearchValues for encoded-word token/data validation Co-authored-by: rzikm <32671551+rzikm@users.noreply.github.com> --- .../src/System/Net/Mime/MimeBasePart.cs | 79 ++++++------------- 1 file changed, 22 insertions(+), 57 deletions(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs index 7dee2e21ddb1f0..d32d355bc471d8 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers; using System.Collections.Specialized; using System.Net.Mail; using System.Text; @@ -13,6 +14,17 @@ internal abstract class MimeBasePart { internal const string DefaultCharSet = "utf-8"; + // RFC 2047 encoded-word token: any printable ASCII except SPACE, CTLs, and especials + // (especials = "(" / ")" / "<" / ">" / "@" / "," / ";" / ":" / + // "\" / <"> / "/" / "[" / "]" / "?" / "." / "="). + private static readonly SearchValues s_encodedWordTokenChars = + SearchValues.Create("!#$%&'*+-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ^_`abcdefghijklmnopqrstuvwxyz{|}~"); + + // Valid characters inside the encoded-text section of an RFC 2047 encoded-word: + // any printable ASCII except SPACE and '?' (which terminates the section). + private static readonly SearchValues s_encodedWordDataChars = + SearchValues.Create("!\"#$%&'()*+,-./0123456789:;<=>@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"); + protected ContentType? _contentType; protected ContentDisposition? _contentDisposition; private HeaderCollection? _headers; @@ -121,8 +133,8 @@ internal static string DecodeHeaderValue(string? value) } ReadOnlySpan charSet = remainder.Slice(2, charSetLength); - // Validate charset is an RFC 2047 token (no whitespace, controls, or tspecials). - if (!IsValidEncodedWordToken(charSet)) + // Validate charset is an RFC 2047 token (no whitespace, controls, or especials). + if (charSet.ContainsAnyExcept(s_encodedWordTokenChars)) { return null; } @@ -138,28 +150,16 @@ internal static string DecodeHeaderValue(string? value) return null; } - // Encoded text: terminated by "?=", and must not contain '?', whitespace, - // or any non-printable ASCII (per RFC 2047). + // Encoded text: terminated by "?=", and must not contain whitespace or any + // non-printable ASCII (per RFC 2047). int dataStart = encodingPos + 2; - int dataEnd = -1; - for (int i = dataStart; i < remainder.Length - 1; i++) + int terminator = remainder.Slice(dataStart).IndexOf("?="); + if (terminator < 0) { - char c = remainder[i]; - if (c == '?') - { - if (remainder[i + 1] == '=') - { - dataEnd = i; - break; - } - return null; - } - if (c <= ' ' || c >= 127) - { - return null; - } + return null; } - if (dataEnd < 0) + ReadOnlySpan data = remainder.Slice(dataStart, terminator); + if (data.ContainsAnyExcept(s_encodedWordDataChars)) { return null; } @@ -169,7 +169,7 @@ internal static string DecodeHeaderValue(string? value) firstCharSet = charSet; } - remainder = remainder.Slice(dataEnd + 2); + remainder = remainder.Slice(dataStart + terminator + 2); if (remainder.IsEmpty) { break; @@ -201,41 +201,6 @@ internal static string DecodeHeaderValue(string? value) return Encoding.GetEncoding(firstCharSet.ToString()); } - private static bool IsValidEncodedWordToken(ReadOnlySpan token) - { - foreach (char c in token) - { - // RFC 2047 token: any CHAR except SPACE, CTLs, and especials - // (especials = "(" / ")" / "<" / ">" / "@" / "," / ";" / ":" / - // "\" / <"> / "/" / "[" / "]" / "?" / "." / "="). - if (c <= ' ' || c >= 127) - { - return false; - } - switch (c) - { - case '(': - case ')': - case '<': - case '>': - case '@': - case ',': - case ';': - case ':': - case '\\': - case '"': - case '/': - case '[': - case ']': - case '?': - case '.': - case '=': - return false; - } - } - return true; - } - internal static bool IsAscii(string value, bool permitCROrLF) { ArgumentNullException.ThrowIfNull(value); From 3505d033d0313d7e6c59dfb0a0126f7bfe3d53eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:07:18 +0200 Subject: [PATCH 04/47] Minor fix --- .../System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs index d32d355bc471d8..de87a9a2235d45 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs @@ -84,7 +84,7 @@ internal static string DecodeHeaderValue(string? value) } string charSet = subStrings[1]; - bool base64Encoding = (subStrings[2] == "B"); + bool base64Encoding = (subStrings[2] == "B" || subStrings[2] == "b"); byte[] buffer = Encoding.ASCII.GetBytes(subStrings[3]); int newLength; From b7ffb80e22a6448f1ee1e1e193b3a5b90fac5fea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:07:58 +0200 Subject: [PATCH 05/47] Add tests exercising RFC 2047 spec. --- .../tests/Functional/AttachmentTest.cs | 116 +++++++++++++++++- 1 file changed, 112 insertions(+), 4 deletions(-) diff --git a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs index c1c6769395e96b..757fc48f1d3464 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs @@ -103,11 +103,119 @@ public void ContentType() [Fact] public void NameEncoding() { - Attachment attach = Attachment.CreateAttachmentFromString("test", "attachment-name"); - Assert.Null(attach.NameEncoding); - Attachment a = new Attachment(new MemoryStream(), "myname"); + Attachment a; + + a = Attachment.CreateAttachmentFromString("test", "attachment-name"); + Assert.Null(a.NameEncoding); + + a = new Attachment(new MemoryStream(), "attachmentname"); + Assert.Null(a.NameEncoding); + + a = new Attachment(new MemoryStream(), "attachmentname\u3067"); + Assert.Null(a.NameEncoding); + } + + [Fact] + public void NameParsingAndEncodingDetection() + { + Attachment a; + + // smoke test + a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachmentname?="); + Assert.Equal("attachmentname", a.Name); + Assert.Equal(a.NameEncoding, Encoding.Latin1); + + // lower case charset + a = new Attachment(new MemoryStream(), "=?iso-8859-1?Q?attachmentname?="); + Assert.Equal("attachmentname", a.Name); + Assert.Equal(a.NameEncoding, Encoding.Latin1); + + // Q encoding + a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20name?="); + Assert.Equal("attachment name", a.Name); + Assert.Equal(a.NameEncoding, Encoding.Latin1); + + // Q encoding (lowercase) + a = new Attachment(new MemoryStream(), "=?ISO-8859-1?q?attachment=20name?="); + Assert.Equal("attachment name", a.Name); + Assert.Equal(a.NameEncoding, Encoding.Latin1); + + // B encoding + a = new Attachment(new MemoryStream(), "=?ISO-8859-1?B?YXR0YWNobWVudCBuYW1l?="); + Assert.Equal("attachment name", a.Name); + Assert.Equal(a.NameEncoding, Encoding.Latin1); + + // B encoding (lowercase) + a = new Attachment(new MemoryStream(), "=?ISO-8859-1?b?YXR0YWNobWVudCBuYW1l?="); + Assert.Equal("attachment name", a.Name); + Assert.Equal(a.NameEncoding, Encoding.Latin1); + + // space alternate + a = new Attachment(new MemoryStream(), "=?ISO-8859-1?q?attachment_name?="); + Assert.Equal("attachment name", a.Name); + Assert.Equal(a.NameEncoding, Encoding.Latin1); + + // multiple encoded-words + a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20?= =?ISO-8859-1?Q?name?="); + Assert.Equal("attachment name", a.Name); + Assert.Equal(Encoding.Latin1, a.NameEncoding); + + // multi whitespace between encoded-words + a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20?= =?ISO-8859-1?Q?name?="); + Assert.Equal("attachment name", a.Name); + Assert.Equal(Encoding.Latin1, a.NameEncoding); + + // multi tab whitespace between encoded-words + a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20?= \t =?ISO-8859-1?Q?name?="); + Assert.Equal("attachment name", a.Name); + Assert.Equal(Encoding.Latin1, a.NameEncoding); + + // multi new-line whitespace between encoded-words + a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20?= \r\n =?ISO-8859-1?Q?name?="); + Assert.Equal("attachment name", a.Name); + Assert.Equal(Encoding.Latin1, a.NameEncoding); + + // multiple different encodings + a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20?= =?ISO-8859-2?Q?name?="); + Assert.Equal("attachment name", a.Name); Assert.Null(a.NameEncoding); - a = new Attachment(new MemoryStream(), "myname\u3067"); + } + + [Theory] + [InlineData("=?Q?foo?=")] // missing charset + [InlineData("=?XXXX?Q?foo?=")] // non existing encoding + [InlineData("=?ISO-8859-1?foo?=")] // missing encoding + [InlineData("=?ISO-8859-1?Q?foo?")] // missing end = + [InlineData("=?ISO-8859-1?Q?foo")] // missing whole end + [InlineData("=?ISO-8859-1?B?YXR0YWNobWV@@@@@@@@udCBuYW1l?=")] // broken base64 + [InlineData("=?ISO?8859-1?Q?foo_bar?=")] // prohibited char in charset + [InlineData("=?ISO(8859-1?Q?foo_bar?=")] // prohibited char in charset + [InlineData("=?ISO<8859-1?Q?foo_bar?=")] // prohibited char in charset + [InlineData("=?ISO@8859-1?Q?foo_bar?=")] // prohibited char in charset + [InlineData("=?ISO,8859-1?Q?foo_bar?=")] // prohibited char in charset + [InlineData("=?ISO;8859-1?Q?foo_bar?=")] // prohibited char in charset + [InlineData("=?ISO:8859-1?Q?foo_bar?=")] // prohibited char in charset + [InlineData("=?ISO/8859-1?Q?foo_bar?=")] // prohibited char in charset + [InlineData("=?ISO[8859-1?Q?foo_bar?=")] // prohibited char in charset + [InlineData("=?ISO.8859-1?Q?foo_bar?=")] // prohibited char in charset + [InlineData("=?ISO=8859-1?Q?foo_bar?=")] // prohibited char in charset + [InlineData("=?ISO\"8859-1?Q?foo_bar?=")] // prohibited char in charset + [InlineData("=?ISO-8859-1?Q??foo_bar?=")] // prohibited char in encoding + [InlineData("=?ISO-8859-1?Q(?foo_bar?=")] // prohibited char in encoding + [InlineData("=?ISO-8859-1?Q Date: Tue, 25 Aug 2026 11:08:26 +0200 Subject: [PATCH 06/47] some leftover test TODOs --- .../System.Net.Mail/tests/Functional/AttachmentTest.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs index 757fc48f1d3464..cc02751ee2ad99 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs @@ -179,6 +179,9 @@ public void NameParsingAndEncodingDetection() a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20?= =?ISO-8859-2?Q?name?="); Assert.Equal("attachment name", a.Name); Assert.Null(a.NameEncoding); + + // TODO unrescore as `=XX` + // TODO comment } [Theory] @@ -212,6 +215,7 @@ public void NameParsingAndEncodingDetection() [InlineData("=?ISO-8859-1?Q.?foo_bar?=")] // prohibited char in encoding [InlineData("=?ISO-8859-1?Q=?foo_bar?=")] // prohibited char in encoding [InlineData("=?ISO-8859-1?Q\"?foo_bar?=")] // prohibited char in encoding + // TODO: Longer > 75chars public void NameParsingAndEncodingDetectionBadInputs(string attachmentName) { Attachment a = new Attachment(new MemoryStream(), attachmentName); From 09759e888b42a825dc08ca8175fe61b823fbeecc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:49:58 +0000 Subject: [PATCH 07/47] Merge MimeBasePart.DecodeHeaderValue and DecodeEncoding into a single method Co-authored-by: mrek-msft <188900745+mrek-msft@users.noreply.github.com> --- .../src/System/Net/Mail/Attachment.cs | 4 +- .../src/System/Net/Mail/MailPriority.cs | 26 ++--- .../src/System/Net/Mime/ContentDisposition.cs | 13 ++- .../src/System/Net/Mime/ContentType.cs | 17 ++- .../src/System/Net/Mime/MimeBasePart.cs | 102 +++++++----------- .../tests/Unit/ByteEncodingTest.cs | 6 +- 6 files changed, 79 insertions(+), 89 deletions(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs b/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs index 2dbb15891d013e..1d0edf628f3b15 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs @@ -360,11 +360,11 @@ public string? Name } set { - Encoding? nameEncoding = MimeBasePart.DecodeEncoding(value); + (string decodedValue, Encoding? nameEncoding) = MimeBasePart.DecodeHeaderValue(value); if (nameEncoding != null) { _nameEncoding = nameEncoding; - _name = MimeBasePart.DecodeHeaderValue(value); + _name = decodedValue; MimePart.ContentType.Name = value; } else diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mail/MailPriority.cs b/src/libraries/System.Net.Mail/src/System/Net/Mail/MailPriority.cs index be70ab7b91a530..bd7f7e7d252148 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mail/MailPriority.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mail/MailPriority.cs @@ -132,26 +132,22 @@ internal string? Subject } set { - Encoding? inputEncoding = null; - try - { - // extract the encoding from =?encoding?BorQ?blablalba?= - inputEncoding = MimeBasePart.DecodeEncoding(value); - } - catch (ArgumentException) - { - } - - if (inputEncoding != null && value != null) + if (value != null) { try { - // Store the decoded value, we'll re-encode before sending - value = MimeBasePart.DecodeHeaderValue(value); - _subjectEncoding ??= inputEncoding; + // extract the encoding from =?encoding?BorQ?blablalba?= and store the + // decoded value; we'll re-encode before sending + (string decodedValue, Encoding? inputEncoding) = MimeBasePart.DecodeHeaderValue(value); + if (inputEncoding != null) + { + value = decodedValue; + _subjectEncoding ??= inputEncoding; + } } - // Failed to decode, just pass it through as ascii (legacy) + // Failed to decode, or unsupported charset; just pass it through as ascii (legacy) catch (FormatException) { } + catch (ArgumentException) { } } if (value != null && MailBnfHelper.HasCROrLF(value)) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs index a7af93eabceeee..c2cc440c431db3 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs @@ -223,7 +223,18 @@ internal string Encode(bool allowUnicode) private static void EncodeToBuffer(string value, StringBuilder builder, bool allowUnicode) { - Encoding? encoding = MimeBasePart.DecodeEncoding(value); + Encoding? encoding; + try + { + (_, encoding) = MimeBasePart.DecodeHeaderValue(value); + } + catch (FormatException) + { + // The value looks like an encoded-word but its content isn't actually decodable; + // treat it as not pre-encoded so it gets properly quoted/escaped below. + encoding = null; + } + if (encoding != null) // Manually encoded elsewhere, pass through { builder.Append('"').Append(value).Append('"'); diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs index 55d7b51b42d887..69bc831771f6fb 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs @@ -116,10 +116,10 @@ public string Name get { string? value = Parameters["name"]; - Encoding? nameEncoding = MimeBasePart.DecodeEncoding(value); + (string decodedValue, Encoding? nameEncoding) = MimeBasePart.DecodeHeaderValue(value); if (nameEncoding != null) { - value = MimeBasePart.DecodeHeaderValue(value); + value = decodedValue; } return value!; } @@ -191,7 +191,18 @@ internal string Encode(bool allowUnicode) private static void EncodeToBuffer(string value, StringBuilder builder, bool allowUnicode) { - Encoding? encoding = MimeBasePart.DecodeEncoding(value); + Encoding? encoding; + try + { + (_, encoding) = MimeBasePart.DecodeHeaderValue(value); + } + catch (FormatException) + { + // The value looks like an encoded-word but its content isn't actually decodable; + // treat it as not pre-encoded so it gets properly quoted/escaped below. + encoding = null; + } + if (encoding != null) // Manually encoded elsewhere, pass through { builder.Append('\"').Append(value).Append('"'); diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs index d32d355bc471d8..7e77e7b5ce4796 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs @@ -55,66 +55,27 @@ internal static string EncodeHeaderValue(string value, Encoding? encoding, bool return stream.GetEncodedString(); } - private static readonly char[] s_headerValueSplitChars = new char[] { '\r', '\n', ' ' }; - - internal static string DecodeHeaderValue(string? value) - { - if (string.IsNullOrEmpty(value)) - { - return string.Empty; - } - - string newValue = string.Empty; - - //split strings, they may be folded. If they are, decode one at a time and append the results - string[] substringsToDecode = value.Split(s_headerValueSplitChars, StringSplitOptions.RemoveEmptyEntries); - - foreach (string foldedSubString in substringsToDecode) - { - //an encoded string has as specific format in that it must start and end with an - //'=' char and contains five parts, separated by '?' chars. - //the first and last part are therefore '=', the second part is the byte encoding (B or Q) - //the third is the unicode encoding type, and the fourth is encoded message itself. '?' is not valid inside of - //an encoded string other than as a separator for these five parts. - //If this check fails, the string is either not encoded or cannot be decoded by this method - string[] subStrings = foldedSubString.Split('?'); - if ((subStrings.Length != 5 || subStrings[0] != "=" || subStrings[4] != "=")) - { - return value; - } - - string charSet = subStrings[1]; - bool base64Encoding = (subStrings[2] == "B"); - byte[] buffer = Encoding.ASCII.GetBytes(subStrings[3]); - int newLength; - - IEncodableStream s = EncodedStreamFactory.GetEncoderForHeader(Encoding.GetEncoding(charSet), base64Encoding, 0); - - newLength = s.DecodeBytes(buffer); - - Encoding encoding = Encoding.GetEncoding(charSet); - newValue += encoding.GetString(buffer, 0, newLength); - } - return newValue; - } - - // Detect the encoding: "=?charset?BorQ?content?=" + // Decodes a header value of the form "=?charset?BorQ?content?=", optionally folded across + // multiple lines: // "=?utf-8?B?RmlsZU5hbWVf55CG0Y3Qq9C60I5jw4TRicKq0YIM0Y1hSsSeTNCy0Klh?="; // 3.5 - // With the addition of folding in 4.0, there may be multiple lines with encoding, only detect the first: + // With the addition of folding in 4.0, there may be multiple lines with encoding: // "=?utf-8?B?RmlsZU5hbWVf55CG0Y3Qq9C60I5jw4TRicKq0YIM0Y1hSsSeTNCy0Klh?=\r\n =?utf-8?B??="; // // The entire value must consist of one or more well-formed RFC 2047 encoded-words - // separated by linear whitespace (folding); otherwise null is returned so callers - // do not treat attacker-controlled input as pre-encoded and pass it through unquoted. - internal static Encoding? DecodeEncoding(string? value) + // separated by linear whitespace (folding); otherwise the value is returned unchanged + // with a null Encoding, so callers do not treat attacker-controlled input as pre-encoded + // and pass it through unquoted. Otherwise, the decoded value and the Encoding of the + // first encoded-word are returned. + internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? value) { if (string.IsNullOrEmpty(value)) { - return null; + return (string.Empty, null); } ReadOnlySpan remainder = value; - ReadOnlySpan firstCharSet = default; + Encoding? firstEncoding = null; + StringBuilder? decodedValue = null; while (true) { @@ -122,32 +83,40 @@ internal static string DecodeHeaderValue(string? value) // Minimum possible length is "=?x?Q??=" (8 chars). if (remainder.Length < 8 || remainder[0] != '=' || remainder[1] != '?') { - return null; + return (value, null); } // charset = characters up to the next '?'. int charSetLength = remainder.Slice(2).IndexOf('?'); if (charSetLength <= 0) { - return null; + return (value, null); } ReadOnlySpan charSet = remainder.Slice(2, charSetLength); // Validate charset is an RFC 2047 token (no whitespace, controls, or especials). if (charSet.ContainsAnyExcept(s_encodedWordTokenChars)) { - return null; + return (value, null); } int encodingPos = 2 + charSetLength + 1; if (encodingPos + 2 >= remainder.Length || remainder[encodingPos + 1] != '?') { - return null; + return (value, null); } - char encoding = remainder[encodingPos]; - if (encoding is not ('B' or 'b' or 'Q' or 'q')) + char encodingChar = remainder[encodingPos]; + bool base64Encoding; + switch (encodingChar) { - return null; + case 'B' or 'b': + base64Encoding = true; + break; + case 'Q' or 'q': + base64Encoding = false; + break; + default: + return (value, null); } // Encoded text: terminated by "?=", and must not contain whitespace or any @@ -156,18 +125,21 @@ internal static string DecodeHeaderValue(string? value) int terminator = remainder.Slice(dataStart).IndexOf("?="); if (terminator < 0) { - return null; + return (value, null); } ReadOnlySpan data = remainder.Slice(dataStart, terminator); if (data.ContainsAnyExcept(s_encodedWordDataChars)) { - return null; + return (value, null); } - if (firstCharSet.IsEmpty) - { - firstCharSet = charSet; - } + Encoding wordEncoding = Encoding.GetEncoding(charSet.ToString()); + firstEncoding ??= wordEncoding; + + byte[] buffer = Encoding.ASCII.GetBytes(data.ToString()); + IEncodableStream s = EncodedStreamFactory.GetEncoderForHeader(wordEncoding, base64Encoding, 0); + int newLength = s.DecodeBytes(buffer); + (decodedValue ??= new StringBuilder()).Append(wordEncoding.GetString(buffer, 0, newLength)); remainder = remainder.Slice(dataStart + terminator + 2); if (remainder.IsEmpty) @@ -189,7 +161,7 @@ internal static string DecodeHeaderValue(string? value) } if (wsEnd == wsLength) { - return null; + return (value, null); } remainder = remainder.Slice(wsEnd); if (remainder.IsEmpty) @@ -198,7 +170,7 @@ internal static string DecodeHeaderValue(string? value) } } - return Encoding.GetEncoding(firstCharSet.ToString()); + return (decodedValue!.ToString(), firstEncoding); } internal static bool IsAscii(string value, bool permitCROrLF) diff --git a/src/libraries/System.Net.Mail/tests/Unit/ByteEncodingTest.cs b/src/libraries/System.Net.Mail/tests/Unit/ByteEncodingTest.cs index 1d0fc9db466d57..b5074052c53d6d 100644 --- a/src/libraries/System.Net.Mail/tests/Unit/ByteEncodingTest.cs +++ b/src/libraries/System.Net.Mail/tests/Unit/ByteEncodingTest.cs @@ -22,7 +22,7 @@ public void EncodeHeader_WithNoUnicode_ShouldNotEncode(string testHeader) Assert.InRange((byte)c, 0, 128); } - Assert.Equal(testHeader, MimeBasePart.DecodeHeaderValue(result)); + Assert.Equal(testHeader, MimeBasePart.DecodeHeaderValue(result).Value); } [Theory] @@ -41,7 +41,7 @@ public void EncoderAndDecoder_ShouldEncodeAndDecode(string testHeader, int expec Assert.InRange(foldedHeader.Length, 0, 76); } - Assert.Equal(testHeader, MimeBasePart.DecodeHeaderValue(result)); + Assert.Equal(testHeader, MimeBasePart.DecodeHeaderValue(result).Value); } [Theory] @@ -59,7 +59,7 @@ public void EncoderAndDecoder_WithQEncodedString_AndNoUnicode_AndShortHeader_Sho Assert.InRange(foldedHeader.Length, 0, 76); } - Assert.Equal(testHeader, MimeBasePart.DecodeHeaderValue(result)); + Assert.Equal(testHeader, MimeBasePart.DecodeHeaderValue(result).Value); } [Fact] From 44e4bda549135815e14fb9551f3007bfb3019369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:46:48 +0200 Subject: [PATCH 08/47] Modified handling ambigous encoding and improved readability of folding whitespace parsing. --- .../src/System/Net/Mime/MimeBasePart.cs | 46 +++++++++++++------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs index 7e77e7b5ce4796..3e342ab058678c 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs @@ -4,6 +4,7 @@ using System.Buffers; using System.Collections.Specialized; using System.Net.Mail; +using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -63,9 +64,7 @@ internal static string EncodeHeaderValue(string value, Encoding? encoding, bool // // The entire value must consist of one or more well-formed RFC 2047 encoded-words // separated by linear whitespace (folding); otherwise the value is returned unchanged - // with a null Encoding, so callers do not treat attacker-controlled input as pre-encoded - // and pass it through unquoted. Otherwise, the decoded value and the Encoding of the - // first encoded-word are returned. + // with a null Encoding. internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? value) { if (string.IsNullOrEmpty(value)) @@ -133,8 +132,28 @@ internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? val return (value, null); } - Encoding wordEncoding = Encoding.GetEncoding(charSet.ToString()); - firstEncoding ??= wordEncoding; + Encoding wordEncoding; + try + { + wordEncoding = Encoding.GetEncoding(charSet.ToString()); + } + catch (ArgumentException) + { + return (value, null); + } + + if (firstEncoding is null) + { + firstEncoding = wordEncoding; + } + else + { + // ambiguous encodings + if (firstEncoding != wordEncoding) + { + return (value, null); + } + } byte[] buffer = Encoding.ASCII.GetBytes(data.ToString()); IEncodableStream s = EncodedStreamFactory.GetEncoderForHeader(wordEncoding, base64Encoding, 0); @@ -149,21 +168,18 @@ internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? val // Multiple encoded-words must be separated by linear whitespace (folding): // an optional CRLF followed by one or more SP/HT, or just SP/HT. - int wsLength = 0; - if (remainder.Length >= 2 && remainder[0] == '\r' && remainder[1] == '\n') - { - wsLength = 2; - } - int wsEnd = wsLength; - while (wsEnd < remainder.Length && (remainder[wsEnd] == ' ' || remainder[wsEnd] == '\t')) + bool hasNewLine = remainder.Length >= 2 && remainder[0] == '\r' && remainder[1] == '\n'; + int whiteSpacesLength = hasNewLine ? 2 : 0; + while (whiteSpacesLength < remainder.Length && (remainder[whiteSpacesLength] == ' ' || remainder[whiteSpacesLength] == '\t')) { - wsEnd++; + whiteSpacesLength++; } - if (wsEnd == wsLength) + // CRLF NOT followed by at least one SP/HT + if (hasNewLine && whiteSpacesLength == 2) { return (value, null); } - remainder = remainder.Slice(wsEnd); + remainder = remainder.Slice(whiteSpacesLength); if (remainder.IsEmpty) { break; From 8ff78a5cffd4065a30234a9a7ca54721239ef88a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:49:07 +0200 Subject: [PATCH 09/47] Improve test coverage and fix failing tests. --- .../tests/Functional/AttachmentTest.cs | 51 ++++++++++++++----- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs index cc02751ee2ad99..40d5fdf1fad3c9 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs @@ -155,42 +155,52 @@ public void NameParsingAndEncodingDetection() Assert.Equal("attachment name", a.Name); Assert.Equal(a.NameEncoding, Encoding.Latin1); + // question mark alternate + a = new Attachment(new MemoryStream(), "=?ISO-8859-1?q?attachment=3Fname?="); + Assert.Equal("attachment?name", a.Name); + Assert.Equal(a.NameEncoding, Encoding.Latin1); + + // underscore alternate + a = new Attachment(new MemoryStream(), "=?ISO-8859-1?q?attachment=5Fname?="); + Assert.Equal("attachment_name", a.Name); + Assert.Equal(a.NameEncoding, Encoding.Latin1); + // multiple encoded-words a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20?= =?ISO-8859-1?Q?name?="); Assert.Equal("attachment name", a.Name); Assert.Equal(Encoding.Latin1, a.NameEncoding); - // multi whitespace between encoded-words + // whitespace between encoded-words a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20?= =?ISO-8859-1?Q?name?="); Assert.Equal("attachment name", a.Name); Assert.Equal(Encoding.Latin1, a.NameEncoding); - // multi tab whitespace between encoded-words + // tab between encoded-words a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20?= \t =?ISO-8859-1?Q?name?="); Assert.Equal("attachment name", a.Name); Assert.Equal(Encoding.Latin1, a.NameEncoding); - // multi new-line whitespace between encoded-words - a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20?= \r\n =?ISO-8859-1?Q?name?="); + // new-line whitespace between encoded-words + a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20?=\r\n =?ISO-8859-1?Q?name?="); Assert.Equal("attachment name", a.Name); Assert.Equal(Encoding.Latin1, a.NameEncoding); - - // multiple different encodings - a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20?= =?ISO-8859-2?Q?name?="); - Assert.Equal("attachment name", a.Name); - Assert.Null(a.NameEncoding); - - // TODO unrescore as `=XX` - // TODO comment } [Theory] [InlineData("=?Q?foo?=")] // missing charset [InlineData("=?XXXX?Q?foo?=")] // non existing encoding [InlineData("=?ISO-8859-1?foo?=")] // missing encoding + [InlineData("=?ISO-8859-1?qb?foo?=")] // two letter encoding [InlineData("=?ISO-8859-1?Q?foo?")] // missing end = [InlineData("=?ISO-8859-1?Q?foo")] // missing whole end - [InlineData("=?ISO-8859-1?B?YXR0YWNobWV@@@@@@@@udCBuYW1l?=")] // broken base64 + [InlineData("=?ISO-8859-1?Q?foo_=?=")] // broken Q encoding, = at end + [InlineData("=?ISO-8859-1?Q?foo_=A?=")] // broken Q encoding, single hex digit + [InlineData("=?ISO-8859-1?Q?foo_?A?=")] // broken Q encoding, ? in text + [InlineData("=?ISO-8859-1?Q?foo bar?=")] // broken Q encoding, space in text + [InlineData("=?ISO-8859-1?Q?foo\tbar?=")] // broken Q encoding, tab in text + [InlineData("=?ISO-8859-1?Q?foo\rbar?=")] // broken Q encoding, new line (CR) in text + [InlineData("=?ISO-8859-1?Q?foo\nbar?=")] // broken Q encoding, new line (LF) in text + [InlineData("=?ISO-8859-1?Q?foo\r\nbar?=")] // broken Q encoding, new line (CRLF) in text [InlineData("=?ISO?8859-1?Q?foo_bar?=")] // prohibited char in charset [InlineData("=?ISO(8859-1?Q?foo_bar?=")] // prohibited char in charset [InlineData("=?ISO<8859-1?Q?foo_bar?=")] // prohibited char in charset @@ -215,7 +225,8 @@ public void NameParsingAndEncodingDetection() [InlineData("=?ISO-8859-1?Q.?foo_bar?=")] // prohibited char in encoding [InlineData("=?ISO-8859-1?Q=?foo_bar?=")] // prohibited char in encoding [InlineData("=?ISO-8859-1?Q\"?foo_bar?=")] // prohibited char in encoding - // TODO: Longer > 75chars + [InlineData("=?ISO-8859-1?Q\"?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_barxy?=")] // longer then 75 chars + [InlineData("=?ISO-8859-1?Q?attachment=20?= =?ISO-8859-2?Q?name?=")] // multiple different encodings public void NameParsingAndEncodingDetectionBadInputs(string attachmentName) { Attachment a = new Attachment(new MemoryStream(), attachmentName); @@ -223,6 +234,18 @@ public void NameParsingAndEncodingDetectionBadInputs(string attachmentName) Assert.Null(a.NameEncoding); } + [Fact] + public void NameParsingAndEncodingDetectionBadInputsThrowing() + { + // Bad Base64 encoding + Assert.Throws(() => new Attachment(new MemoryStream(), "=?ISO-8859-1?B?YXR0YWNobWV@@@@@@@@udCBuYW1l?=")); + + // broken Q encoding, invalid hex value + Assert.Throws(() => new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?foo_=XY?=")); + + } + + [Fact] public void ContentStream() { From 05a1d1093b48651a6915bde19dec3dc4da1dbe78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:22:52 +0000 Subject: [PATCH 10/47] Fix silent truncation of malformed Q-encoded escape sequences Co-authored-by: mrek-msft <188900745+mrek-msft@users.noreply.github.com> --- .../src/System/Net/Mime/MimeBasePart.cs | 16 ++++ .../src/System/Net/Mime/QEncodedStream.cs | 80 +++---------------- 2 files changed, 26 insertions(+), 70 deletions(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs index 3e342ab058678c..edb073a589c88d 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs @@ -132,6 +132,22 @@ internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? val return (value, null); } + // For Q-encoding, every '=' must be followed by exactly two hex digits. + // QEncodedStream.DecodeBytes validates the digits themselves, but a '=' + // within the last two characters can never have two digits following it, + // so reject that shape here rather than passing a truncated escape to the + // decoder (which is only ever invoked once per encoded-word and would + // otherwise decode everything up to the truncated escape, silently + // dropping it, instead of recognizing the whole value as malformed). + if (!base64Encoding) + { + ReadOnlySpan tail = data.Length > 2 ? data.Slice(data.Length - 2) : data; + if (tail.Contains('=')) + { + return (value, null); + } + } + Encoding wordEncoding; try { diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/QEncodedStream.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/QEncodedStream.cs index d747eab91f9f70..88e7945a557410 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/QEncodedStream.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/QEncodedStream.cs @@ -48,8 +48,6 @@ internal QEncodedStream(WriteStateInfoBase wsi) : base(new MemoryStream()) _encoder = new QEncoder(_writeState); } - private ReadStateInfo ReadState => field ??= new ReadStateInfo(); - internal WriteStateInfoBase WriteState => _writeState; public override bool CanRead => BaseStream.CanRead; @@ -71,58 +69,6 @@ public int DecodeBytes(Span buffer) int source = 0; int destination = 0; - // if the last read ended in a partially decoded - // sequence, pick up where we left off. - if (ReadState.IsEscaped) - { - // this will be -1 if the previous read ended - // with an escape character. - if (ReadState.Byte == -1) - { - // if we only read one byte from the underlying - // stream, we'll need to save the byte and - // ask for more. - if (buffer.Length == 1) - { - ReadState.Byte = buffer[source]; - return 0; - } - - // '=\r\n' means a soft (aka. invisible) CRLF sequence... - if (buffer[source] != '\r' || buffer[source + 1] != '\n') - { - byte b1 = HexDecodeMap[buffer[source]]; - byte b2 = HexDecodeMap[buffer[source + 1]]; - if (b1 == 255) - throw new FormatException(SR.Format(SR.InvalidHexDigit, (char)buffer[source])); - if (b2 == 255) - throw new FormatException(SR.Format(SR.InvalidHexDigit, (char)buffer[source + 1])); - - buffer[destination++] = (byte)((b1 << 4) + b2); - } - - source += 2; - } - else - { - // '=\r\n' means a soft (aka. invisible) CRLF sequence... - if (ReadState.Byte != '\r' || buffer[source] != '\n') - { - byte b1 = HexDecodeMap[ReadState.Byte]; - byte b2 = HexDecodeMap[buffer[source]]; - if (b1 == 255) - throw new FormatException(SR.Format(SR.InvalidHexDigit, (char)ReadState.Byte)); - if (b2 == 255) - throw new FormatException(SR.Format(SR.InvalidHexDigit, (char)buffer[source])); - buffer[destination++] = (byte)((b1 << 4) + b2); - } - source++; - } - // reset state for next read. - ReadState.IsEscaped = false; - ReadState.Byte = -1; - } - // Here's where most of the decoding takes place. // We'll loop around until we've inspected all the // bytes read. @@ -145,19 +91,19 @@ public int DecodeBytes(Span buffer) else { // determine where we are relative to the end - // of the data. If we don't have enough data to - // decode the escape sequence, save off what we - // have and continue the decoding in the next - // read. Otherwise, decode the data and copy - // into dest. + // of the data. Otherwise, decode the data and + // copy into dest. switch (buffer.Length - source) { case 2: - ReadState.Byte = buffer[source + 1]; - goto case 1; case 1: - ReadState.IsEscaped = true; - goto EndWhile; + // DecodeBytes is always called with the entire encoded-word's data in one + // shot (see MimeBasePart.DecodeHeaderValue), so there is no subsequent call + // that could complete a deferred escape sequence. An '=' without two + // trailing hex digits at the end of the data is therefore malformed, not + // merely split across reads, and must be rejected rather than silently + // dropped. + throw new FormatException(SR.MailHeaderFieldMalformedHeader); default: if (buffer[source + 1] != '\r' || buffer[source + 2] != '\n') { @@ -175,7 +121,7 @@ public int DecodeBytes(Span buffer) } } } - EndWhile: + return destination; } @@ -258,11 +204,5 @@ protected override async ValueTask WriteAsyncInternal(ReadOnlyMemory buffe } } } - - private sealed class ReadStateInfo - { - internal bool IsEscaped { get; set; } - internal short Byte { get; set; } = -1; - } } } From 1763e491e4537936b2c6629aab8b1ee8391dad26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:33:41 +0200 Subject: [PATCH 11/47] Revert changes in QEncodedStream --- .../src/System/Net/Mime/QEncodedStream.cs | 80 ++++++++++++++++--- 1 file changed, 70 insertions(+), 10 deletions(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/QEncodedStream.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/QEncodedStream.cs index 88e7945a557410..d747eab91f9f70 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/QEncodedStream.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/QEncodedStream.cs @@ -48,6 +48,8 @@ internal QEncodedStream(WriteStateInfoBase wsi) : base(new MemoryStream()) _encoder = new QEncoder(_writeState); } + private ReadStateInfo ReadState => field ??= new ReadStateInfo(); + internal WriteStateInfoBase WriteState => _writeState; public override bool CanRead => BaseStream.CanRead; @@ -69,6 +71,58 @@ public int DecodeBytes(Span buffer) int source = 0; int destination = 0; + // if the last read ended in a partially decoded + // sequence, pick up where we left off. + if (ReadState.IsEscaped) + { + // this will be -1 if the previous read ended + // with an escape character. + if (ReadState.Byte == -1) + { + // if we only read one byte from the underlying + // stream, we'll need to save the byte and + // ask for more. + if (buffer.Length == 1) + { + ReadState.Byte = buffer[source]; + return 0; + } + + // '=\r\n' means a soft (aka. invisible) CRLF sequence... + if (buffer[source] != '\r' || buffer[source + 1] != '\n') + { + byte b1 = HexDecodeMap[buffer[source]]; + byte b2 = HexDecodeMap[buffer[source + 1]]; + if (b1 == 255) + throw new FormatException(SR.Format(SR.InvalidHexDigit, (char)buffer[source])); + if (b2 == 255) + throw new FormatException(SR.Format(SR.InvalidHexDigit, (char)buffer[source + 1])); + + buffer[destination++] = (byte)((b1 << 4) + b2); + } + + source += 2; + } + else + { + // '=\r\n' means a soft (aka. invisible) CRLF sequence... + if (ReadState.Byte != '\r' || buffer[source] != '\n') + { + byte b1 = HexDecodeMap[ReadState.Byte]; + byte b2 = HexDecodeMap[buffer[source]]; + if (b1 == 255) + throw new FormatException(SR.Format(SR.InvalidHexDigit, (char)ReadState.Byte)); + if (b2 == 255) + throw new FormatException(SR.Format(SR.InvalidHexDigit, (char)buffer[source])); + buffer[destination++] = (byte)((b1 << 4) + b2); + } + source++; + } + // reset state for next read. + ReadState.IsEscaped = false; + ReadState.Byte = -1; + } + // Here's where most of the decoding takes place. // We'll loop around until we've inspected all the // bytes read. @@ -91,19 +145,19 @@ public int DecodeBytes(Span buffer) else { // determine where we are relative to the end - // of the data. Otherwise, decode the data and - // copy into dest. + // of the data. If we don't have enough data to + // decode the escape sequence, save off what we + // have and continue the decoding in the next + // read. Otherwise, decode the data and copy + // into dest. switch (buffer.Length - source) { case 2: + ReadState.Byte = buffer[source + 1]; + goto case 1; case 1: - // DecodeBytes is always called with the entire encoded-word's data in one - // shot (see MimeBasePart.DecodeHeaderValue), so there is no subsequent call - // that could complete a deferred escape sequence. An '=' without two - // trailing hex digits at the end of the data is therefore malformed, not - // merely split across reads, and must be rejected rather than silently - // dropped. - throw new FormatException(SR.MailHeaderFieldMalformedHeader); + ReadState.IsEscaped = true; + goto EndWhile; default: if (buffer[source + 1] != '\r' || buffer[source + 2] != '\n') { @@ -121,7 +175,7 @@ public int DecodeBytes(Span buffer) } } } - + EndWhile: return destination; } @@ -204,5 +258,11 @@ protected override async ValueTask WriteAsyncInternal(ReadOnlyMemory buffe } } } + + private sealed class ReadStateInfo + { + internal bool IsEscaped { get; set; } + internal short Byte { get; set; } = -1; + } } } From 8984cb89c50f66734409178953a18bb4ef738027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:55:52 +0200 Subject: [PATCH 12/47] Improve test data --- .../System.Net.Mail/tests/Functional/ContentDispositionTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs b/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs index 3cdc0a2312e348..d1212947b990af 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs @@ -346,7 +346,7 @@ public void CultureSensitiveSetDateViaProperty_ShouldPersistToProprtyAndParamete } [Theory] - [InlineData("=?utf-8?Q?foo?=?evil\"; filename=\"foo.txt")] + [InlineData("=?utf-8?Q?foo?=?bar\"; filename=\"foo.txt")] [InlineData("=?utf-8?B?Zm9v?=\"; filename=\"foo.txt")] [InlineData("=?utf-8?Q?foo\"; filename=\"foo.txt?=")] [InlineData("=?utf-8?B?Zm9v?=trailing")] From b02417ca18048927e6acabcf4355d2a33b7aa2a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:40:39 +0200 Subject: [PATCH 13/47] Improve test data --- .../System.Net.Mail/tests/Functional/ContentTypeTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs b/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs index c84692523abff3..bd62c8f413b90c 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs @@ -173,7 +173,7 @@ public static void MediaType_Roundtrip() [Theory] // A valid RFC 2047 encoded-word followed by attacker-controlled junk: the prefix // alone must not put the value on the pass-through-unquoted path. - [InlineData("=?utf-8?Q?foo?=?evil\"; filename=\"foo.txt")] + [InlineData("=?utf-8?Q?foo?=?bar\"; filename=\"foo.txt")] [InlineData("=?utf-8?B?Zm9v?=\"; filename=\"foo.txt")] // A would-be encoded-word containing an unescaped quote in the data section. [InlineData("=?utf-8?Q?foo\"; filename=\"foo.txt?=")] From 66e2cb7f384a645d2a0513e80c0d84c0a86fc787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:47:26 +0200 Subject: [PATCH 14/47] Allow multiple ecnoded words with different encodings. --- .../src/System/Net/Mime/MimeBasePart.cs | 13 +------------ .../tests/Functional/AttachmentTest.cs | 6 +++++- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs index edb073a589c88d..e350a4e4342b3b 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs @@ -158,18 +158,7 @@ internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? val return (value, null); } - if (firstEncoding is null) - { - firstEncoding = wordEncoding; - } - else - { - // ambiguous encodings - if (firstEncoding != wordEncoding) - { - return (value, null); - } - } + firstEncoding ??= wordEncoding; byte[] buffer = Encoding.ASCII.GetBytes(data.ToString()); IEncodableStream s = EncodedStreamFactory.GetEncoderForHeader(wordEncoding, base64Encoding, 0); diff --git a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs index 40d5fdf1fad3c9..c509d95f25c2c8 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs @@ -184,6 +184,11 @@ public void NameParsingAndEncodingDetection() a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20?=\r\n =?ISO-8859-1?Q?name?="); Assert.Equal("attachment name", a.Name); Assert.Equal(Encoding.Latin1, a.NameEncoding); + + // multiple different encodings + a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20?= =?ISO-8859-2?Q?name?="); + Assert.Equal("attachment name", a.Name); + Assert.Equal(Encoding.Latin1, a.NameEncoding); } [Theory] @@ -226,7 +231,6 @@ public void NameParsingAndEncodingDetection() [InlineData("=?ISO-8859-1?Q=?foo_bar?=")] // prohibited char in encoding [InlineData("=?ISO-8859-1?Q\"?foo_bar?=")] // prohibited char in encoding [InlineData("=?ISO-8859-1?Q\"?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_barxy?=")] // longer then 75 chars - [InlineData("=?ISO-8859-1?Q?attachment=20?= =?ISO-8859-2?Q?name?=")] // multiple different encodings public void NameParsingAndEncodingDetectionBadInputs(string attachmentName) { Attachment a = new Attachment(new MemoryStream(), attachmentName); From 8e2fd28e900bbf3d3825c80107dca4bf83730a79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:09:46 +0200 Subject: [PATCH 15/47] Return backward compatible behaviour and promote bad encoding exceptions --- .../src/System/Net/Mime/MimeBasePart.cs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs index e350a4e4342b3b..1ad6de803f0bd1 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs @@ -148,16 +148,7 @@ internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? val } } - Encoding wordEncoding; - try - { - wordEncoding = Encoding.GetEncoding(charSet.ToString()); - } - catch (ArgumentException) - { - return (value, null); - } - + Encoding wordEncoding = Encoding.GetEncoding(charSet.ToString()); firstEncoding ??= wordEncoding; byte[] buffer = Encoding.ASCII.GetBytes(data.ToString()); From ee5dedfc59df24dae02fdc78b220e43362408464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:25:47 +0200 Subject: [PATCH 16/47] Fixed wrong character in length test. Added support for checking encoded word max length in compliance with RFC 2047. --- .../System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs | 6 ++++-- .../System.Net.Mail/tests/Functional/AttachmentTest.cs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs index 1ad6de803f0bd1..dc184d3694a1d3 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs @@ -67,6 +67,8 @@ internal static string EncodeHeaderValue(string value, Encoding? encoding, bool // with a null Encoding. internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? value) { + const int MaxEncodedWordLength = 75; + if (string.IsNullOrEmpty(value)) { return (string.Empty, null); @@ -86,7 +88,7 @@ internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? val } // charset = characters up to the next '?'. - int charSetLength = remainder.Slice(2).IndexOf('?'); + int charSetLength = remainder.Slice(2, MaxEncodedWordLength).IndexOf('?'); if (charSetLength <= 0) { return (value, null); @@ -121,7 +123,7 @@ internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? val // Encoded text: terminated by "?=", and must not contain whitespace or any // non-printable ASCII (per RFC 2047). int dataStart = encodingPos + 2; - int terminator = remainder.Slice(dataStart).IndexOf("?="); + int terminator = remainder.Slice(dataStart, MaxEncodedWordLength).IndexOf("?="); if (terminator < 0) { return (value, null); diff --git a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs index c509d95f25c2c8..ca5bf5c9ca44a5 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs @@ -230,7 +230,7 @@ public void NameParsingAndEncodingDetection() [InlineData("=?ISO-8859-1?Q.?foo_bar?=")] // prohibited char in encoding [InlineData("=?ISO-8859-1?Q=?foo_bar?=")] // prohibited char in encoding [InlineData("=?ISO-8859-1?Q\"?foo_bar?=")] // prohibited char in encoding - [InlineData("=?ISO-8859-1?Q\"?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_barxy?=")] // longer then 75 chars + [InlineData("=?ISO-8859-1?Q?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?=")] // longer then 75 chars public void NameParsingAndEncodingDetectionBadInputs(string attachmentName) { Attachment a = new Attachment(new MemoryStream(), attachmentName); From a6d875792886ae4a2a2be2895c9eff46f44ae029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:54:14 +0200 Subject: [PATCH 17/47] Fix length validation and tests. --- .../src/System/Net/Mime/MimeBasePart.cs | 9 +++-- .../tests/Functional/AttachmentTest.cs | 36 +++++++++++++++---- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs index dc184d3694a1d3..bb5a3166d8cbb0 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs @@ -88,7 +88,7 @@ internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? val } // charset = characters up to the next '?'. - int charSetLength = remainder.Slice(2, MaxEncodedWordLength).IndexOf('?'); + int charSetLength = remainder.Slice(2, Math.Min(remainder.Length - 2, MaxEncodedWordLength)).IndexOf('?'); if (charSetLength <= 0) { return (value, null); @@ -123,7 +123,7 @@ internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? val // Encoded text: terminated by "?=", and must not contain whitespace or any // non-printable ASCII (per RFC 2047). int dataStart = encodingPos + 2; - int terminator = remainder.Slice(dataStart, MaxEncodedWordLength).IndexOf("?="); + int terminator = remainder.Slice(dataStart, Math.Min(remainder.Length - dataStart, MaxEncodedWordLength)).IndexOf("?="); if (terminator < 0) { return (value, null); @@ -158,6 +158,11 @@ internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? val int newLength = s.DecodeBytes(buffer); (decodedValue ??= new StringBuilder()).Append(wordEncoding.GetString(buffer, 0, newLength)); + if (dataStart + terminator + 2 > MaxEncodedWordLength) + { + return (value, null); + } + remainder = remainder.Slice(dataStart + terminator + 2); if (remainder.IsEmpty) { diff --git a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs index ca5bf5c9ca44a5..84fccb5ccccf96 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs @@ -116,7 +116,7 @@ public void NameEncoding() } [Fact] - public void NameParsingAndEncodingDetection() + public void NameParsingAndEncodingDetection_Basics() { Attachment a; @@ -186,14 +186,13 @@ public void NameParsingAndEncodingDetection() Assert.Equal(Encoding.Latin1, a.NameEncoding); // multiple different encodings - a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20?= =?ISO-8859-2?Q?name?="); + a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20?= =?UTF-8?Q?name?="); Assert.Equal("attachment name", a.Name); Assert.Equal(Encoding.Latin1, a.NameEncoding); } [Theory] [InlineData("=?Q?foo?=")] // missing charset - [InlineData("=?XXXX?Q?foo?=")] // non existing encoding [InlineData("=?ISO-8859-1?foo?=")] // missing encoding [InlineData("=?ISO-8859-1?qb?foo?=")] // two letter encoding [InlineData("=?ISO-8859-1?Q?foo?")] // missing end = @@ -230,8 +229,7 @@ public void NameParsingAndEncodingDetection() [InlineData("=?ISO-8859-1?Q.?foo_bar?=")] // prohibited char in encoding [InlineData("=?ISO-8859-1?Q=?foo_bar?=")] // prohibited char in encoding [InlineData("=?ISO-8859-1?Q\"?foo_bar?=")] // prohibited char in encoding - [InlineData("=?ISO-8859-1?Q?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?=")] // longer then 75 chars - public void NameParsingAndEncodingDetectionBadInputs(string attachmentName) + public void NameParsingAndEncodingDetection_BadInputs(string attachmentName) { Attachment a = new Attachment(new MemoryStream(), attachmentName); Assert.Equal(attachmentName, a.Name); @@ -239,7 +237,7 @@ public void NameParsingAndEncodingDetectionBadInputs(string attachmentName) } [Fact] - public void NameParsingAndEncodingDetectionBadInputsThrowing() + public void NameParsingAndEncodingDetection_BadInputsThrowing() { // Bad Base64 encoding Assert.Throws(() => new Attachment(new MemoryStream(), "=?ISO-8859-1?B?YXR0YWNobWV@@@@@@@@udCBuYW1l?=")); @@ -247,8 +245,34 @@ public void NameParsingAndEncodingDetectionBadInputsThrowing() // broken Q encoding, invalid hex value Assert.Throws(() => new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?foo_=XY?=")); + // non existing encoding + Assert.Throws(() => new Attachment(new MemoryStream(), "=?XXXX?Q?foo?=")); } + [Fact] + public void NameParsingAndEncodingDetection_LengthValidation() + { + // 75 characters => decoded + Attachment a; + a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_fo?="); + Assert.Equal("foo bar foo bar foo bar foo bar foo bar foo bar foo bar fo", a.Name); + Assert.Equal(Encoding.Latin1, a.NameEncoding); + + // 76 charcters => RFC 2047 violation, not processed as encoded word + a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?="); + Assert.Equal("=?ISO-8859-1?Q?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?=", a.Name); + Assert.Null(a.NameEncoding); + + // 76 characters, different part of encoded word violating + a = new Attachment(new MemoryStream(), "=?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?ISO-8859-1?Q?="); + Assert.Equal("=?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?ISO-8859-1?Q?=", a.Name); + Assert.Null(a.NameEncoding); + + // 76 characters, different part of encoded word violating + a = new Attachment(new MemoryStream(), "=?Q?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?ISO-8859-1?="); + Assert.Equal("=?Q?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?ISO-8859-1?=", a.Name); + Assert.Null(a.NameEncoding); + } [Fact] public void ContentStream() From e7e23969b2d2e48739ee98097d73bb9069d02093 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:44:56 +0000 Subject: [PATCH 18/47] Reject over-length encoded-words before charset resolution Co-authored-by: mrek-msft <188900745+mrek-msft@users.noreply.github.com> --- .../src/System/Net/Mime/MimeBasePart.cs | 17 ++++++++++++----- .../tests/Functional/AttachmentTest.cs | 8 ++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs index bb5a3166d8cbb0..cc141206f89972 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs @@ -128,6 +128,18 @@ internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? val { return (value, null); } + + // An encoded-word may not be more than 75 characters long, including + // 'charset', 'encoding', 'encoded-text', and the delimiting '?'s (RFC 2047 + // section 2). Reject an over-long encoded-word before attempting to resolve + // its charset or decode its content, so that an invalid charset name in an + // already-non-conforming (over-long) value doesn't surface as an exception + // instead of being treated as ordinary literal text. + if (dataStart + terminator + 2 > MaxEncodedWordLength) + { + return (value, null); + } + ReadOnlySpan data = remainder.Slice(dataStart, terminator); if (data.ContainsAnyExcept(s_encodedWordDataChars)) { @@ -158,11 +170,6 @@ internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? val int newLength = s.DecodeBytes(buffer); (decodedValue ??= new StringBuilder()).Append(wordEncoding.GetString(buffer, 0, newLength)); - if (dataStart + terminator + 2 > MaxEncodedWordLength) - { - return (value, null); - } - remainder = remainder.Slice(dataStart + terminator + 2); if (remainder.IsEmpty) { diff --git a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs index 84fccb5ccccf96..b6a7922be5f52d 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs @@ -272,6 +272,14 @@ public void NameParsingAndEncodingDetection_LengthValidation() a = new Attachment(new MemoryStream(), "=?Q?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?ISO-8859-1?="); Assert.Equal("=?Q?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?ISO-8859-1?=", a.Name); Assert.Null(a.NameEncoding); + + // over-length AND a non-existent charset: the length violation must be detected + // before the charset is resolved, so this is treated as literal text rather + // than throwing. + string overLong = "=?XXXX?Q?" + new string('a', 70) + "?="; + a = new Attachment(new MemoryStream(), overLong); + Assert.Equal(overLong, a.Name); + Assert.Null(a.NameEncoding); } [Fact] From b61e2003c57931f78388bc2ca65e53518563c3fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:06:53 +0200 Subject: [PATCH 19/47] Remove useless long comment binding statements about order of checks. --- .../System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs | 6 ------ .../System.Net.Mail/tests/Functional/AttachmentTest.cs | 8 -------- 2 files changed, 14 deletions(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs index cc141206f89972..14700c7c7098d8 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs @@ -129,12 +129,6 @@ internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? val return (value, null); } - // An encoded-word may not be more than 75 characters long, including - // 'charset', 'encoding', 'encoded-text', and the delimiting '?'s (RFC 2047 - // section 2). Reject an over-long encoded-word before attempting to resolve - // its charset or decode its content, so that an invalid charset name in an - // already-non-conforming (over-long) value doesn't surface as an exception - // instead of being treated as ordinary literal text. if (dataStart + terminator + 2 > MaxEncodedWordLength) { return (value, null); diff --git a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs index b6a7922be5f52d..84fccb5ccccf96 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs @@ -272,14 +272,6 @@ public void NameParsingAndEncodingDetection_LengthValidation() a = new Attachment(new MemoryStream(), "=?Q?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?ISO-8859-1?="); Assert.Equal("=?Q?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?ISO-8859-1?=", a.Name); Assert.Null(a.NameEncoding); - - // over-length AND a non-existent charset: the length violation must be detected - // before the charset is resolved, so this is treated as literal text rather - // than throwing. - string overLong = "=?XXXX?Q?" + new string('a', 70) + "?="; - a = new Attachment(new MemoryStream(), overLong); - Assert.Equal(overLong, a.Name); - Assert.Null(a.NameEncoding); } [Fact] From 6de02d035ca2c366081a3987dd06af583b0133d6 Mon Sep 17 00:00:00 2001 From: MichalZ <188900745+mrek-msft@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:39:19 +0200 Subject: [PATCH 20/47] Add test case Adding copilot proposed test case Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../System.Net.Mail/tests/Functional/ContentTypeTest.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs b/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs index bd62c8f413b90c..f8494067cfa4dd 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs @@ -177,6 +177,7 @@ public static void MediaType_Roundtrip() [InlineData("=?utf-8?B?Zm9v?=\"; filename=\"foo.txt")] // A would-be encoded-word containing an unescaped quote in the data section. [InlineData("=?utf-8?Q?foo\"; filename=\"foo.txt?=")] + [InlineData("=?utf-8?Q?foo\"bar?=")] // Trailing content after the closing "?=" with no folding whitespace. [InlineData("=?utf-8?B?Zm9v?=trailing")] public static void Name_Set_InjectionPayload_IsEscapedAndNotInjected(string injection) From 16995ef4b8f3dfbfaed75901b95a5d79abbf8478 Mon Sep 17 00:00:00 2001 From: MichalZ <188900745+mrek-msft@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:39:48 +0200 Subject: [PATCH 21/47] Add test case Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../System.Net.Mail/tests/Functional/ContentDispositionTest.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs b/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs index d1212947b990af..ba75eb84fc7cdb 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs @@ -349,6 +349,7 @@ public void CultureSensitiveSetDateViaProperty_ShouldPersistToProprtyAndParamete [InlineData("=?utf-8?Q?foo?=?bar\"; filename=\"foo.txt")] [InlineData("=?utf-8?B?Zm9v?=\"; filename=\"foo.txt")] [InlineData("=?utf-8?Q?foo\"; filename=\"foo.txt?=")] + [InlineData("=?utf-8?Q?foo\"bar?=")] [InlineData("=?utf-8?B?Zm9v?=trailing")] public static void FileName_Set_InjectionPayload_IsEscapedAndNotInjected(string injection) { From f69c6cf144ff9588b040e0aeb241d3049ba3e166 Mon Sep 17 00:00:00 2001 From: MichalZ <188900745+mrek-msft@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:26:07 +0200 Subject: [PATCH 22/47] Catch unsupported encoding exceptions same as format. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/System/Net/Mime/ContentDisposition.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs index c2cc440c431db3..d9035b5c8b1a9e 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs @@ -234,6 +234,11 @@ private static void EncodeToBuffer(string value, StringBuilder builder, bool all // treat it as not pre-encoded so it gets properly quoted/escaped below. encoding = null; } + catch (ArgumentException) + { + // Unsupported charset; treat it as not pre-encoded so it gets properly quoted/escaped below. + encoding = null; + } if (encoding != null) // Manually encoded elsewhere, pass through { From cc1f41905c7bbfc2250e0c14fc1a4658c8c67df2 Mon Sep 17 00:00:00 2001 From: MichalZ <188900745+mrek-msft@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:26:38 +0200 Subject: [PATCH 23/47] Handle unsupported charset in ContentType Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../System.Net.Mail/src/System/Net/Mime/ContentType.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs index 69bc831771f6fb..4aadc295e57ef6 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs @@ -202,6 +202,11 @@ private static void EncodeToBuffer(string value, StringBuilder builder, bool all // treat it as not pre-encoded so it gets properly quoted/escaped below. encoding = null; } + catch (ArgumentException) + { + // Unsupported charset; treat it as not pre-encoded so it gets properly quoted/escaped below. + encoding = null; + } if (encoding != null) // Manually encoded elsewhere, pass through { From 4880dae1d49b0aadc867a8f298bc4fd7f1875570 Mon Sep 17 00:00:00 2001 From: MichalZ <188900745+mrek-msft@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:27:31 +0200 Subject: [PATCH 24/47] Remove unused using directive for InteropServices Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs index 14700c7c7098d8..b9b2323e31d105 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs @@ -4,7 +4,6 @@ using System.Buffers; using System.Collections.Specialized; using System.Net.Mail; -using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; From 7b77d0ddfe3a96a66920fbd459a8e18ee544006d Mon Sep 17 00:00:00 2001 From: MichalZ <188900745+mrek-msft@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:28:10 +0200 Subject: [PATCH 25/47] Fix typo in comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../System.Net.Mail/tests/Functional/AttachmentTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs index 84fccb5ccccf96..9e5da2d63db8d0 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs @@ -258,7 +258,7 @@ public void NameParsingAndEncodingDetection_LengthValidation() Assert.Equal("foo bar foo bar foo bar foo bar foo bar foo bar foo bar fo", a.Name); Assert.Equal(Encoding.Latin1, a.NameEncoding); - // 76 charcters => RFC 2047 violation, not processed as encoded word + // 76 characters => RFC 2047 violation, not processed as encoded word a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?="); Assert.Equal("=?ISO-8859-1?Q?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?=", a.Name); Assert.Null(a.NameEncoding); From 15df36cd8a07b1de551fea66b3466219fd26238b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:34:14 +0200 Subject: [PATCH 26/47] Add test case --- .../System.Net.Mail/tests/Functional/ContentTypeTest.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs b/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs index f8494067cfa4dd..6be51d3c7844ae 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs @@ -177,6 +177,8 @@ public static void MediaType_Roundtrip() [InlineData("=?utf-8?B?Zm9v?=\"; filename=\"foo.txt")] // A would-be encoded-word containing an unescaped quote in the data section. [InlineData("=?utf-8?Q?foo\"; filename=\"foo.txt?=")] + // Like the above, but with no whitespace after ';' (still should not be treated as safe pre-encoded input). + [InlineData("=?utf-8?Q?foo\";filename=\"foo.txt?=")] [InlineData("=?utf-8?Q?foo\"bar?=")] // Trailing content after the closing "?=" with no folding whitespace. [InlineData("=?utf-8?B?Zm9v?=trailing")] From 8febeb293415c34adc23d608f1c69f8a47fde245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:08:57 +0200 Subject: [PATCH 27/47] Remove wrapping encoded word in quoted string (RFC 2047 prohibits this) --- .../System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs | 2 +- .../System.Net.Mail/src/System/Net/Mime/ContentType.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs index d9035b5c8b1a9e..6ea5dd4f88787c 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs @@ -242,7 +242,7 @@ private static void EncodeToBuffer(string value, StringBuilder builder, bool all if (encoding != null) // Manually encoded elsewhere, pass through { - builder.Append('"').Append(value).Append('"'); + builder.Append(value); } else if ((allowUnicode && !MailBnfHelper.HasCROrLF(value)) // Unicode without CL or LF's || MimeBasePart.IsAscii(value, false)) // Ascii diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs index 4aadc295e57ef6..e42b93c94c9bc3 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs @@ -210,7 +210,7 @@ private static void EncodeToBuffer(string value, StringBuilder builder, bool all if (encoding != null) // Manually encoded elsewhere, pass through { - builder.Append('\"').Append(value).Append('"'); + builder.Append(value); } else if ((allowUnicode && !MailBnfHelper.HasCROrLF(value)) // Unicode without CL or LF's || MimeBasePart.IsAscii(value, false)) // Ascii From 007fa8caab3fbfcb625c7341651c462f60fb449a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:34:39 +0200 Subject: [PATCH 28/47] Fix assert expect/actual param order. --- .../tests/Functional/AttachmentTest.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs index 9e5da2d63db8d0..22e6c3e478240e 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs @@ -123,47 +123,47 @@ public void NameParsingAndEncodingDetection_Basics() // smoke test a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachmentname?="); Assert.Equal("attachmentname", a.Name); - Assert.Equal(a.NameEncoding, Encoding.Latin1); + Assert.Equal(Encoding.Latin1, a.NameEncoding); // lower case charset a = new Attachment(new MemoryStream(), "=?iso-8859-1?Q?attachmentname?="); Assert.Equal("attachmentname", a.Name); - Assert.Equal(a.NameEncoding, Encoding.Latin1); + Assert.Equal(Encoding.Latin1, a.NameEncoding); // Q encoding a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20name?="); Assert.Equal("attachment name", a.Name); - Assert.Equal(a.NameEncoding, Encoding.Latin1); + Assert.Equal(Encoding.Latin1, a.NameEncoding); // Q encoding (lowercase) a = new Attachment(new MemoryStream(), "=?ISO-8859-1?q?attachment=20name?="); Assert.Equal("attachment name", a.Name); - Assert.Equal(a.NameEncoding, Encoding.Latin1); + Assert.Equal(Encoding.Latin1, a.NameEncoding); // B encoding a = new Attachment(new MemoryStream(), "=?ISO-8859-1?B?YXR0YWNobWVudCBuYW1l?="); Assert.Equal("attachment name", a.Name); - Assert.Equal(a.NameEncoding, Encoding.Latin1); + Assert.Equal(Encoding.Latin1, a.NameEncoding); // B encoding (lowercase) a = new Attachment(new MemoryStream(), "=?ISO-8859-1?b?YXR0YWNobWVudCBuYW1l?="); Assert.Equal("attachment name", a.Name); - Assert.Equal(a.NameEncoding, Encoding.Latin1); + Assert.Equal(Encoding.Latin1, a.NameEncoding); // space alternate a = new Attachment(new MemoryStream(), "=?ISO-8859-1?q?attachment_name?="); Assert.Equal("attachment name", a.Name); - Assert.Equal(a.NameEncoding, Encoding.Latin1); + Assert.Equal(Encoding.Latin1, a.NameEncoding); // question mark alternate a = new Attachment(new MemoryStream(), "=?ISO-8859-1?q?attachment=3Fname?="); Assert.Equal("attachment?name", a.Name); - Assert.Equal(a.NameEncoding, Encoding.Latin1); + Assert.Equal(Encoding.Latin1, a.NameEncoding); // underscore alternate a = new Attachment(new MemoryStream(), "=?ISO-8859-1?q?attachment=5Fname?="); Assert.Equal("attachment_name", a.Name); - Assert.Equal(a.NameEncoding, Encoding.Latin1); + Assert.Equal(Encoding.Latin1, a.NameEncoding); // multiple encoded-words a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?attachment=20?= =?ISO-8859-1?Q?name?="); From f3b391b362ebafbe96b6a5d3f08081415ae8f68f Mon Sep 17 00:00:00 2001 From: MichalZ <188900745+mrek-msft@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:02:38 +0200 Subject: [PATCH 29/47] Improving RFC2047 compliance Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs index b9b2323e31d105..baa66e16cc4c03 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs @@ -177,11 +177,13 @@ internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? val { whiteSpacesLength++; } - // CRLF NOT followed by at least one SP/HT - if (hasNewLine && whiteSpacesLength == 2) + + // Require at least one SP/HT between encoded-words (and if CRLF is present, it must be followed by SP/HT). + if (whiteSpacesLength == (hasNewLine ? 2 : 0)) { return (value, null); } + remainder = remainder.Slice(whiteSpacesLength); if (remainder.IsEmpty) { From f79bdfcb83220807e61f9853f72fb6c09ac4084d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:12:13 +0200 Subject: [PATCH 30/47] Wrap mime fields in quotes again despite its against RFC. --- .../System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs | 2 +- .../System.Net.Mail/src/System/Net/Mime/ContentType.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs index 6ea5dd4f88787c..d9035b5c8b1a9e 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs @@ -242,7 +242,7 @@ private static void EncodeToBuffer(string value, StringBuilder builder, bool all if (encoding != null) // Manually encoded elsewhere, pass through { - builder.Append(value); + builder.Append('"').Append(value).Append('"'); } else if ((allowUnicode && !MailBnfHelper.HasCROrLF(value)) // Unicode without CL or LF's || MimeBasePart.IsAscii(value, false)) // Ascii diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs index e42b93c94c9bc3..91cd93c612aeaa 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs @@ -210,7 +210,7 @@ private static void EncodeToBuffer(string value, StringBuilder builder, bool all if (encoding != null) // Manually encoded elsewhere, pass through { - builder.Append(value); + builder.Append('"').Append(value).Append('"'); } else if ((allowUnicode && !MailBnfHelper.HasCROrLF(value)) // Unicode without CL or LF's || MimeBasePart.IsAscii(value, false)) // Ascii From 5bf01f25a7f774b3c3ed9647708e60df39908075 Mon Sep 17 00:00:00 2001 From: MichalZ <188900745+mrek-msft@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:12:38 +0200 Subject: [PATCH 31/47] Add test case Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../System.Net.Mail/tests/Functional/ContentTypeTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs b/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs index 6be51d3c7844ae..c4cabd931ddfc2 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs @@ -175,7 +175,7 @@ public static void MediaType_Roundtrip() // alone must not put the value on the pass-through-unquoted path. [InlineData("=?utf-8?Q?foo?=?bar\"; filename=\"foo.txt")] [InlineData("=?utf-8?B?Zm9v?=\"; filename=\"foo.txt")] - // A would-be encoded-word containing an unescaped quote in the data section. + [InlineData("=?utf-8?Q?foo\\\\?=")] [InlineData("=?utf-8?Q?foo\"; filename=\"foo.txt?=")] // Like the above, but with no whitespace after ';' (still should not be treated as safe pre-encoded input). [InlineData("=?utf-8?Q?foo\";filename=\"foo.txt?=")] From 4ccac8793d6b8a15a4204d9b1d925892d42e3630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:20:32 +0200 Subject: [PATCH 32/47] Fix handling " in encoded word for MIME headers --- .../src/System/Net/Mail/Attachment.cs | 2 +- .../src/System/Net/Mail/MailPriority.cs | 2 +- .../src/System/Net/Mime/ContentDisposition.cs | 2 +- .../src/System/Net/Mime/ContentType.cs | 4 ++-- .../src/System/Net/Mime/MimeBasePart.cs | 6 ++++- .../tests/Unit/ByteEncodingTest.cs | 6 ++--- .../tests/Unit/QuotedPrintableStreamTest.cs | 22 +++++++++++++++++++ 7 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs b/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs index 1d0edf628f3b15..a643e2f061f2fd 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs @@ -360,7 +360,7 @@ public string? Name } set { - (string decodedValue, Encoding? nameEncoding) = MimeBasePart.DecodeHeaderValue(value); + (string decodedValue, Encoding? nameEncoding) = MimeBasePart.DecodeHeaderValue(value, false); if (nameEncoding != null) { _nameEncoding = nameEncoding; diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mail/MailPriority.cs b/src/libraries/System.Net.Mail/src/System/Net/Mail/MailPriority.cs index bd7f7e7d252148..557e36e3fe87fb 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mail/MailPriority.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mail/MailPriority.cs @@ -138,7 +138,7 @@ internal string? Subject { // extract the encoding from =?encoding?BorQ?blablalba?= and store the // decoded value; we'll re-encode before sending - (string decodedValue, Encoding? inputEncoding) = MimeBasePart.DecodeHeaderValue(value); + (string decodedValue, Encoding? inputEncoding) = MimeBasePart.DecodeHeaderValue(value, true); if (inputEncoding != null) { value = decodedValue; diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs index d9035b5c8b1a9e..7ca521d80dc0ca 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs @@ -226,7 +226,7 @@ private static void EncodeToBuffer(string value, StringBuilder builder, bool all Encoding? encoding; try { - (_, encoding) = MimeBasePart.DecodeHeaderValue(value); + (_, encoding) = MimeBasePart.DecodeHeaderValue(value, false); } catch (FormatException) { diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs index 91cd93c612aeaa..6963ed2e32f587 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentType.cs @@ -116,7 +116,7 @@ public string Name get { string? value = Parameters["name"]; - (string decodedValue, Encoding? nameEncoding) = MimeBasePart.DecodeHeaderValue(value); + (string decodedValue, Encoding? nameEncoding) = MimeBasePart.DecodeHeaderValue(value, false); if (nameEncoding != null) { value = decodedValue; @@ -194,7 +194,7 @@ private static void EncodeToBuffer(string value, StringBuilder builder, bool all Encoding? encoding; try { - (_, encoding) = MimeBasePart.DecodeHeaderValue(value); + (_, encoding) = MimeBasePart.DecodeHeaderValue(value, false); } catch (FormatException) { diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs index baa66e16cc4c03..606ab6cbedf893 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs @@ -64,7 +64,7 @@ internal static string EncodeHeaderValue(string value, Encoding? encoding, bool // The entire value must consist of one or more well-formed RFC 2047 encoded-words // separated by linear whitespace (folding); otherwise the value is returned unchanged // with a null Encoding. - internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? value) + internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? value, bool allowQuoteAndEscape) { const int MaxEncodedWordLength = 75; @@ -138,6 +138,10 @@ internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? val { return (value, null); } + if (!allowQuoteAndEscape & data.ContainsAny('\\', '"')) + { + return (value, null); + } // For Q-encoding, every '=' must be followed by exactly two hex digits. // QEncodedStream.DecodeBytes validates the digits themselves, but a '=' diff --git a/src/libraries/System.Net.Mail/tests/Unit/ByteEncodingTest.cs b/src/libraries/System.Net.Mail/tests/Unit/ByteEncodingTest.cs index b5074052c53d6d..6a1103c3c7607a 100644 --- a/src/libraries/System.Net.Mail/tests/Unit/ByteEncodingTest.cs +++ b/src/libraries/System.Net.Mail/tests/Unit/ByteEncodingTest.cs @@ -22,7 +22,7 @@ public void EncodeHeader_WithNoUnicode_ShouldNotEncode(string testHeader) Assert.InRange((byte)c, 0, 128); } - Assert.Equal(testHeader, MimeBasePart.DecodeHeaderValue(result).Value); + Assert.Equal(testHeader, MimeBasePart.DecodeHeaderValue(result, true).Value); } [Theory] @@ -41,7 +41,7 @@ public void EncoderAndDecoder_ShouldEncodeAndDecode(string testHeader, int expec Assert.InRange(foldedHeader.Length, 0, 76); } - Assert.Equal(testHeader, MimeBasePart.DecodeHeaderValue(result).Value); + Assert.Equal(testHeader, MimeBasePart.DecodeHeaderValue(result, true).Value); } [Theory] @@ -59,7 +59,7 @@ public void EncoderAndDecoder_WithQEncodedString_AndNoUnicode_AndShortHeader_Sho Assert.InRange(foldedHeader.Length, 0, 76); } - Assert.Equal(testHeader, MimeBasePart.DecodeHeaderValue(result).Value); + Assert.Equal(testHeader, MimeBasePart.DecodeHeaderValue(result, true).Value); } [Fact] diff --git a/src/libraries/System.Net.Mail/tests/Unit/QuotedPrintableStreamTest.cs b/src/libraries/System.Net.Mail/tests/Unit/QuotedPrintableStreamTest.cs index e53fbb76c4e9a6..dd63ae1eb27e58 100644 --- a/src/libraries/System.Net.Mail/tests/Unit/QuotedPrintableStreamTest.cs +++ b/src/libraries/System.Net.Mail/tests/Unit/QuotedPrintableStreamTest.cs @@ -92,5 +92,27 @@ public void QuotedPrintableStream_EmbededCRAndLFSpltBetweenWrites_EncodeCrlfFlag string results = Encoding.ASCII.GetString(bytesRead, 0, bytesReadCount); Assert.Equal(ExpectedOutput, results); } + + [Theory] + [InlineData(false, "foo", "foo")] + [InlineData(false, "fo=o", "\"fo=o\"")] + [InlineData(false, "fo\"o", "\"fo\\\"o\"")] + [InlineData(false, "=", "\"=\"")] + [InlineData(false, "", "\"\"")] + [InlineData(false, " ", "\" \"")] + public void GetTokenOrQuotedString(bool allowUnicode, string input, string expected) + { + StringBuilder sb = new(); + MailBnfHelper.GetTokenOrQuotedString(input, sb, allowUnicode); + Assert.Equal(expected, sb.ToString()); + } + + [Theory] + [InlineData(false, "ň")] + public void GetTokenOrQuotedString_Throws(bool allowUnicode, string input) + { + StringBuilder sb = new(); + Assert.Throws(() => MailBnfHelper.GetTokenOrQuotedString(input, sb, allowUnicode)); + } } } From 132dc9ff13dd58fe03f53cea0fc1e629bff6dd73 Mon Sep 17 00:00:00 2001 From: MichalZ <188900745+mrek-msft@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:15:40 +0200 Subject: [PATCH 33/47] Fix logical operator Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs index 606ab6cbedf893..a3097ef88b88e5 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs @@ -138,7 +138,7 @@ internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? val { return (value, null); } - if (!allowQuoteAndEscape & data.ContainsAny('\\', '"')) + if (!allowQuoteAndEscape && data.ContainsAny('\\', '"')) { return (value, null); } From 1c7cd96ffe5680b3d1184590eb1add6a709a31b0 Mon Sep 17 00:00:00 2001 From: MichalZ <188900745+mrek-msft@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:16:00 +0200 Subject: [PATCH 34/47] Enhance comments Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs index a3097ef88b88e5..b992472f514173 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs @@ -63,7 +63,8 @@ internal static string EncodeHeaderValue(string value, Encoding? encoding, bool // // The entire value must consist of one or more well-formed RFC 2047 encoded-words // separated by linear whitespace (folding); otherwise the value is returned unchanged - // with a null Encoding. + // with a null Encoding. If the value is well-formed but cannot be decoded (unsupported charset, + // invalid Base64/Q encoding), this method may throw. internal static (string Value, Encoding? Encoding) DecodeHeaderValue(string? value, bool allowQuoteAndEscape) { const int MaxEncodedWordLength = 75; From 87e48743929bfcf18b85a7535a41c649fb5c0590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:16:34 +0200 Subject: [PATCH 35/47] Remove unused import --- .../System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs index 4e4179617ac822..628e46f25a0431 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/MimeBasePart.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Buffers; using System.Collections.Specialized; using System.Net.Mail; using System.Text; From c4f96f9b3bf3a1eceb74492545dd9d8814da703a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:51:17 +0200 Subject: [PATCH 36/47] Remove bad test, fix leaking disposable --- .../tests/Functional/AttachmentTest.cs | 42 +++++-------------- 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs index 22e6c3e478240e..c3ed509314a4c9 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs @@ -197,8 +197,6 @@ public void NameParsingAndEncodingDetection_Basics() [InlineData("=?ISO-8859-1?qb?foo?=")] // two letter encoding [InlineData("=?ISO-8859-1?Q?foo?")] // missing end = [InlineData("=?ISO-8859-1?Q?foo")] // missing whole end - [InlineData("=?ISO-8859-1?Q?foo_=?=")] // broken Q encoding, = at end - [InlineData("=?ISO-8859-1?Q?foo_=A?=")] // broken Q encoding, single hex digit [InlineData("=?ISO-8859-1?Q?foo_?A?=")] // broken Q encoding, ? in text [InlineData("=?ISO-8859-1?Q?foo bar?=")] // broken Q encoding, space in text [InlineData("=?ISO-8859-1?Q?foo\tbar?=")] // broken Q encoding, tab in text @@ -240,38 +238,18 @@ public void NameParsingAndEncodingDetection_BadInputs(string attachmentName) public void NameParsingAndEncodingDetection_BadInputsThrowing() { // Bad Base64 encoding - Assert.Throws(() => new Attachment(new MemoryStream(), "=?ISO-8859-1?B?YXR0YWNobWV@@@@@@@@udCBuYW1l?=")); + Assert.Throws(() => + { + using MemoryStream ms = new(); + new Attachment(ms, "=?ISO-8859-1?B?YXR0YWNobWV@@@@@@@@udCBuYW1l?="); + }); // broken Q encoding, invalid hex value - Assert.Throws(() => new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?foo_=XY?=")); - - // non existing encoding - Assert.Throws(() => new Attachment(new MemoryStream(), "=?XXXX?Q?foo?=")); - } - - [Fact] - public void NameParsingAndEncodingDetection_LengthValidation() - { - // 75 characters => decoded - Attachment a; - a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_fo?="); - Assert.Equal("foo bar foo bar foo bar foo bar foo bar foo bar foo bar fo", a.Name); - Assert.Equal(Encoding.Latin1, a.NameEncoding); - - // 76 characters => RFC 2047 violation, not processed as encoded word - a = new Attachment(new MemoryStream(), "=?ISO-8859-1?Q?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?="); - Assert.Equal("=?ISO-8859-1?Q?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?=", a.Name); - Assert.Null(a.NameEncoding); - - // 76 characters, different part of encoded word violating - a = new Attachment(new MemoryStream(), "=?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?ISO-8859-1?Q?="); - Assert.Equal("=?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?ISO-8859-1?Q?=", a.Name); - Assert.Null(a.NameEncoding); - - // 76 characters, different part of encoded word violating - a = new Attachment(new MemoryStream(), "=?Q?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?ISO-8859-1?="); - Assert.Equal("=?Q?foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo_bar_foo?ISO-8859-1?=", a.Name); - Assert.Null(a.NameEncoding); + Assert.Throws(() => + { + using MemoryStream ms = new(); + new Attachment(ms, "=?ISO-8859-1?Q?foo_=XY?="); + }); } [Fact] From 0929cd42812d9af9a4e61959ce2e66d86d9f6236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:01:04 +0200 Subject: [PATCH 37/47] Add CRLF validations --- .../System.Net.Mail/src/Resources/Strings.resx | 8 +++++++- .../src/System/Net/Mail/Attachment.cs | 17 +++++++++++++++-- .../src/System/Net/Mime/ContentDisposition.cs | 10 +++++++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/libraries/System.Net.Mail/src/Resources/Strings.resx b/src/libraries/System.Net.Mail/src/Resources/Strings.resx index 1cb6a74ab19722..c2f9f57033cd43 100644 --- a/src/libraries/System.Net.Mail/src/Resources/Strings.resx +++ b/src/libraries/System.Net.Mail/src/Resources/Strings.resx @@ -155,7 +155,13 @@ The specified port is invalid. The port must be greater than 0. - The ContentID cannot contain a '<' or '>' character. + The ContentID cannot contain a '<', '>', CR, or LF character. + + + The value cannot contain a CR or LF character. + + + The value cannot contain a CR, LF or non-ASCII character. The server response was: {0} diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs b/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs index 2dbb15891d013e..82a03596c3a99e 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs @@ -219,7 +219,7 @@ public string ContentId } else { - if (value.AsSpan().ContainsAny('<', '>')) // invalid chars + if (value.AsSpan().ContainsAny('<', '>') || value.AsSpan().ContainsAny('\r', '\n')) { throw new ArgumentException(SR.MailHeaderInvalidCID, nameof(value)); } @@ -268,7 +268,20 @@ internal Uri? ContentLocation set { - _part.ContentLocation = value == null ? null : value.IsAbsoluteUri ? value.AbsoluteUri : value.OriginalString; + if (value == null) + { + _part.ContentLocation = null; + return; + } + + string valueCandidate = value.IsAbsoluteUri ? value.AbsoluteUri : value.OriginalString; + + if (MailBnfHelper.HasCROrLF(valueCandidate)) + { + throw new ArgumentException(SR.MailHeaderInvalid, nameof(value)); + } + + _part.ContentLocation = valueCandidate; } } diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs index 8f33592b9ffaf7..6b0a8ef90bef0f 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs @@ -79,8 +79,16 @@ public string DispositionType { ArgumentException.ThrowIfNullOrEmpty(value); + if (!MimeBasePart.IsAscii(value, false)) + { + throw new ArgumentException(SR.DispositionTypeInvalid, nameof(value)); + } + + StringBuilder sb = new(); + MailBnfHelper.GetTokenOrQuotedString(value, sb, false); + _isChanged = true; - _dispositionType = value; + _dispositionType = sb.ToString(); } } From bff81590b9f3f6370403c389cf42537dcd61d52d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:36:10 +0200 Subject: [PATCH 38/47] Cover ContentId by more tests. --- .../tests/Functional/AttachmentTest.cs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs index c3ed509314a4c9..ac54f42b951f5d 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/AttachmentTest.cs @@ -277,5 +277,61 @@ public void TransferEncodingTest() Attachment attach = Attachment.CreateAttachmentFromString("test", "attachment-name"); Assert.Equal(TransferEncoding.QuotedPrintable, attach.TransferEncoding); } + + // For backward compatibility, we allows some non-compliant with RFC2045 + [Theory] + [InlineData("a")] + [InlineData("fň")] + [InlineData("foo-_:;()[]bar")] + [InlineData("foo\"bar")] + [InlineData("foo=bar")] + [InlineData("foo@bar")] + public void ContentId_Validation_Valid(string contentId) + { + Attachment attach = Attachment.CreateAttachmentFromString("test", "attachment-name"); + attach.ContentId = contentId; + Assert.Equal(contentId, attach.ContentId); + } + + [Theory] + [InlineData("foobar")] + [InlineData("")] + [InlineData("foo\rbar")] + [InlineData("foo\nbar")] + [InlineData("foo\r\nbar")] + public void ContentId_Validation_Invalid(string contentId) + { + Attachment attach = Attachment.CreateAttachmentFromString("test", "attachment-name"); + Assert.Throws(() => + { + attach.ContentId = contentId; + }); + } + + [Fact] + public void ContentId_Validation_Empty() + { + // toggles between spcified and random generated content id + + Attachment attach = Attachment.CreateAttachmentFromString("test", "attachment-name"); + + attach.ContentId = "foo"; + Assert.Equal("foo", attach.ContentId); + + attach.ContentId = ""; + var guid1 = attach.ContentId; + Guid.ParseExact(guid1, "D"); + + attach.ContentId = "bar"; + Assert.Equal("bar", attach.ContentId); + + attach.ContentId = null; + var guid2 = attach.ContentId; + Guid.ParseExact(guid2, "D"); + + Assert.NotEqual(guid1, guid2); + } + } } From d676b70e2c19c02275a65833060e37068f21a966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:20:57 +0200 Subject: [PATCH 39/47] Cover AlternateView.BaseUri (aka ContentLocation) by more tests. --- .../tests/Functional/AlternateViewTest.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/libraries/System.Net.Mail/tests/Functional/AlternateViewTest.cs b/src/libraries/System.Net.Mail/tests/Functional/AlternateViewTest.cs index b0b515c7ea610d..ef83633de26a49 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/AlternateViewTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/AlternateViewTest.cs @@ -72,5 +72,38 @@ public void CreateAlternateViewFromStringEncodingNull() AlternateView av = AlternateView.CreateAlternateViewFromString("

test message

", null, "text/html"); Assert.Equal(Text.Encoding.ASCII.BodyName, av.ContentType.CharSet); } + + [Theory] + [InlineData("", UriKind.Relative)] + [InlineData("aaaa", UriKind.Relative)] + [InlineData("aaaň", UriKind.Relative)] + [InlineData("aa-aa", UriKind.Relative)] + [InlineData("aa_aa", UriKind.Relative)] + [InlineData("aa\"aa", UriKind.Relative)] + [InlineData("aa\\aa", UriKind.Relative)] + [InlineData("aa/aa", UriKind.Relative)] + [InlineData("aa@aa", UriKind.Relative)] + [InlineData("aa; bb=cc", UriKind.Relative)] + [InlineData("C:\\foo.txt", UriKind.Absolute)] + public void ContentLocation_Validation_Valid(string uri, UriKind kind) + { + AlternateView av = AlternateView.CreateAlternateViewFromString("foo"); + av.BaseUri = new Uri(uri, kind); + } + + [Theory] + [InlineData("aa\raa", UriKind.Relative)] + [InlineData("aa\naa", UriKind.Relative)] + [InlineData("aa\r\naa", UriKind.Relative)] + [InlineData("aa\r\n aa", UriKind.Relative)] + public void ContentLocation_Validation_Invalid(string uri, UriKind kind) + { + AlternateView av = AlternateView.CreateAlternateViewFromString("foo"); + Uri u = new Uri(uri, kind); + Assert.Throws(() => + { + av.BaseUri = u; + }); + } } } From 5d901b36f880acec411a3b24642f7ec8bfa2472f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:25:25 +0200 Subject: [PATCH 40/47] Improve backward compatibility of ContentDisposition validation --- .../src/Resources/Strings.resx | 2 +- .../src/System/Net/Mime/ContentDisposition.cs | 9 +++--- .../Functional/ContentDispositionTest.cs | 29 +++++++++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/libraries/System.Net.Mail/src/Resources/Strings.resx b/src/libraries/System.Net.Mail/src/Resources/Strings.resx index c2f9f57033cd43..caa04af17088b9 100644 --- a/src/libraries/System.Net.Mail/src/Resources/Strings.resx +++ b/src/libraries/System.Net.Mail/src/Resources/Strings.resx @@ -161,7 +161,7 @@ The value cannot contain a CR or LF character.
- The value cannot contain a CR, LF or non-ASCII character. + The value cannot contain disallowed character. The server response was: {0} diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs index 6b0a8ef90bef0f..0f8fa15eb7c82a 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mime/ContentDisposition.cs @@ -79,16 +79,15 @@ public string DispositionType { ArgumentException.ThrowIfNullOrEmpty(value); - if (!MimeBasePart.IsAscii(value, false)) + // RFC allows IANA registered token or X- extension token. We do not validate extensively. + // For backward compatibility we restrict only chars which can interfere with other part of syntax. + if (!MimeBasePart.IsAscii(value, false) || value.AsSpan().ContainsAny(';', '"')) { throw new ArgumentException(SR.DispositionTypeInvalid, nameof(value)); } - StringBuilder sb = new(); - MailBnfHelper.GetTokenOrQuotedString(value, sb, false); - _isChanged = true; - _dispositionType = sb.ToString(); + _dispositionType = value; } } diff --git a/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs b/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs index 1642d931aa6d77..31bbc8caa0fdda 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs @@ -389,5 +389,34 @@ public static void FileName_Set_EncodedWord_RoundTripsUnchanged(string encoded) var reparsed = new ContentDisposition(serialized); Assert.Equal(encoded, reparsed.FileName); } + + [Theory] + [InlineData("inline")] + [InlineData("attachment")] + [InlineData("foo")] + [InlineData("foo_bar")] + [InlineData("foo@bar")] + public void ContentDisposition_Type_Validation_Valid(string dispositionType) + { + var cd = new ContentDisposition(); + cd.DispositionType = dispositionType; + Assert.Equal(dispositionType, cd.DispositionType); + } + + [Theory] + [InlineData("foo\rbar")] + [InlineData("foo\nbar")] + [InlineData("foo\r\nbar")] + [InlineData("foo; abc=bar")] // You can pass this to ctor, but not to property + [InlineData("foo\"bar")] // You can pass this to ctor, but not to property + [InlineData("fň")] + public void ContentDisposition_Type_Validation_Invalid(string dispositionType) + { + var cd = new ContentDisposition(); + Assert.Throws(() => + { + cd.DispositionType = dispositionType; + }); + } } } From b962c673e629faf21bc44cd42002737863b1bb4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:55:02 +0200 Subject: [PATCH 41/47] Consolidate similar tests --- .../Functional/ContentDispositionTest.cs | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs b/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs index 31bbc8caa0fdda..c2c5760d1fee60 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs @@ -357,37 +357,28 @@ public void CultureSensitiveSetDateViaProperty_ShouldPersistToProprtyAndParamete } [Theory] + [InlineData("=?utf-8?B?SGVsbG8=?=")] + [InlineData("=?utf-8?Q?Hello=20world?=")] + [InlineData("=?utf-8?B?SGVsbG8=?=\r\n =?utf-8?B?d29ybGQ=?=")] [InlineData("=?utf-8?Q?foo?=?bar\"; filename=\"foo.txt")] [InlineData("=?utf-8?B?Zm9v?=\"; filename=\"foo.txt")] [InlineData("=?utf-8?Q?foo\"; filename=\"foo.txt?=")] [InlineData("=?utf-8?Q?foo\"bar?=")] [InlineData("=?utf-8?B?Zm9v?=trailing")] - public static void FileName_Set_InjectionPayload_IsEscapedAndNotInjected(string injection) - { - var cd = new ContentDisposition(); - cd.FileName = injection; - - Assert.Equal(injection, cd.FileName); - - var reparsed = new ContentDisposition(cd.ToString()); - Assert.Equal(injection, reparsed.FileName); - Assert.Equal(cd.Parameters.Count, reparsed.Parameters.Count); - } - - [Theory] - [InlineData("=?utf-8?B?SGVsbG8=?=")] - [InlineData("=?utf-8?Q?Hello=20world?=")] - [InlineData("=?utf-8?B?SGVsbG8=?=\r\n =?utf-8?B?d29ybGQ=?=")] - public static void FileName_Set_EncodedWord_RoundTripsUnchanged(string encoded) + public static void FileName_ParsingSerialization_Roundtrip(string encoded) { var cd = new ContentDisposition(); cd.FileName = encoded; - string serialized = cd.ToString(); - Assert.Contains(encoded, serialized); + // Property roundtrip + Assert.Equal(encoded, cd.FileName); + // Serialize/deserialize roundtrip + string serialized = cd.ToString(); var reparsed = new ContentDisposition(serialized); Assert.Equal(encoded, reparsed.FileName); + + Assert.Equal(cd.Parameters.Count, reparsed.Parameters.Count); } [Theory] From 938caf5c19f5f0759f98090ef84878a1cfe9f7ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:07:43 +0200 Subject: [PATCH 42/47] Improve test comment --- .../tests/Functional/ContentDispositionTest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs b/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs index c2c5760d1fee60..cc1b991d37ebc2 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/ContentDispositionTest.cs @@ -398,8 +398,8 @@ public void ContentDisposition_Type_Validation_Valid(string dispositionType) [InlineData("foo\rbar")] [InlineData("foo\nbar")] [InlineData("foo\r\nbar")] - [InlineData("foo; abc=bar")] // You can pass this to ctor, but not to property - [InlineData("foo\"bar")] // You can pass this to ctor, but not to property + [InlineData("foo; abc=bar")] // You can pass this to ctor, but not to property of already parsed disposition + [InlineData("foo\"bar")] [InlineData("fň")] public void ContentDisposition_Type_Validation_Invalid(string dispositionType) { From 4d5a14e85c4eb90e842751b674838b252d3c1af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:15:00 +0200 Subject: [PATCH 43/47] Unify similar tests and improve comments clarity --- .../tests/Functional/ContentTypeTest.cs | 49 +++++-------------- 1 file changed, 13 insertions(+), 36 deletions(-) diff --git a/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs b/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs index 0a29e12a39760e..a8109ff79261ea 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs @@ -206,55 +206,32 @@ public static void MediaType_Roundtrip() } [Theory] - // A valid RFC 2047 encoded-word followed by attacker-controlled junk: the prefix - // alone must not put the value on the pass-through-unquoted path. + [InlineData("=?utf-8?B?SGVsbG8=?=")] + [InlineData("=?utf-8?Q?Hello=20world?=")] + [InlineData("=?utf-8?B?SGVsbG8=?=\r\n =?utf-8?B?d29ybGQ=?=")] [InlineData("=?utf-8?Q?foo?=?bar\"; filename=\"foo.txt")] [InlineData("=?utf-8?B?Zm9v?=\"; filename=\"foo.txt")] [InlineData("=?utf-8?Q?foo\\\\?=")] - [InlineData("=?utf-8?Q?foo\"; filename=\"foo.txt?=")] - // Like the above, but with no whitespace after ';' (still should not be treated as safe pre-encoded input). [InlineData("=?utf-8?Q?foo\";filename=\"foo.txt?=")] + [InlineData("=?utf-8?Q?foo\"; filename=\"foo.txt?=")] [InlineData("=?utf-8?Q?foo\"bar?=")] - // Trailing content after the closing "?=" with no folding whitespace. [InlineData("=?utf-8?B?Zm9v?=trailing")] - public static void Name_Set_InjectionPayload_IsEscapedAndNotInjected(string injection) - { - var ct = new ContentType("application/octet-stream"); - ct.Name = injection; - - // The "name" parameter must round-trip exactly through the Parameters dictionary - // and no extra parameters may appear in the serialized header. - Assert.Equal(injection, ct.Parameters["name"]); - Assert.Equal(1, ct.Parameters.Count); - - // Re-parsing the serialized form must recover the original name and produce - // exactly one parameter (i.e. no extra parameter was injected). - var reparsed = new ContentType(ct.ToString()); - Assert.Equal(1, reparsed.Parameters.Count); - Assert.Equal(injection, reparsed.Parameters["name"]); - } - - [Theory] - // Well-formed encoded-words must continue to be treated as already-encoded and pass - // through unchanged (the existing pre-encoded round-trip contract). - [InlineData("=?utf-8?B?SGVsbG8=?=")] - [InlineData("=?utf-8?Q?Hello=20world?=")] - [InlineData("=?utf-8?B?SGVsbG8=?=\r\n =?utf-8?B?d29ybGQ=?=")] - public static void Name_Set_EncodedWord_RoundTripsUnchanged(string encoded) + public static void Name_MultiWordAndEscaping(string input) { var ct = new ContentType("application/octet-stream"); - ct.Name = encoded; + ct.Name = input; - Assert.Equal(encoded, ct.Parameters["name"]); + // Property roundtrip + Assert.Equal(input, ct.Name); + Assert.Equal(input, ct.Parameters["name"]); Assert.Equal(1, ct.Parameters.Count); - // ToString wraps the encoded-word in quotes but must not escape its contents. - string serialized = ct.ToString(); - Assert.Contains(encoded, serialized); - + // Serialize/parse roundtrip + var serialized = ct.ToString(); var reparsed = new ContentType(serialized); Assert.Equal(1, reparsed.Parameters.Count); - Assert.Equal(encoded, reparsed.Parameters["name"]); + Assert.Equal(input, reparsed.Name); + Assert.Equal(input, reparsed.Parameters["name"]); } } } From 78df3cc00a24c4e8f3a2b51d3fe5356119f9c40c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:09:14 +0200 Subject: [PATCH 44/47] Remove property checked by other test --- .../System.Net.Mail/tests/Functional/ContentTypeTest.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs b/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs index a8109ff79261ea..ff6ef6253c9f9b 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/ContentTypeTest.cs @@ -222,7 +222,6 @@ public static void Name_MultiWordAndEscaping(string input) ct.Name = input; // Property roundtrip - Assert.Equal(input, ct.Name); Assert.Equal(input, ct.Parameters["name"]); Assert.Equal(1, ct.Parameters.Count); @@ -230,7 +229,6 @@ public static void Name_MultiWordAndEscaping(string input) var serialized = ct.ToString(); var reparsed = new ContentType(serialized); Assert.Equal(1, reparsed.Parameters.Count); - Assert.Equal(input, reparsed.Name); Assert.Equal(input, reparsed.Parameters["name"]); } } From bc42b6e3c96327c3800a1607ae95ef1112a689fc Mon Sep 17 00:00:00 2001 From: MichalZ <188900745+mrek-msft@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:04:19 +0200 Subject: [PATCH 45/47] Better ContainsAny Co-authored-by: Miha Zupan --- src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs b/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs index 82a03596c3a99e..a60a64ee455831 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs @@ -219,7 +219,7 @@ public string ContentId } else { - if (value.AsSpan().ContainsAny('<', '>') || value.AsSpan().ContainsAny('\r', '\n')) + if (value.AsSpan().ContainsAny("<>\r\n")) { throw new ArgumentException(SR.MailHeaderInvalidCID, nameof(value)); } From 50847dfbafaa041ceefb516bc3790d7bd56881a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C5=BD=C5=AFrek?= <188900745+mrek-msft@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:31:05 +0200 Subject: [PATCH 46/47] Better ContainsAny --- src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs b/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs index 82a03596c3a99e..a60a64ee455831 100644 --- a/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs +++ b/src/libraries/System.Net.Mail/src/System/Net/Mail/Attachment.cs @@ -219,7 +219,7 @@ public string ContentId } else { - if (value.AsSpan().ContainsAny('<', '>') || value.AsSpan().ContainsAny('\r', '\n')) + if (value.AsSpan().ContainsAny("<>\r\n")) { throw new ArgumentException(SR.MailHeaderInvalidCID, nameof(value)); } From 5072de22271d7163738027e6486e8193109493ea Mon Sep 17 00:00:00 2001 From: MichalZ <188900745+mrek-msft@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:16:37 +0200 Subject: [PATCH 47/47] Improve exception message Co-authored-by: Miha Zupan --- src/libraries/System.Net.Mail/src/Resources/Strings.resx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Mail/src/Resources/Strings.resx b/src/libraries/System.Net.Mail/src/Resources/Strings.resx index caa04af17088b9..6fcaf3d71de1c4 100644 --- a/src/libraries/System.Net.Mail/src/Resources/Strings.resx +++ b/src/libraries/System.Net.Mail/src/Resources/Strings.resx @@ -161,7 +161,7 @@ The value cannot contain a CR or LF character. - The value cannot contain disallowed character. + The value cannot contain disallowed characters. The server response was: {0}