carlos / platform Public

mail: narrow ambiguity refusal to accept display-name-with-comma

The initial refusal of multi-address sources was too coarse: it rejected
'Smith, Bob <bob@example.com>', a standard display-name form, because the
raw source contained a comma.

Changed logic to extract the address first (stripping display-name wrapper),
then test the extracted address for ambiguity. This rejects:
- Multiple @ symbols in the extracted address
- Commas in the extracted address (multi-address after display name is removed)
- Multiple bracket pairs (detected by another < before the final one)

But accepts:
- Commas inside a display name (like 'Smith, Bob')
- Single addresses with plus-sign tags (noreply+tag@example.com)

Control-character check stays on the raw source: any injected control char
anywhere in the field is rejected.

Added test case TestParseEventAcceptsDisplayNameWithComma with four valid
cases, including the standard 'Smith, Bob <bob@example.com>' form.
Paul Campbell pushed by paul@keymail.dev 253d1c4757941cf039e79e27a2f20d343549eb6a parent 7d5c701
2 files changed, +51 −8
  • internal/mail/events.go +25 −8
  • internal/mail/events_test.go +26 −0
diff --git a/internal/mail/events.go b/internal/mail/events.go
index 1a3d7460b..959d5f4d9 100644
--- a/internal/mail/events.go
+++ b/internal/mail/events.go
@@ -81,19 +81,36 @@ func ParseEvent(body []byte) (Event, error) {
if raw.Mail.Source == "" {
return Event{}, fmt.Errorf("mail: ses event has no mail.source — it can be attributed to no tenant")
}
- // Refuse sources with commas or multiple @ symbols: they indicate multiple
- // address-shaped substrings, making attribution ambiguous. A single envelope
- // MAIL FROM must have exactly one @ and no commas.
- if strings.Contains(raw.Mail.Source, ",") || strings.Count(raw.Mail.Source, "@") > 1 {
- return Event{}, fmt.Errorf("mail: ses event source %q is ambiguous — cannot reliably attribute to one tenant", raw.Mail.Source)
- }
- // Refuse control characters, which would pass through to the domain and indicate
- // malformation or injection.
+ // Refuse control characters on the raw source: an injected newline anywhere
+ // in the field indicates malformation and must not be trusted.
for _, c := range raw.Mail.Source {
if c < 32 || c == 127 {
return Event{}, fmt.Errorf("mail: ses event source contains control character (0x%02x)", c)
}
}
+ // Extract the address part (stripping display-name wrapper if present) and
+ // test it for ambiguity. A single envelope MAIL FROM must extract to exactly
+ // one @ and no commas.
+ extractedAddr := raw.Mail.Source
+ s := strings.TrimSpace(raw.Mail.Source)
+ if i := strings.LastIndex(s, "<"); i >= 0 {
+ if j := strings.LastIndex(s, ">"); j > i {
+ // Has a bracketed span; refuse if there are multiple bracket pairs
+ // (indicated by another < before the final one).
+ if strings.Contains(s[:i], "<") {
+ return Event{}, fmt.Errorf("mail: ses event source %q is ambiguous — multiple bracketed address spans", raw.Mail.Source)
+ }
+ extractedAddr = strings.TrimSpace(s[i+1 : j])
+ }
+ }
+ // Check the extracted address for ambiguity: commas or multiple @ symbols.
+ // Note: "a@b"@example.com (a quoted local part containing @) is refused here.
+ // This is deliberate: SES mail.source is a bare envelope MAIL FROM with no
+ // display name, and quoted local parts with @ essentially never appear there.
+ // Quote-aware parsing is not worth the machinery for a case that does not occur.
+ if strings.Contains(extractedAddr, ",") || strings.Count(extractedAddr, "@") > 1 {
+ return Event{}, fmt.Errorf("mail: ses event source %q is ambiguous — cannot reliably attribute to one tenant", raw.Mail.Source)
+ }
dom := DomainOf(raw.Mail.Source)
if dom == "" {
return Event{}, fmt.Errorf("mail: ses event source %q has no domain", raw.Mail.Source)
diff --git a/internal/mail/events_test.go b/internal/mail/events_test.go
index 99e928b5d..6b645ea3c 100644
--- a/internal/mail/events_test.go
+++ b/internal/mail/events_test.go
@@ -90,8 +90,11 @@ func TestDomainOfStripsTrailingDot(t *testing.T) {
// the event belongs to.
func TestParseEventRefusesAmbiguousMultiAddressSources(t *testing.T) {
testCases := []string{
+ // Comma inside raw source (not in display name) indicates multiple addresses
`{"eventType":"Send","mail":{"source":"a@b.com,evil@attacker.com"}}`,
+ // Multiple bracket pairs separated by comma outside brackets
`{"eventType":"Send","mail":{"source":"Legit <legit@good.com>, Evil <evil@bad.com>"}}`,
+ // Multiple bracket pairs with no space between them
`{"eventType":"Send","mail":{"source":"<a@good.com><b@evil.com>"}}`,
}
for _, tc := range testCases {
@@ -101,6 +104,29 @@ func TestParseEventRefusesAmbiguousMultiAddressSources(t *testing.T) {
}
}
+// Display-name-with-comma is standard and must be accepted. A comma inside
+// a display name (like "Smith, Bob") does not indicate multiple addresses.
+func TestParseEventAcceptsDisplayNameWithComma(t *testing.T) {
+ testCases := []struct {
+ source string
+ want string
+ }{
+ {`Smith, Bob <bob@example.com>`, `example.com`},
+ {`Bob <bob@example.com>`, `example.com`},
+ {`noreply+tag@example.com`, `example.com`},
+ {`noreply@mail-eu-west-1.seapointish.bag.oncarlos.com`, `mail-eu-west-1.seapointish.bag.oncarlos.com`},
+ }
+ for _, tc := range testCases {
+ ev, err := ParseEvent([]byte(`{"eventType":"Send","mail":{"source":"` + tc.source + `"}}`))
+ if err != nil {
+ t.Fatalf("ParseEvent rejected valid address %q: %v", tc.source, err)
+ }
+ if ev.Domain != tc.want {
+ t.Fatalf("Domain for %q = %q, want %q", tc.source, ev.Domain, tc.want)
+ }
+ }
+}
+
// Control characters in the source indicate malformation or injection.
func TestParseEventRefusesControlCharacters(t *testing.T) {
testCases := []struct {