fix(smtp): AUTH LOGIN fallback when PLAIN is refused
Servers like Postfix with Dovecot SASL or Exchange can fail PLAIN with 451 4.3.2 but accept LOGIN. MindSift now uses LOGIN directly when it is the only mechanism, and otherwise reconnects and retries with LOGIN after a refused PLAIN (net/smtp closes the connection on a failed AUTH). If both fail with 451, the error names both attempts and points to the server's SASL backend. LOGIN, like PLAIN, is never sent unencrypted to remote hosts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
75a7b67d09
commit
d8a16c6775
3 files changed
+149
-8
No files matched your search
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
@@ -21,8 +22,13 @@ import (
|
||||
type fakeSMTP struct {
|
||||
addr string
|
||||
rcptCode string // e.g. "451 4.3.2 Internal server error" to refuse RCPT TO
|
||||
// AUTH behaviour: mechanisms announced, and per mechanism a reply code to fail with ("" = accept)
|
||||
authMechs string
|
||||
authFail map[string]string
|
||||
|
||||
mu sync.Mutex
|
||||
authUsed []string // mechanisms tried, in order
|
||||
authUser string // decoded user of a successful LOGIN
|
||||
ehlo string
|
||||
mailFrom string
|
||||
rcptTo string
|
||||
@@ -53,7 +59,7 @@ func startFakeSMTP(t *testing.T, rcptCode string) *fakeSMTP {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
f := &fakeSMTP{addr: ln.Addr().String(), rcptCode: rcptCode}
|
||||
f := &fakeSMTP{addr: ln.Addr().String(), rcptCode: rcptCode, authMechs: "PLAIN", authFail: map[string]string{}}
|
||||
tlsCfg := &tls.Config{Certificates: []tls.Certificate{selfSignedCert(t)}}
|
||||
go func() {
|
||||
for {
|
||||
@@ -86,9 +92,9 @@ func (f *fakeSMTP) serve(conn net.Conn, tlsCfg *tls.Config) {
|
||||
f.ehlo = cmd[5:]
|
||||
f.mu.Unlock()
|
||||
if secure {
|
||||
reply("250-fake\r\n250 AUTH PLAIN")
|
||||
reply("250-fake\r\n250 AUTH " + f.authMechs)
|
||||
} else {
|
||||
reply("250-fake\r\n250-STARTTLS\r\n250 AUTH PLAIN")
|
||||
reply("250-fake\r\n250-STARTTLS\r\n250 AUTH " + f.authMechs)
|
||||
}
|
||||
case upper == "STARTTLS":
|
||||
reply("220 go ahead")
|
||||
@@ -99,7 +105,27 @@ func (f *fakeSMTP) serve(conn net.Conn, tlsCfg *tls.Config) {
|
||||
conn, secure = tlsConn, true
|
||||
r, w = bufio.NewReader(conn), bufio.NewWriter(conn)
|
||||
case strings.HasPrefix(upper, "AUTH "):
|
||||
reply("235 ok")
|
||||
mech := strings.Fields(upper)[1]
|
||||
f.mu.Lock()
|
||||
f.authUsed = append(f.authUsed, mech)
|
||||
fail := f.authFail[mech]
|
||||
f.mu.Unlock()
|
||||
if mech == "LOGIN" {
|
||||
reply("334 " + base64.StdEncoding.EncodeToString([]byte("Username:")))
|
||||
u, _ := r.ReadString('\n')
|
||||
reply("334 " + base64.StdEncoding.EncodeToString([]byte("Password:")))
|
||||
_, _ = r.ReadString('\n')
|
||||
if dec, err := base64.StdEncoding.DecodeString(strings.TrimSpace(u)); err == nil {
|
||||
f.mu.Lock()
|
||||
f.authUser = string(dec)
|
||||
f.mu.Unlock()
|
||||
}
|
||||
}
|
||||
if fail != "" {
|
||||
reply(fail)
|
||||
} else {
|
||||
reply("235 ok")
|
||||
}
|
||||
case strings.HasPrefix(upper, "MAIL FROM"):
|
||||
f.mu.Lock()
|
||||
f.mailFrom = strings.TrimSpace(cmd[len("MAIL FROM:"):])
|
||||
@@ -213,3 +239,34 @@ func TestEHLONameNeverLocalhost(t *testing.T) {
|
||||
t.Errorf("fallback must not be localhost, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthFallsBackToLogin(t *testing.T) {
|
||||
f := startFakeSMTP(t, "")
|
||||
f.authMechs = "PLAIN LOGIN"
|
||||
f.authFail["PLAIN"] = "451 4.3.2 Internal server error"
|
||||
if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo"); err != nil {
|
||||
t.Fatalf("LOGIN fallback should succeed: %v", err)
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if strings.Join(f.authUsed, ",") != "PLAIN,LOGIN" || f.authUser != "alarm@firma.de" {
|
||||
t.Fatalf("expected PLAIN then LOGIN with the user, got %v / %q", f.authUsed, f.authUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginOnlyAndBothFailing(t *testing.T) {
|
||||
f := startFakeSMTP(t, "")
|
||||
f.authMechs = "LOGIN"
|
||||
if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo"); err != nil {
|
||||
t.Fatalf("LOGIN-only server: %v", err)
|
||||
}
|
||||
|
||||
g := startFakeSMTP(t, "")
|
||||
g.authMechs = "PLAIN LOGIN"
|
||||
g.authFail["PLAIN"] = "451 4.3.2 Internal server error"
|
||||
g.authFail["LOGIN"] = "451 4.3.2 Internal server error"
|
||||
err := sendMail(g.settings(true), "anna@example.com", "Test", "Hallo")
|
||||
if err == nil || !strings.Contains(err.Error(), "PLAIN") || !strings.Contains(err.Error(), "LOGIN") || !strings.Contains(err.Error(), "SASL") {
|
||||
t.Fatalf("expected both mechanisms and the server hint in the error, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user