Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 87 additions & 1 deletion services/api/internal/platform/plugin/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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) {
Comment thread
pullfrog[bot] marked this conversation as resolved.
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).
Expand Down
63 changes: 63 additions & 0 deletions services/api/internal/platform/plugin/email_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package plugin
import (
"context"
"net"
"net/smtp"
"strings"
"testing"
)
Expand Down Expand Up @@ -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)
}
}
Loading