From e9fbbe3373eb66a345f5e3829e2563b94dc92051 Mon Sep 17 00:00:00 2001 From: Gab Virebent Date: Mon, 24 Aug 2026 17:34:45 +0200 Subject: Harden transport and preserve profile identities --- internal/smtpclient/client.go | 141 ++++++++++++++++++++++++++++++++---------- 1 file changed, 109 insertions(+), 32 deletions(-) (limited to 'internal/smtpclient/client.go') diff --git a/internal/smtpclient/client.go b/internal/smtpclient/client.go index d4ee686..a0abc06 100644 --- a/internal/smtpclient/client.go +++ b/internal/smtpclient/client.go @@ -4,11 +4,13 @@ import ( "bufio" "context" "crypto/tls" + "encoding/base64" "fmt" "io" "net" "net/textproto" "strings" + "sync" "time" ) @@ -19,6 +21,8 @@ type Config struct { Port int Recipient string EnvelopeFrom string + Username string + Password string HELO string TLSServerName string RequireTLS bool @@ -30,6 +34,7 @@ type Config struct { type Client struct { cfg Config dial DialContextFunc + mu sync.Mutex } type Message struct { @@ -45,22 +50,90 @@ func (c *Client) Send(ctx context.Context, msg Message) error { if c.cfg.DryRun { return nil } - if c.dial == nil { - c.dial = (&net.Dialer{}).DialContext + c.mu.Lock() + defer c.mu.Unlock() + + conn, err := c.connect(ctx) + if err != nil { + return err + } + defer conn.close() + session := conn.session + + envelopeFrom := sanitizeEnvelope(c.cfg.EnvelopeFrom) + if envelopeFrom == "" { + envelopeFrom = sanitizeEnvelope(msg.EnvelopeFrom) + } + if _, _, err := session.cmd(250, "MAIL FROM:<%s>\r\n", envelopeFrom); err != nil { + return fmt.Errorf("mail from rejected: %w", err) + } + if _, _, err := session.cmd(250, "RCPT TO:<%s>\r\n", sanitizeEnvelope(c.cfg.Recipient)); err != nil { + return fmt.Errorf("rcpt to rejected: %w", err) + } + if _, _, err := session.cmd(354, "DATA\r\n"); err != nil { + return fmt.Errorf("data rejected: %w", err) + } + if err := writeSMTPData(session.w, msg.Raw); err != nil { + return err + } + if _, _, err := session.read(250); err != nil { + return fmt.Errorf("message rejected: %w", err) + } + _, _, _ = session.cmd(221, "QUIT\r\n") + return nil +} + +// Check verifies the complete SOCKS/SMTP/TLS/AUTH path without issuing a mail +// transaction or sending message data. +func (c *Client) Check(ctx context.Context) error { + if c.cfg.DryRun { + return nil + } + c.mu.Lock() + defer c.mu.Unlock() + + conn, err := c.connect(ctx) + if err != nil { + return err + } + defer conn.close() + _, _, _ = conn.session.cmd(221, "QUIT\r\n") + return nil +} + +type smtpConnection struct { + conn net.Conn + session *session + cancel context.CancelFunc +} + +func (c *smtpConnection) close() { + c.cancel() + _ = c.conn.Close() +} + +func (c *Client) connect(ctx context.Context) (*smtpConnection, error) { + dial := c.dial + if dial == nil { + dial = (&net.Dialer{}).DialContext } timeout := c.cfg.Timeout if timeout <= 0 { timeout = 90 * time.Second } ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() addr := net.JoinHostPort(c.cfg.Host, fmt.Sprintf("%d", c.cfg.Port)) - conn, err := c.dial(ctx, "tcp", addr) + conn, err := dial(ctx, "tcp", addr) if err != nil { - return fmt.Errorf("dial smtp: %w", err) + cancel() + return nil, fmt.Errorf("dial smtp: %w", err) + } + fail := func(err error) (*smtpConnection, error) { + cancel() + _ = conn.Close() + return nil, err } - defer conn.Close() if deadline, ok := ctx.Deadline(); ok { _ = conn.SetDeadline(deadline) } @@ -70,58 +143,51 @@ func (c *Client) Send(ctx context.Context, msg Message) error { MinVersion: tls.VersionTLS12, }) if err := tlsConn.HandshakeContext(ctx); err != nil { - return fmt.Errorf("implicit tls handshake: %w", err) + return fail(fmt.Errorf("implicit tls handshake: %w", err)) } conn = tlsConn } session := newSession(conn) if _, _, err := session.read(220); err != nil { - return fmt.Errorf("smtp greeting: %w", err) + return fail(fmt.Errorf("smtp greeting: %w", err)) } if err := session.ehlo(c.cfg.HELO); err != nil { - return err + return fail(err) } if c.cfg.RequireTLS && !c.cfg.ImplicitTLS { if _, _, err := session.cmd(220, "STARTTLS\r\n"); err != nil { - return fmt.Errorf("starttls: %w", err) + return fail(fmt.Errorf("starttls: %w", err)) } tlsConn := tls.Client(conn, &tls.Config{ ServerName: c.cfg.TLSServerName, MinVersion: tls.VersionTLS12, }) if err := tlsConn.HandshakeContext(ctx); err != nil { - return fmt.Errorf("tls handshake: %w", err) + return fail(fmt.Errorf("tls handshake: %w", err)) } + conn = tlsConn session = newSession(tlsConn) if err := session.ehlo(c.cfg.HELO); err != nil { - return err + return fail(err) } } - envelopeFrom := sanitizeEnvelope(c.cfg.EnvelopeFrom) - if envelopeFrom == "" { - envelopeFrom = sanitizeEnvelope(msg.EnvelopeFrom) - } - if _, _, err := session.cmd(250, "MAIL FROM:<%s>\r\n", envelopeFrom); err != nil { - return fmt.Errorf("mail from rejected: %w", err) - } - if _, _, err := session.cmd(250, "RCPT TO:<%s>\r\n", sanitizeEnvelope(c.cfg.Recipient)); err != nil { - return fmt.Errorf("rcpt to rejected: %w", err) - } - if _, _, err := session.cmd(354, "DATA\r\n"); err != nil { - return fmt.Errorf("data rejected: %w", err) - } - if err := writeSMTPData(session.w, msg.Raw); err != nil { - return err - } - if _, _, err := session.read(250); err != nil { - return fmt.Errorf("message rejected: %w", err) + if c.cfg.Username != "" || c.cfg.Password != "" { + if c.cfg.Username == "" || c.cfg.Password == "" { + return fail(fmt.Errorf("smtp authentication credentials are incomplete")) + } + if !c.cfg.RequireTLS && !c.cfg.ImplicitTLS { + return fail(fmt.Errorf("smtp authentication requires TLS")) + } + if err := session.authPlain(c.cfg.Username, c.cfg.Password); err != nil { + return fail(err) + } } - _, _, _ = session.cmd(221, "QUIT\r\n") - return nil + + return &smtpConnection{conn: conn, session: session, cancel: cancel}, nil } type session struct { @@ -151,6 +217,17 @@ func (s *session) ehlo(helo string) error { return nil } +func (s *session) authPlain(username, password string) error { + if strings.ContainsRune(username, '\x00') || strings.ContainsRune(password, '\x00') { + return fmt.Errorf("smtp authentication credentials contain invalid data") + } + payload := base64.StdEncoding.EncodeToString([]byte("\x00" + username + "\x00" + password)) + if _, _, err := s.cmd(235, "AUTH PLAIN %s\r\n", payload); err != nil { + return fmt.Errorf("smtp authentication rejected: %w", err) + } + return nil +} + func (s *session) cmd(expect int, format string, args ...any) (int, string, error) { if _, err := fmt.Fprintf(s.w, format, args...); err != nil { return 0, "", err -- cgit v1.2.3