carlos / platform Public

mail: harden SES event parsing — defend against ambiguous sources, control characters, stray brackets, and FQDN dots

Added four defensive improvements to DomainOf and ParseEvent:
- Refuse sources with multiple @ symbols or commas (ambiguous attribution)
- Strip stray trailing > from unbalanced brackets
- Strip trailing FQDN dot for normalisation
- Refuse control characters (< 32 or == 127) that indicate malformation

Added nine test cases covering edge cases, including the three ambiguous
multi-address patterns identified by robustness testing.

Both original design tensions remain intact: unknown event types parse
(SES adds types), missing source is refused (unattributable).
Paul Campbell pushed by paul@keymail.dev 7d5c7014012a9e3be96ecb7feef394037f22a812 parent 9ff7d29
2 files changed, +98 −2
  • internal/mail/events.go +28 −2
  • internal/mail/events_test.go +70 −0
diff --git a/internal/mail/events.go b/internal/mail/events.go
index 1eb646d29..1a3d7460b 100644
--- a/internal/mail/events.go
+++ b/internal/mail/events.go
@@ -28,7 +28,8 @@ type Event struct {
}
// DomainOf extracts the lowercase domain from an address, tolerating a
-// display-name wrapper. Empty when there is no domain to find.
+// display-name wrapper. Empty when there is no domain to find. Strips one
+// trailing dot and any stray angle bracket.
func DomainOf(address string) string {
s := strings.TrimSpace(address)
if i := strings.LastIndex(s, "<"); i >= 0 {
@@ -40,7 +41,19 @@ func DomainOf(address string) string {
if at < 0 || at == len(s)-1 {
return ""
}
- return strings.ToLower(strings.TrimSpace(s[at+1:]))
+ dom := strings.TrimSpace(s[at+1:])
+ // Strip stray trailing > from unbalanced brackets
+ if len(dom) > 0 && dom[len(dom)-1] == '>' {
+ dom = strings.TrimRight(dom, ">")
+ if dom == "" {
+ return ""
+ }
+ }
+ // Strip exactly one trailing dot (FQDN normalisation)
+ if len(dom) > 1 && dom[len(dom)-1] == '.' {
+ dom = dom[:len(dom)-1]
+ }
+ return strings.ToLower(dom)
}
// ParseEvent reads one SES notification.
@@ -68,6 +81,19 @@ 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.
+ 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)
+ }
+ }
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 083f33967..99e928b5d 100644
--- a/internal/mail/events_test.go
+++ b/internal/mail/events_test.go
@@ -65,3 +65,73 @@ func TestDomainOfIsCaseFoldedAndAngleTolerant(t *testing.T) {
}
}
}
+
+// Stray trailing > from unbalanced brackets should be stripped.
+func TestDomainOfStripsStrayTrailingBracket(t *testing.T) {
+ for _, c := range []struct{ in, want string }{
+ {"a@b.com>", "b.com"},
+ {"<<a@b.com>>", "b.com"},
+ } {
+ if got := DomainOf(c.in); got != c.want {
+ t.Fatalf("DomainOf(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+// Trailing FQDN dots should be normalised away.
+func TestDomainOfStripsTrailingDot(t *testing.T) {
+ if got := DomainOf("a@b.com."); got != "b.com" {
+ t.Fatalf("DomainOf(\"a@b.com.\") = %q, want \"b.com\"", got)
+ }
+}
+
+// Multi-address sources are ambiguous and must be refused. Attribution is by
+// domain, and multiple addresses would make it impossible to know which tenant
+// the event belongs to.
+func TestParseEventRefusesAmbiguousMultiAddressSources(t *testing.T) {
+ testCases := []string{
+ `{"eventType":"Send","mail":{"source":"a@b.com,evil@attacker.com"}}`,
+ `{"eventType":"Send","mail":{"source":"Legit <legit@good.com>, Evil <evil@bad.com>"}}`,
+ `{"eventType":"Send","mail":{"source":"<a@good.com><b@evil.com>"}}`,
+ }
+ for _, tc := range testCases {
+ if _, err := ParseEvent([]byte(tc)); err == nil {
+ t.Fatalf("ParseEvent accepted ambiguous multi-address source: %s", tc)
+ }
+ }
+}
+
+// Control characters in the source indicate malformation or injection.
+func TestParseEventRefusesControlCharacters(t *testing.T) {
+ testCases := []struct {
+ name string
+ body string
+ }{
+ {"newline", `{"eventType":"Send","mail":{"source":"a@b.com\nX-Injected: true"}}`},
+ {"null", `{"eventType":"Send","mail":{"source":"a@b.com\x00injected"}}`},
+ }
+ for _, tc := range testCases {
+ if _, err := ParseEvent([]byte(tc.body)); err == nil {
+ t.Fatalf("ParseEvent accepted control character in source (%s)", tc.name)
+ }
+ }
+}
+
+// A malformed timestamp must be silently ignored, not fail the parse. Timestamp
+// is unrelated to attribution, so failing loudly would block valid events.
+func TestParseEventIgnoresMalformedTimestamp(t *testing.T) {
+ ev, err := ParseEvent([]byte(`{
+ "eventType":"Send",
+ "mail":{
+ "timestamp":"not-a-time",
+ "messageId":"msg123",
+ "source":"a@b.com"
+ }
+ }`))
+ if err != nil {
+ t.Fatalf("ParseEvent failed on malformed timestamp: %v", err)
+ }
+ if !ev.At.IsZero() {
+ t.Fatalf("At = %v, want zero (timestamp ignored)", ev.At)
+ }
+}