diff --git a/services/api/internal/platform/plugin/email.go b/services/api/internal/platform/plugin/email.go index 7c7b01ec7..14cd5f724 100644 --- a/services/api/internal/platform/plugin/email.go +++ b/services/api/internal/platform/plugin/email.go @@ -7,11 +7,13 @@ import ( "crypto/tls" "encoding/hex" "encoding/json" + "errors" "fmt" "mime" "net" "net/mail" "net/smtp" + "strings" "time" "github.com/tetratelabs/wazero" @@ -185,7 +187,15 @@ func dialAndSendSMTP(ctx context.Context, req smtpSendRequest) error { } if req.Username != "" { - if err := client.Auth(smtp.PlainAuth("", req.Username, req.Password, req.Host)); err != nil { + // Negotiate from what the server advertises. net/smtp's PlainAuth + // sends AUTH PLAIN unconditionally, so a server that accepts only + // LOGIN (Azure Communication Services, some Exchange relays) rejects + // the handshake with "504 5.7.4 Unrecognized authentication type". + auth, err := smtpAuthFor(advertisedMechanisms(client), req.Username, req.Password, req.Host) + if err != nil { + return fmt.Errorf("send_email: auth: %w", err) + } + if err := client.Auth(auth); err != nil { return fmt.Errorf("send_email: auth: %w", err) } } @@ -212,6 +222,82 @@ func dialAndSendSMTP(ctx context.Context, req smtpSendRequest) error { return client.Quit() } +// advertisedMechanisms returns the AUTH mechanisms the server listed in its +// EHLO response, upper-cased (net/smtp exposes the raw EHLO parameter line). +func advertisedMechanisms(client *smtp.Client) []string { + ok, params := client.Extension("AUTH") + if !ok { + return nil + } + return strings.Fields(strings.ToUpper(params)) +} + +// smtpAuthFor picks an smtp.Auth the server actually supports, trying PLAIN +// before LOGIN regardless of advertisement order. LOGIN is needed because it +// is the only mechanism Azure Communication Services (and some Exchange +// relays) accept. +// Returns an error naming the advertised set rather than letting net/smtp +// fail later with a bare "unrecognized authentication type". +func smtpAuthFor(mechanisms []string, username, password, host string) (smtp.Auth, error) { + for _, want := range []string{"PLAIN", "LOGIN"} { + for _, mech := range mechanisms { + if mech != want { + continue + } + if want == "PLAIN" { + return smtp.PlainAuth("", username, password, host), nil + } + return &loginAuth{username: username, password: password}, nil + } + } + if len(mechanisms) == 0 { + return nil, fmt.Errorf("server advertises no AUTH mechanism") + } + return nil, fmt.Errorf("no supported AUTH mechanism (server offers %s)", strings.Join(mechanisms, ", ")) +} + +// loginAuth implements the AUTH LOGIN handshake net/smtp does not ship: the +// server prompts for the username, then the password, each as a base64 +// challenge. Start returns "LOGIN" so net/smtp issues AUTH LOGIN without an +// initial response. +type loginAuth struct { + username string + password string +} + +func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + // Mirror smtp.PlainAuth: never put credentials on a link that was never + // encrypted. STARTTLS is opportunistic in dialAndSendSMTP, so a server + // that does not advertise it would otherwise receive the password in the + // clear. + if !server.TLS && !isLocalhost(server.Name) { + return "", nil, errors.New("unencrypted connection") + } + return "LOGIN", nil, nil +} + +// isLocalhost mirrors net/smtp's unexported helper: loopback needs no TLS +// because the traffic never leaves the host. +func isLocalhost(name string) bool { + return name == "localhost" || name == "127.0.0.1" || name == "::1" +} + +func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if !more { + return nil, nil + } + switch strings.ToLower(strings.TrimSpace(string(fromServer))) { + case "username:", "user name:": + return []byte(a.username), nil + case "password:": + return []byte(a.password), nil + default: + // Never echo the challenge: a misbehaving server could ask for the + // password twice and the answer would leak into logs. + return nil, fmt.Errorf("unexpected AUTH LOGIN challenge") + } +} + // buildMIMEMessage renders a minimal RFC 5322 message with a // multipart/alternative text+HTML body (or a single part when only one is // supplied). diff --git a/services/api/internal/platform/plugin/email_test.go b/services/api/internal/platform/plugin/email_test.go index 822c3b2f8..9e53bd605 100644 --- a/services/api/internal/platform/plugin/email_test.go +++ b/services/api/internal/platform/plugin/email_test.go @@ -3,6 +3,7 @@ package plugin import ( "context" "net" + "net/smtp" "strings" "testing" ) @@ -51,3 +52,65 @@ func TestDialAndSendSMTP_BlocksPrivateTargets(t *testing.T) { default: } } + +func TestSmtpAuthFor_PicksAdvertisedMechanism(t *testing.T) { + // Azure Communication Services advertises only LOGIN/XOAUTH2; a PLAIN-only + // assumption is what broke the send with "Unrecognized authentication type". + for _, tc := range []struct { + name string + mech []string + wantErr bool + wantKind string + }{ + {"plain preferred when offered", []string{"PLAIN", "LOGIN"}, false, "plain"}, + {"login only", []string{"LOGIN", "XOAUTH2"}, false, "login"}, + {"login after starttls advertisement", []string{"LOGIN"}, false, "login"}, + {"xoauth2 only is unsupported", []string{"XOAUTH2"}, true, ""}, + {"nothing advertised", nil, true, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + auth, err := smtpAuthFor(tc.mech, "user", "secret", "smtp.example.com") + if tc.wantErr { + if err == nil { + t.Fatalf("expected an error for mechanisms %v", tc.mech) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + _, isLogin := auth.(*loginAuth) + if got := map[bool]string{true: "login", false: "plain"}[isLogin]; got != tc.wantKind { + t.Fatalf("mechanism = %s, want %s", got, tc.wantKind) + } + }) + } +} + +func TestLoginAuth_AnswersChallengesInOrder(t *testing.T) { + auth := &loginAuth{username: "user", password: "secret"} + mech, initial, err := auth.Start(&smtp.ServerInfo{Name: "smtp.example.com", TLS: true}) + if err != nil || mech != "LOGIN" || initial != nil { + t.Fatalf("Start = %q, %v, %v; want LOGIN, nil, nil", mech, initial, err) + } + answer, err := auth.Next([]byte("Username:"), true) + if err != nil || string(answer) != "user" { + t.Fatalf("username challenge = %q, %v", answer, err) + } + answer, err = auth.Next([]byte("Password:"), true) + if err != nil || string(answer) != "secret" { + t.Fatalf("password challenge = %q, %v", answer, err) + } + if _, err := auth.Next(nil, false); err != nil { + t.Fatalf("final call should be a no-op, got %v", err) + } + if _, _, err := auth.Start(&smtp.ServerInfo{Name: "smtp.example.com"}); err == nil { + t.Fatalf("credentials must not be offered on an unencrypted connection") + } + if _, _, err := auth.Start(&smtp.ServerInfo{Name: "localhost"}); err != nil { + t.Fatalf("loopback needs no TLS, got %v", err) + } + if answer, err := auth.Next([]byte("Password again:"), true); err == nil { + t.Fatalf("unexpected challenge answered with %q; want an error", answer) + } +}