* fix: support SMTP STARTTLS mode and NTLM auth Add explicit SMTP STARTTLS configuration for 587-style connections and keep SSL/TLS as the implicit TLS mode. Prefer PLAIN when advertised, keep LOGIN compatibility, and add NTLM as a fallback for Exchange SMTP servers that require it after STARTTLS. * fix: respect explicit SMTP encryption mode * fix: preserve SMTP TLS compatibility
532 lines
14 KiB
Go
532 lines
14 KiB
Go
package common
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"math/big"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type fakeSMTPServer struct {
|
|
listener net.Listener
|
|
host string
|
|
port int
|
|
cert tls.Certificate
|
|
advertiseSTARTTLS bool
|
|
authMechanisms []string
|
|
messages chan string
|
|
authCommands chan string
|
|
startTLSCommands chan string
|
|
}
|
|
|
|
func newFakeSMTPServer(t *testing.T) *fakeSMTPServer {
|
|
return newFakeSMTPServerWithSTARTTLSAdvertisement(t, true)
|
|
}
|
|
|
|
func newFakeSMTPServerWithSTARTTLSAdvertisement(t *testing.T, advertiseSTARTTLS bool) *fakeSMTPServer {
|
|
t.Helper()
|
|
|
|
cert, err := newTestTLSCertificate()
|
|
require.NoError(t, err)
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
|
|
host, portText, err := net.SplitHostPort(listener.Addr().String())
|
|
require.NoError(t, err)
|
|
port, err := strconv.Atoi(portText)
|
|
require.NoError(t, err)
|
|
|
|
server := &fakeSMTPServer{
|
|
listener: listener,
|
|
host: host,
|
|
port: port,
|
|
cert: cert,
|
|
advertiseSTARTTLS: advertiseSTARTTLS,
|
|
authMechanisms: []string{"PLAIN", "LOGIN"},
|
|
messages: make(chan string, 1),
|
|
authCommands: make(chan string, 1),
|
|
startTLSCommands: make(chan string, 1),
|
|
}
|
|
go server.serve()
|
|
return server
|
|
}
|
|
|
|
func newFakeImplicitTLSSMTPServer(t *testing.T) *fakeSMTPServer {
|
|
t.Helper()
|
|
|
|
cert, err := newTestTLSCertificate()
|
|
require.NoError(t, err)
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
|
|
host, portText, err := net.SplitHostPort(listener.Addr().String())
|
|
require.NoError(t, err)
|
|
port, err := strconv.Atoi(portText)
|
|
require.NoError(t, err)
|
|
|
|
server := &fakeSMTPServer{
|
|
listener: tls.NewListener(listener, &tls.Config{Certificates: []tls.Certificate{cert}}),
|
|
host: host,
|
|
port: port,
|
|
cert: cert,
|
|
advertiseSTARTTLS: false,
|
|
authMechanisms: []string{"PLAIN", "LOGIN"},
|
|
messages: make(chan string, 1),
|
|
authCommands: make(chan string, 1),
|
|
startTLSCommands: make(chan string, 1),
|
|
}
|
|
go server.serve()
|
|
return server
|
|
}
|
|
|
|
func (s *fakeSMTPServer) close() {
|
|
_ = s.listener.Close()
|
|
}
|
|
|
|
func (s *fakeSMTPServer) serve() {
|
|
conn, err := s.listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
|
|
|
rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
|
|
if err := writeSMTPLine(rw, "220 fake.smtp.local ESMTP"); err != nil {
|
|
return
|
|
}
|
|
|
|
encrypted := false
|
|
for {
|
|
line, err := rw.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
command := strings.TrimRight(line, "\r\n")
|
|
upperCommand := strings.ToUpper(command)
|
|
|
|
switch {
|
|
case strings.HasPrefix(upperCommand, "EHLO"):
|
|
if err := writeSMTPLine(rw, "250-fake.smtp.local"); err != nil {
|
|
return
|
|
}
|
|
if !encrypted && s.advertiseSTARTTLS {
|
|
if err := writeSMTPLine(rw, "250-STARTTLS"); err != nil {
|
|
return
|
|
}
|
|
}
|
|
if len(s.authMechanisms) > 0 {
|
|
if err := writeSMTPLine(rw, "250 AUTH "+strings.Join(s.authMechanisms, " ")); err != nil {
|
|
return
|
|
}
|
|
} else if err := writeSMTPLine(rw, "250 8BITMIME"); err != nil {
|
|
return
|
|
}
|
|
case upperCommand == "STARTTLS":
|
|
if encrypted || !s.advertiseSTARTTLS {
|
|
if err := writeSMTPLine(rw, "502 5.5.1 STARTTLS not supported"); err != nil {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
select {
|
|
case s.startTLSCommands <- command:
|
|
default:
|
|
}
|
|
if err := writeSMTPLine(rw, "220 2.0.0 Ready to start TLS"); err != nil {
|
|
return
|
|
}
|
|
tlsConn := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{s.cert}})
|
|
if err := tlsConn.Handshake(); err != nil {
|
|
return
|
|
}
|
|
conn = tlsConn
|
|
rw = bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
|
|
encrypted = true
|
|
case strings.HasPrefix(upperCommand, "AUTH"):
|
|
select {
|
|
case s.authCommands <- command:
|
|
default:
|
|
}
|
|
if err := writeSMTPLine(rw, "235 2.7.0 Authentication successful"); err != nil {
|
|
return
|
|
}
|
|
case strings.HasPrefix(upperCommand, "MAIL FROM:"):
|
|
if err := writeSMTPLine(rw, "250 2.1.0 Sender OK"); err != nil {
|
|
return
|
|
}
|
|
case strings.HasPrefix(upperCommand, "RCPT TO:"):
|
|
if err := writeSMTPLine(rw, "250 2.1.5 Recipient OK"); err != nil {
|
|
return
|
|
}
|
|
case upperCommand == "DATA":
|
|
if err := writeSMTPLine(rw, "354 End data with <CR><LF>.<CR><LF>"); err != nil {
|
|
return
|
|
}
|
|
var data strings.Builder
|
|
for {
|
|
dataLine, err := rw.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
if strings.TrimRight(dataLine, "\r\n") == "." {
|
|
break
|
|
}
|
|
data.WriteString(dataLine)
|
|
}
|
|
s.messages <- data.String()
|
|
if err := writeSMTPLine(rw, "250 2.0.0 Queued"); err != nil {
|
|
return
|
|
}
|
|
case upperCommand == "QUIT":
|
|
_ = writeSMTPLine(rw, "221 2.0.0 Bye")
|
|
return
|
|
default:
|
|
if err := writeSMTPLine(rw, "502 5.5.1 Command not implemented"); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeSMTPLine(rw *bufio.ReadWriter, line string) error {
|
|
_, err := rw.WriteString(line + "\r\n")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return rw.Flush()
|
|
}
|
|
|
|
func newTestTLSCertificate() (tls.Certificate, error) {
|
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
return tls.Certificate{}, err
|
|
}
|
|
|
|
template := x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{
|
|
CommonName: "aixinexchange01.aixin-chip.com",
|
|
},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().Add(time.Hour),
|
|
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
DNSNames: []string{"aixinexchange01", "aixinexchange01.aixin-chip.com"},
|
|
}
|
|
|
|
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
|
|
if err != nil {
|
|
return tls.Certificate{}, err
|
|
}
|
|
|
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
|
|
return tls.X509KeyPair(certPEM, keyPEM)
|
|
}
|
|
|
|
func withSMTPSettings(t *testing.T) {
|
|
t.Helper()
|
|
originalSMTPServer := SMTPServer
|
|
originalSMTPPort := SMTPPort
|
|
originalSMTPSSLEnabled := SMTPSSLEnabled
|
|
originalSMTPStartTLSEnabled := SMTPStartTLSEnabled
|
|
originalSMTPInsecureSkipVerify := SMTPInsecureSkipVerify
|
|
originalSMTPForceAuthLogin := SMTPForceAuthLogin
|
|
originalSMTPAccount := SMTPAccount
|
|
originalSMTPFrom := SMTPFrom
|
|
originalSMTPToken := SMTPToken
|
|
originalSystemName := SystemName
|
|
|
|
t.Cleanup(func() {
|
|
SMTPServer = originalSMTPServer
|
|
SMTPPort = originalSMTPPort
|
|
SMTPSSLEnabled = originalSMTPSSLEnabled
|
|
SMTPStartTLSEnabled = originalSMTPStartTLSEnabled
|
|
SMTPInsecureSkipVerify = originalSMTPInsecureSkipVerify
|
|
SMTPForceAuthLogin = originalSMTPForceAuthLogin
|
|
SMTPAccount = originalSMTPAccount
|
|
SMTPFrom = originalSMTPFrom
|
|
SMTPToken = originalSMTPToken
|
|
SystemName = originalSystemName
|
|
})
|
|
}
|
|
|
|
func TestSendEmailUsesExplicitStartTLSWithInsecureCertificate(t *testing.T) {
|
|
server := newFakeSMTPServer(t)
|
|
defer server.close()
|
|
withSMTPSettings(t)
|
|
|
|
SMTPServer = server.host
|
|
SMTPPort = server.port
|
|
SMTPSSLEnabled = false
|
|
SMTPStartTLSEnabled = true
|
|
SMTPInsecureSkipVerify = true
|
|
SMTPForceAuthLogin = false
|
|
SMTPAccount = "sender@example.com"
|
|
SMTPFrom = "sender@example.com"
|
|
SMTPToken = "secret"
|
|
SystemName = "New API"
|
|
|
|
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
|
|
require.NoError(t, err)
|
|
|
|
select {
|
|
case message := <-server.messages:
|
|
require.Contains(t, message, "Subject: =?UTF-8?B?")
|
|
require.Contains(t, message, "<p>123456</p>")
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for SMTP DATA")
|
|
}
|
|
}
|
|
|
|
func TestSendEmailExplicitStartTLSRequiresServerSupport(t *testing.T) {
|
|
server := newFakeSMTPServerWithSTARTTLSAdvertisement(t, false)
|
|
defer server.close()
|
|
withSMTPSettings(t)
|
|
|
|
SMTPServer = server.host
|
|
SMTPPort = server.port
|
|
SMTPSSLEnabled = false
|
|
SMTPStartTLSEnabled = true
|
|
SMTPInsecureSkipVerify = true
|
|
SMTPForceAuthLogin = false
|
|
SMTPAccount = "sender@example.com"
|
|
SMTPFrom = "sender@example.com"
|
|
SMTPToken = "secret"
|
|
SystemName = "New API"
|
|
|
|
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "STARTTLS")
|
|
}
|
|
|
|
func TestSendEmailDoesNotAutoUpgradeWhenStartTLSDisabled(t *testing.T) {
|
|
server := newFakeSMTPServerWithSTARTTLSAdvertisement(t, true)
|
|
defer server.close()
|
|
withSMTPSettings(t)
|
|
|
|
SMTPServer = server.host
|
|
SMTPPort = server.port
|
|
SMTPSSLEnabled = false
|
|
SMTPStartTLSEnabled = false
|
|
SMTPInsecureSkipVerify = false
|
|
SMTPForceAuthLogin = false
|
|
SMTPAccount = "sender@example.com"
|
|
SMTPFrom = "sender@example.com"
|
|
SMTPToken = "secret"
|
|
SystemName = "New API"
|
|
|
|
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
|
|
require.NoError(t, err)
|
|
|
|
select {
|
|
case command := <-server.startTLSCommands:
|
|
t.Fatalf("unexpected SMTP STARTTLS command: %s", command)
|
|
default:
|
|
}
|
|
|
|
select {
|
|
case message := <-server.messages:
|
|
require.Contains(t, message, "<p>123456</p>")
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for SMTP DATA")
|
|
}
|
|
}
|
|
|
|
func TestNewSMTPClientHonorsExplicitStartTLSWhenPortIs465(t *testing.T) {
|
|
server := newFakeSMTPServer(t)
|
|
defer server.close()
|
|
withSMTPSettings(t)
|
|
|
|
SMTPServer = server.host
|
|
SMTPPort = 465
|
|
SMTPSSLEnabled = false
|
|
SMTPStartTLSEnabled = true
|
|
SMTPInsecureSkipVerify = true
|
|
|
|
client, err := newSMTPClient(fmt.Sprintf("%s:%d", server.host, server.port))
|
|
require.NoError(t, err)
|
|
defer client.Close()
|
|
|
|
select {
|
|
case command := <-server.startTLSCommands:
|
|
require.Equal(t, "STARTTLS", command)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for SMTP STARTTLS")
|
|
}
|
|
}
|
|
|
|
func TestNewSMTPClientKeepsImplicitTLSForLegacyPort465(t *testing.T) {
|
|
server := newFakeImplicitTLSSMTPServer(t)
|
|
defer server.close()
|
|
withSMTPSettings(t)
|
|
|
|
SMTPServer = server.host
|
|
SMTPPort = 465
|
|
SMTPSSLEnabled = false
|
|
SMTPStartTLSEnabled = false
|
|
SMTPInsecureSkipVerify = true
|
|
|
|
client, err := newSMTPClient(fmt.Sprintf("%s:%d", server.host, server.port))
|
|
require.NoError(t, err)
|
|
defer client.Close()
|
|
}
|
|
|
|
func TestSendEmailSkipsAuthWhenCredentialsAreEmpty(t *testing.T) {
|
|
server := newFakeSMTPServerWithSTARTTLSAdvertisement(t, false)
|
|
defer server.close()
|
|
withSMTPSettings(t)
|
|
|
|
SMTPServer = server.host
|
|
SMTPPort = server.port
|
|
SMTPSSLEnabled = false
|
|
SMTPStartTLSEnabled = false
|
|
SMTPInsecureSkipVerify = false
|
|
SMTPForceAuthLogin = false
|
|
SMTPAccount = ""
|
|
SMTPFrom = "sender@example.com"
|
|
SMTPToken = ""
|
|
SystemName = "New API"
|
|
|
|
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
|
|
require.NoError(t, err)
|
|
|
|
select {
|
|
case command := <-server.authCommands:
|
|
t.Fatalf("unexpected SMTP auth command: %s", command)
|
|
default:
|
|
}
|
|
|
|
select {
|
|
case message := <-server.messages:
|
|
require.Contains(t, message, "<p>123456</p>")
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for SMTP DATA")
|
|
}
|
|
}
|
|
|
|
func TestSendEmailSkipsAuthWhenCredentialsAreIncomplete(t *testing.T) {
|
|
server := newFakeSMTPServerWithSTARTTLSAdvertisement(t, false)
|
|
defer server.close()
|
|
withSMTPSettings(t)
|
|
|
|
SMTPServer = server.host
|
|
SMTPPort = server.port
|
|
SMTPSSLEnabled = false
|
|
SMTPStartTLSEnabled = false
|
|
SMTPInsecureSkipVerify = false
|
|
SMTPForceAuthLogin = false
|
|
SMTPAccount = "sender@example.com"
|
|
SMTPFrom = "sender@example.com"
|
|
SMTPToken = ""
|
|
SystemName = "New API"
|
|
|
|
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
|
|
require.NoError(t, err)
|
|
|
|
select {
|
|
case command := <-server.authCommands:
|
|
t.Fatalf("unexpected SMTP auth command: %s", command)
|
|
default:
|
|
}
|
|
|
|
select {
|
|
case message := <-server.messages:
|
|
require.Contains(t, message, "<p>123456</p>")
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for SMTP DATA")
|
|
}
|
|
}
|
|
|
|
func TestSendEmailUsesNTLMWhenServerOnlySupportsNTLM(t *testing.T) {
|
|
server := newFakeSMTPServer(t)
|
|
server.authMechanisms = []string{"NTLM"}
|
|
defer server.close()
|
|
withSMTPSettings(t)
|
|
|
|
SMTPServer = server.host
|
|
SMTPPort = server.port
|
|
SMTPSSLEnabled = false
|
|
SMTPStartTLSEnabled = true
|
|
SMTPInsecureSkipVerify = true
|
|
SMTPForceAuthLogin = false
|
|
SMTPAccount = "no-reply"
|
|
SMTPFrom = "no-reply@example.com"
|
|
SMTPToken = "secret"
|
|
SystemName = "New API"
|
|
|
|
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
|
|
require.NoError(t, err)
|
|
|
|
select {
|
|
case command := <-server.authCommands:
|
|
require.True(t, strings.HasPrefix(command, "AUTH NTLM "), "unexpected auth command: %s", command)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for SMTP AUTH")
|
|
}
|
|
}
|
|
|
|
func TestSendEmailUsesNTLMForMicrosoftAccountWhenServerOnlySupportsNTLM(t *testing.T) {
|
|
server := newFakeSMTPServer(t)
|
|
server.authMechanisms = []string{"NTLM"}
|
|
defer server.close()
|
|
withSMTPSettings(t)
|
|
|
|
SMTPServer = server.host
|
|
SMTPPort = server.port
|
|
SMTPSSLEnabled = false
|
|
SMTPStartTLSEnabled = true
|
|
SMTPInsecureSkipVerify = true
|
|
SMTPForceAuthLogin = false
|
|
SMTPAccount = "no-reply@contoso.onmicrosoft.com"
|
|
SMTPFrom = "no-reply@contoso.onmicrosoft.com"
|
|
SMTPToken = "secret"
|
|
SystemName = "New API"
|
|
|
|
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
|
|
require.NoError(t, err)
|
|
|
|
select {
|
|
case command := <-server.authCommands:
|
|
require.True(t, strings.HasPrefix(command, "AUTH NTLM "), "unexpected auth command: %s", command)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for SMTP AUTH")
|
|
}
|
|
}
|
|
|
|
func TestSendEmailExplicitStartTLSRejectsUntrustedCertificateByDefault(t *testing.T) {
|
|
server := newFakeSMTPServer(t)
|
|
defer server.close()
|
|
withSMTPSettings(t)
|
|
|
|
SMTPServer = server.host
|
|
SMTPPort = server.port
|
|
SMTPSSLEnabled = false
|
|
SMTPStartTLSEnabled = true
|
|
SMTPInsecureSkipVerify = false
|
|
SMTPForceAuthLogin = false
|
|
SMTPAccount = "sender@example.com"
|
|
SMTPFrom = "sender@example.com"
|
|
SMTPToken = "secret"
|
|
SystemName = "New API"
|
|
|
|
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
|
|
require.Error(t, err)
|
|
require.Contains(t, fmt.Sprint(err), "certificate")
|
|
}
|