From 39a6d8fb2ec5479eb72e3f29eb76fa512749e534 Mon Sep 17 00:00:00 2001 From: Arthur Albuquerque Date: Sun, 7 Jun 2026 17:26:10 -0300 Subject: [PATCH] fix: assemble RFC 2231 multi-segment filename params with non-UTF-8 charsets Go's mime.ParseMediaType only supports UTF-8 and US-ASCII in RFC 2231 charset-encoded continuation parameters. When a Content-Disposition header uses a non-ASCII charset (e.g. EUC-KR) split across multiple filename*N*= segments, stdlib discards all but the last successfully decoded segment, producing a truncated filename. Add assembleRFC2231Params in the mediatype package to detect continuation parameters (name*N*= / name*N=), sort by segment number, concatenate the percent-decoded bytes, convert from the declared charset to UTF-8 using enmime's existing coding package, and replace the N-segment params with a single RFC 2231 UTF-8 encoded parameter before passing to mime.ParseMediaType. Fixes #109 --- mediatype/mediatype.go | 153 ++++++++++++++++++++++++- mediatype/mediatype_test.go | 12 ++ part_test.go | 23 ++++ testdata/parts/long-filename-euckr.raw | 21 ++++ 4 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 testdata/parts/long-filename-euckr.raw diff --git a/mediatype/mediatype.go b/mediatype/mediatype.go index adc8cbf..ad62b40 100644 --- a/mediatype/mediatype.go +++ b/mediatype/mediatype.go @@ -3,7 +3,10 @@ package mediatype import ( "fmt" "mime" + "net/url" + "regexp" "slices" + "strconv" "strings" _utf8 "unicode/utf8" @@ -48,9 +51,157 @@ func Parse(ctype string) (mtype string, params map[string]string, invalidParams } // ParseWithOptions parses media-type with additional options controlling the parsing behavior. +// rfc2231SegmentRe matches RFC 2231 continuation parameter names such as "filename*0*" or "filename*1". +var rfc2231SegmentRe = regexp.MustCompile(`(?i)^([a-zA-Z0-9!#$&\-^_.+]+)\*(\d+)(\*?)$`) + +// assembleRFC2231Params detects RFC 2231 multi-segment continuation parameters (paramname*N*= or +// paramname*N=) in a media type or content-disposition string, assembles their values in segment +// order, applies charset conversion, and returns a new string where the continuation parameters +// are replaced by a single decoded parameter. This is needed because Go's standard +// mime.ParseMediaType only supports UTF-8 and US-ASCII in RFC 2231 charset-encoded parameters. +func assembleRFC2231Params(s string) string { + parts := stringutil.SplitUnquoted(s, ';', '"') + if len(parts) < 2 { + return s + } + + type segment struct { + partIdx int + segNum int + encoded bool // trailing * means percent-encoded value + value string // raw value (may include charset'' prefix on segment 0) + } + + byBase := map[string][]segment{} + for i := 1; i < len(parts); i++ { + trimmed := strings.TrimSpace(parts[i]) + eqIdx := strings.IndexByte(trimmed, '=') + if eqIdx < 0 { + continue + } + paramName := strings.TrimSpace(trimmed[:eqIdx]) + value := trimmed[eqIdx+1:] + + m := rfc2231SegmentRe.FindStringSubmatch(paramName) + if m == nil { + continue + } + baseName := strings.ToLower(m[1]) + segNum, _ := strconv.Atoi(m[2]) + encoded := m[3] == "*" + + byBase[baseName] = append(byBase[baseName], segment{ + partIdx: i, + segNum: segNum, + encoded: encoded, + value: value, + }) + } + + if len(byBase) == 0 { + return s + } + + removals := map[int]bool{} + var additions []string + + for baseName, segs := range byBase { + slices.SortFunc(segs, func(a, b segment) int { + return a.segNum - b.segNum + }) + + charset := "us-ascii" + var rawBytes []byte + failed := false + + for i, seg := range segs { + val := seg.value + if i == 0 { + // Segment 0 may carry a charset''value prefix per RFC 2231. + if before, after, found := strings.Cut(val, "''"); found { + charset = before + val = after + } + } + if seg.encoded { + decoded, err := url.PathUnescape(val) + if err != nil { + failed = true + break + } + rawBytes = append(rawBytes, decoded...) + } else { + rawBytes = append(rawBytes, val...) + } + } + + if failed || rawBytes == nil { + continue + } + + // Skip single-segment ASCII/UTF-8 params; stdlib handles those correctly. + charsetLower := strings.ToLower(charset) + isASCIILike := charsetLower == "us-ascii" || charsetLower == "ascii" || + charsetLower == "utf-8" || charsetLower == "utf8" + if isASCIILike && len(segs) == 1 { + continue + } + + utf8Val, err := coding.ConvertToUTF8String(charset, rawBytes) + if err != nil { + continue + } + + for _, seg := range segs { + removals[seg.partIdx] = true + } + // Produce a single-segment RFC 2231 parameter with UTF-8 charset so that + // Go's mime.ParseMediaType can decode it without further transformation. + // Using the *=utf-8''... form avoids non-ASCII bytes in the raw string, + // which would otherwise be re-encoded to RFC 2047 by fixUnquotedSpecials. + additions = append(additions, " "+baseName+"*=utf-8''"+rfc2231PercentEncode(utf8Val)) + } + + if len(removals) == 0 { + return s + } + + var result strings.Builder + result.WriteString(parts[0]) + for i := 1; i < len(parts); i++ { + if removals[i] { + continue + } + result.WriteByte(';') + result.WriteString(parts[i]) + } + for _, add := range additions { + result.WriteByte(';') + result.WriteString(add) + } + return result.String() +} + +// rfc2231PercentEncode percent-encodes a UTF-8 string for use in an RFC 2231 parameter value. +// Only unreserved token characters (alphanumeric and !#$&-^_.+~) are left unencoded. +func rfc2231PercentEncode(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || + c == '!' || c == '#' || c == '$' || c == '&' || c == '+' || + c == '-' || c == '.' || c == '^' || c == '_' || c == '`' || c == '|' || c == '~' { + b.WriteByte(c) + } else { + fmt.Fprintf(&b, "%%%02X", c) + } + } + return b.String() +} + func ParseWithOptions(ctype string, options ParseOptions) (mtype string, params map[string]string, invalidParams []string, err error) { mtype, params, err = mime.ParseMediaType( - fixNewlines(fixUnescapedQuotes(fixUnquotedSpecials(fixMangledMediaType(removeTrailingHTMLTags(ctype), ';', options))))) + fixNewlines(fixUnescapedQuotes(fixUnquotedSpecials(fixMangledMediaType(assembleRFC2231Params(removeTrailingHTMLTags(ctype)), ';', options))))) if err != nil { if err.Error() == "mime: no media type" { return "", nil, nil, nil diff --git a/mediatype/mediatype_test.go b/mediatype/mediatype_test.go index a40d912..dd23e7b 100644 --- a/mediatype/mediatype_test.go +++ b/mediatype/mediatype_test.go @@ -523,6 +523,18 @@ func TestParseMediaType(t *testing.T) { mtype: "application/pdf", params: map[string]string{"name": "key=value"}, }, + { + label: "RFC 2231 multi-segment filename with euc-kr charset", + input: `attachment; filename*0*=euc-kr''%B0%B3; filename*1*=%2E%74%78%74`, + mtype: "attachment", + params: map[string]string{"filename": "개.txt"}, + }, + { + label: "RFC 2231 multi-segment filename with us-ascii charset", + input: `attachment; filename*0*=us-ascii''hello; filename*1*=-world.txt`, + mtype: "attachment", + params: map[string]string{"filename": "hello-world.txt"}, + }, } for _, tc := range testCases { t.Run(tc.label, func(t *testing.T) { diff --git a/part_test.go b/part_test.go index fab293d..d91da09 100644 --- a/part_test.go +++ b/part_test.go @@ -1405,3 +1405,26 @@ func TestCharacterDetectionRunes(t *testing.T) { test.ComparePart(t, p, wantp) } + +// TestRFC2231LongFilenameSegments verifies that multi-segment RFC 2231 continuation parameters +// in Content-Disposition are assembled correctly, including non-UTF-8 charsets such as EUC-KR. +// Reproduces https://github.com/jhillyerd/enmime/issues/109 +func TestRFC2231LongFilenameSegments(t *testing.T) { + r := test.OpenTestData("parts", "long-filename-euckr.raw") + root, err := enmime.ReadParts(r) + if err != nil { + t.Fatal(err) + } + // Navigate to the attachment part (second child of the root multipart). + if root.FirstChild == nil { + t.Fatal("expected multipart children") + } + attach := root.FirstChild.NextSibling + if attach == nil { + t.Fatal("expected attachment sibling part") + } + want := "개.txt" + if attach.FileName != want { + t.Errorf("FileName got %q, want %q", attach.FileName, want) + } +} diff --git a/testdata/parts/long-filename-euckr.raw b/testdata/parts/long-filename-euckr.raw new file mode 100644 index 0000000..fc43cdf --- /dev/null +++ b/testdata/parts/long-filename-euckr.raw @@ -0,0 +1,21 @@ +From: sender@example.com +To: recipient@example.com +Subject: test +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="boundary" + +--boundary +Content-Type: text/plain; charset=us-ascii + +test + +--boundary +Content-Type: application/zip +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename*0*=euc-kr''%B0%B3; + filename*1*=%2E%74%78%74 + +UEsDBAA= +--boundary--