package mailer import ( "context" "errors" "net" "strings" "testing" ) // The reachability boundary, asserted address by address. This is the test that stops a customer supplied // mail host from becoming a probe into the network this server runs inside. func TestBlockedIP(t *testing.T) { blocked := []string{ "127.0.0.1", "127.0.0.53", "::1", "10.0.0.5", "172.16.4.1", "172.31.255.254", "192.168.1.1", "169.254.169.254", // the cloud instance metadata address, the highest value target of this class "169.254.1.1", "100.64.0.1", "100.94.8.94", "100.127.255.255", // carrier grade NAT, where this operator's own machines live "0.0.0.0", "240.0.0.1", "255.255.255.255", "224.0.0.1", // multicast "fd00::1", "fc00::1", // IPv6 unique local "fe80::1", // IPv6 link local } for _, s := range blocked { ip := net.ParseIP(s) if ip == nil { t.Fatalf("test bug, %q is not an address", s) } if !blockedIP(ip) { t.Errorf("%s MUST be blocked and was allowed", s) } } allowed := []string{ "8.8.8.8", "1.1.1.1", "52.94.236.248", // a public cloud address "99.255.255.255", // immediately below the carrier grade NAT range "100.63.255.255", // immediately below 100.64.0.0/10 "100.128.0.1", // immediately above it "2606:4700::1111", } for _, s := range allowed { ip := net.ParseIP(s) if ip == nil { t.Fatalf("test bug, %q is not an address", s) } if blockedIP(ip) { t.Errorf("%s is a legitimate public address and was blocked", s) } } } // Passing a literal address instead of a name must not be a way around the guard. This is the bypass a // reviewer looks for first. func TestResolveAllowedRefusesLiteralInternalAddress(t *testing.T) { for _, s := range []string{"127.0.0.1", "169.254.169.254", "10.1.2.3", "100.94.8.94"} { if _, err := resolveAllowed(context.Background(), s); !errors.Is(err, ErrBlockedHost) { t.Errorf("literal %s should be refused as a blocked host, got %v", s, err) } } ip, err := resolveAllowed(context.Background(), "8.8.8.8") if err != nil || ip == nil { t.Errorf("a public literal address should resolve, got %v", err) } } // Header injection. A display name or subject carrying a line break is how a second recipient gets added to a // message the sender never intended. func TestSendRefusesHeaderInjection(t *testing.T) { base := Config{Host: "8.8.8.8", Port: 587, From: "a@b.test"} cases := []struct { name string cfg Config msg Message }{ {"from", Config{Host: "8.8.8.8", Port: 587, From: "a@b.test\r\nBcc: evil@x.test"}, Message{To: "c@d.test"}}, {"from name", func() Config { c := base; c.FromName = "Acme\r\nBcc: evil@x.test"; return c }(), Message{To: "c@d.test"}}, {"reply to", func() Config { c := base; c.ReplyTo = "x@y.test\nBcc: evil@x.test"; return c }(), Message{To: "c@d.test"}}, {"recipient", base, Message{To: "c@d.test\r\nBcc: evil@x.test"}}, {"subject", base, Message{To: "c@d.test", Subject: "Hi\r\nBcc: evil@x.test"}}, } for _, c := range cases { // No network is reached: the header check runs before resolution, so this returns ErrHeader without // dialling anything. if err := Send(context.Background(), c.cfg, c.msg); !errors.Is(err, ErrHeader) { t.Errorf("%s with a line break should be refused, got %v", c.name, err) } } } // A line beginning with a dot terminates the DATA phase early if it is not escaped, which truncates the // message at exactly the point an attacker chooses. func TestComposeEscapesLeadingDot(t *testing.T) { out := compose(Config{From: "a@b.test"}, Message{To: "c@d.test", Body: "line one\n.\nline two"}) if strings.Contains(out, "\n.\n") { t.Error("a lone leading dot was not escaped, the message can be truncated by its own body") } if !strings.Contains(out, "\n..\n") { t.Error("the escaped form is missing") } } func TestComposeHeaders(t *testing.T) { out := compose( Config{From: "noreply@acme.test", FromName: "Acme Recruiting", ReplyTo: "talent@acme.test"}, Message{To: "pat@example.test", Subject: "Your invitation"}, ) for _, want := range []string{ `From: "Acme Recruiting" `, "To: pat@example.test", "Reply-To: talent@acme.test", "Subject: Your invitation", "Auto-Submitted: auto-generated", } { if !strings.Contains(out, want) { t.Errorf("composed message is missing %q", want) } } } // An invitation must be multipart/ALTERNATIVE with method=REQUEST. multipart/mixed produces a message with a // file attached, which a calendar ignores and a person has to open by hand; alternative is what makes the // accept and decline buttons appear. func TestCalendarPartIsAlternativeNotMixed(t *testing.T) { out := compose( Config{From: "talent@acme.test", FromName: "Acme"}, Message{To: "jordan@example.test", Subject: "Interview", Body: "Your interview is confirmed.", Calendar: "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"}, ) if !strings.Contains(out, "Content-Type: multipart/alternative;") { t.Errorf("an invitation must be multipart/alternative:\n%s", out) } if strings.Contains(out, "multipart/mixed") { t.Error("multipart/mixed makes the invitation an attachment a calendar ignores") } if !strings.Contains(out, "Content-Type: text/calendar; charset=utf-8; method=REQUEST") { t.Errorf("the calendar part is missing or has no method:\n%s", out) } // Both parts must be present: a plain body for a client that cannot read the calendar. if !strings.Contains(out, "Content-Type: text/plain; charset=utf-8") { t.Error("the plain text alternative is missing") } if !strings.Contains(out, "Your interview is confirmed.") { t.Error("the human readable body is missing") } } // The boundary must not be able to appear in the content it delimits. func TestBoundaryIsDerivedFromContent(t *testing.T) { a := compose(Config{From: "a@b.test"}, Message{To: "x@y.test", Subject: "One", Calendar: "X"}) b := compose(Config{From: "a@b.test"}, Message{To: "x@y.test", Subject: "Two", Calendar: "X"}) getBoundary := func(s string) string { i := strings.Index(s, `boundary="`) if i < 0 { return "" } rest := s[i+10:] return rest[:strings.IndexByte(rest, '"')] } ba, bb := getBoundary(a), getBoundary(b) if ba == "" || bb == "" { t.Fatal("no boundary was emitted") } if ba == bb { t.Error("two different messages share a boundary, so it is not derived from the content") } } // A cancellation must carry METHOD=CANCEL, or the meeting stays in the candidate's calendar. func TestCalendarMethodIsCarried(t *testing.T) { out := compose(Config{From: "a@b.test"}, Message{To: "x@y.test", Subject: "Cancelled", Calendar: "BEGIN:VCALENDAR", CalendarMethod: "CANCEL"}) if !strings.Contains(out, "method=CANCEL") { t.Errorf("the cancel method was not carried:\n%s", out) } } // A calendar object is full of folded lines that can begin with anything, including a dot, which would // terminate the DATA phase and truncate the message. func TestCalendarPartIsDotStuffed(t *testing.T) { out := compose(Config{From: "a@b.test"}, Message{To: "x@y.test", Subject: "s", Calendar: "BEGIN:VCALENDAR\n.\nEND:VCALENDAR"}) if strings.Contains(out, "\n.\n") { t.Error("a lone leading dot in the calendar part was not escaped") } }