Skip to content

Commit 2cbfb00

Browse files
mpasquale-3claude
andcommitted
Lenient recovery from duplicate Content-Type parameters
When mime.ParseMediaType fails due to duplicate parameter names, strip the duplicates (keeping the first occurrence) and retry. On success, return the recovered media type and params alongside a MalformedHeaderError so callers can distinguish a recovered-but-valid header from a genuinely unparseable one. Exports IsMalformedHeader so consumers (e.g. go-mantis) can opt in to using the recovered values while still observing the malformed-header signal. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d90e7a1 commit 2cbfb00

2 files changed

Lines changed: 247 additions & 1 deletion

File tree

header.go

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,197 @@
11
package message
22

33
import (
4+
"errors"
45
"mime"
6+
"strings"
57

68
"github.com/emersion/go-message/textproto"
79
)
810

11+
// MalformedHeaderError is returned alongside recovered values when a header
12+
// field was malformed but could be partially recovered (e.g. duplicate
13+
// parameters). The accompanying return values are valid and safe to use.
14+
// The Err field holds the original underlying parse error.
15+
type MalformedHeaderError struct {
16+
Err error
17+
}
18+
19+
func (e *MalformedHeaderError) Error() string { return e.Err.Error() }
20+
func (e *MalformedHeaderError) Unwrap() error { return e.Err }
21+
22+
// IsMalformedHeader reports whether err signals a header that was malformed
23+
// but recovered. When true, the other return values from ContentType or
24+
// ContentDisposition are valid and should be used.
25+
func IsMalformedHeader(err error) bool {
26+
return errors.As(err, new(*MalformedHeaderError))
27+
}
28+
29+
// deduplicateContentTypeParams returns a copy of s with duplicate parameter
30+
// names removed (first occurrence wins). It handles quoted-string values that
31+
// may contain semicolons or backslash-escaped characters.
32+
//
33+
// Two-pass design: the first pass detects whether any duplicate exists using a
34+
// stack-allocated array and strings.EqualFold (no heap allocations). The second
35+
// pass only runs — and only allocates — when a duplicate is actually found.
36+
func deduplicateContentTypeParams(s string) string {
37+
idx := strings.IndexByte(s, ';')
38+
if idx < 0 {
39+
return s
40+
}
41+
42+
// First pass: scan param names to detect any duplicate.
43+
// The fixed-size array covers the vast majority of real Content-Type headers;
44+
// if more than 8 params are present we conservatively trigger a rebuild.
45+
var names [8]string
46+
n := 0
47+
hasDup := false
48+
49+
rest := s[idx:]
50+
for len(rest) > 0 && !hasDup {
51+
if rest[0] != ';' {
52+
rest = rest[1:]
53+
continue
54+
}
55+
rest = rest[1:]
56+
rest = strings.TrimLeft(rest, " \t\r\n")
57+
58+
eqIdx := strings.IndexByte(rest, '=')
59+
if eqIdx < 0 {
60+
break
61+
}
62+
if semiBeforeEq := strings.IndexByte(rest[:eqIdx], ';'); semiBeforeEq >= 0 {
63+
rest = rest[semiBeforeEq:]
64+
continue
65+
}
66+
67+
name := strings.TrimRight(rest[:eqIdx], " \t")
68+
rest = rest[eqIdx+1:]
69+
70+
// Check for a duplicate before scanning past the value — if we find one
71+
// we break immediately and skip the value-scanning work entirely.
72+
if n >= len(names) {
73+
hasDup = true // more params than our array — rebuild conservatively
74+
break
75+
}
76+
for i := range n {
77+
if strings.EqualFold(names[i], name) {
78+
hasDup = true
79+
break
80+
}
81+
}
82+
if hasDup {
83+
break
84+
}
85+
names[n] = name
86+
n++
87+
88+
// Skip past the value to advance to the next param.
89+
if len(rest) > 0 && rest[0] == '"' {
90+
end := 1
91+
for end < len(rest) {
92+
if rest[end] == '\\' {
93+
end += 2
94+
} else if rest[end] == '"' {
95+
end++
96+
break
97+
} else {
98+
end++
99+
}
100+
}
101+
rest = rest[end:]
102+
rest = strings.TrimLeft(rest, " \t\r\n")
103+
} else if semi := strings.IndexByte(rest, ';'); semi >= 0 {
104+
rest = rest[semi:]
105+
} else {
106+
rest = ""
107+
}
108+
}
109+
110+
if !hasDup {
111+
return s // no duplicates — return the original string unchanged
112+
}
113+
114+
// Second pass: rebuild the string with duplicates removed.
115+
seen := make(map[string]bool)
116+
var result strings.Builder
117+
result.WriteString(s[:idx])
118+
119+
rest = s[idx:]
120+
for len(rest) > 0 {
121+
if rest[0] != ';' {
122+
rest = rest[1:]
123+
continue
124+
}
125+
rest = rest[1:]
126+
rest = strings.TrimLeft(rest, " \t\r\n")
127+
128+
eqIdx := strings.IndexByte(rest, '=')
129+
if eqIdx < 0 {
130+
break
131+
}
132+
if semiBeforeEq := strings.IndexByte(rest[:eqIdx], ';'); semiBeforeEq >= 0 {
133+
rest = rest[semiBeforeEq:]
134+
continue
135+
}
136+
137+
name := strings.TrimRight(rest[:eqIdx], " \t")
138+
rest = rest[eqIdx+1:]
139+
140+
var value string
141+
if len(rest) > 0 && rest[0] == '"' {
142+
end := 1
143+
for end < len(rest) {
144+
if rest[end] == '\\' {
145+
end += 2
146+
} else if rest[end] == '"' {
147+
end++
148+
break
149+
} else {
150+
end++
151+
}
152+
}
153+
value = rest[:end]
154+
rest = rest[end:]
155+
rest = strings.TrimLeft(rest, " \t\r\n")
156+
} else if semi := strings.IndexByte(rest, ';'); semi >= 0 {
157+
value = strings.TrimRight(rest[:semi], " \t\r\n")
158+
rest = rest[semi:]
159+
} else {
160+
value = strings.TrimRight(rest, " \t\r\n")
161+
rest = ""
162+
}
163+
164+
lower := strings.ToLower(name)
165+
if name != "" && !seen[lower] {
166+
seen[lower] = true
167+
result.WriteString("; ")
168+
result.WriteString(name)
169+
result.WriteByte('=')
170+
result.WriteString(value)
171+
}
172+
}
173+
174+
return result.String()
175+
}
176+
9177
func parseHeaderWithParams(s string) (f string, params map[string]string, err error) {
10178
f, params, err = mime.ParseMediaType(s)
11179
if err != nil {
12-
return s, nil, err
180+
// Try recovery by removing duplicate parameter names
181+
deduped := deduplicateContentTypeParams(s)
182+
var recoveredF string
183+
var recoveredParams map[string]string
184+
recoveredF, recoveredParams, _ = mime.ParseMediaType(deduped)
185+
if recoveredParams != nil {
186+
// Wrap the original error so callers can distinguish a recovered
187+
// malformed header (where the return values are valid) from a
188+
// genuinely unparseable one (where params is nil).
189+
f = recoveredF
190+
params = recoveredParams
191+
err = &MalformedHeaderError{Err: err}
192+
} else {
193+
return s, nil, err
194+
}
13195
}
14196
for k, v := range params {
15197
params[k], _ = decodeHeader(v)

header_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,70 @@ func TestKnownCharset(t *testing.T) {
6767
}
6868
}
6969

70+
func TestDeduplicateContentTypeParams(t *testing.T) {
71+
tests := []struct {
72+
name string
73+
input string
74+
want string
75+
}{
76+
{
77+
name: "no params",
78+
input: "multipart/mixed",
79+
want: "multipart/mixed",
80+
},
81+
{
82+
name: "no duplicates",
83+
input: `multipart/mixed; boundary=abc`,
84+
want: `multipart/mixed; boundary=abc`,
85+
},
86+
{
87+
name: "duplicate boundary keeps first",
88+
input: `multipart/mixed; boundary=abc; boundary=xyz`,
89+
want: `multipart/mixed; boundary=abc`,
90+
},
91+
{
92+
name: "duplicate charset keeps first",
93+
input: `text/html; charset=utf-8; charset=us-ascii`,
94+
want: `text/html; charset=utf-8`,
95+
},
96+
{
97+
name: "quoted value with semicolon",
98+
input: `multipart/mixed; boundary="ab;cd"; boundary=xyz`,
99+
want: `multipart/mixed; boundary="ab;cd"`,
100+
},
101+
{
102+
name: "case-insensitive param names",
103+
input: `text/plain; charset=utf-8; CHARSET=us-ascii`,
104+
want: `text/plain; charset=utf-8`,
105+
},
106+
}
107+
108+
for _, tc := range tests {
109+
t.Run(tc.name, func(t *testing.T) {
110+
got := deduplicateContentTypeParams(tc.input)
111+
if got != tc.want {
112+
t.Errorf("deduplicateContentTypeParams(%q) = %q, want %q", tc.input, got, tc.want)
113+
}
114+
})
115+
}
116+
}
117+
118+
func TestContentTypeDuplicateParamRecovery(t *testing.T) {
119+
var h Header
120+
h.Set("Content-Type", `multipart/mixed; boundary=abc; boundary=xyz`)
121+
122+
mediaType, params, err := h.ContentType()
123+
if !IsMalformedHeader(err) {
124+
t.Errorf("expected IsMalformedHeader error, got %v", err)
125+
}
126+
if mediaType != "multipart/mixed" {
127+
t.Errorf("expected media type %q, got %q", "multipart/mixed", mediaType)
128+
}
129+
if params["boundary"] != "abc" {
130+
t.Errorf("expected boundary %q, got %q", "abc", params["boundary"])
131+
}
132+
}
133+
70134
func TestUnknownCharset(t *testing.T) {
71135
var h Header
72136

0 commit comments

Comments
 (0)