summaryrefslogtreecommitdiffstats
path: root/internal/smtpclient
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-08-24 17:34:45 +0200
committerGab Virebent <gabriel1@virebent.art>2026-08-24 17:34:45 +0200
commite9fbbe3373eb66a345f5e3829e2563b94dc92051 (patch)
tree950d75fda88574afb46b4aeab36e96f3c089a3bb /internal/smtpclient
parentfb83c4d70616ec23d8a5397409a5d31c70b70d66 (diff)
downloadn2usenet-main.tar.gz
n2usenet-main.tar.xz
n2usenet-main.zip
Harden transport and preserve profile identitiesHEADmain
Diffstat (limited to 'internal/smtpclient')
-rw-r--r--internal/smtpclient/client.go141
-rw-r--r--internal/smtpclient/client_test.go99
2 files changed, 208 insertions, 32 deletions
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
diff --git a/internal/smtpclient/client_test.go b/internal/smtpclient/client_test.go
index d9eec6d..e91d128 100644
--- a/internal/smtpclient/client_test.go
+++ b/internal/smtpclient/client_test.go
@@ -3,6 +3,7 @@ package smtpclient
import (
"bufio"
"context"
+ "encoding/base64"
"net"
"strings"
"testing"
@@ -91,3 +92,101 @@ func TestSendUsesConfiguredEnvelopeFrom(t *testing.T) {
t.Fatal("configured envelope sender was not used")
}
}
+
+func TestSessionAuthPlain(t *testing.T) {
+ server, client := net.Pipe()
+ defer client.Close()
+
+ command := make(chan string, 1)
+ go func() {
+ defer server.Close()
+ r := bufio.NewReader(server)
+ line, err := r.ReadString('\n')
+ if err != nil {
+ command <- ""
+ return
+ }
+ command <- line
+ _, _ = server.Write([]byte("235 2.7.0 authentication successful\r\n"))
+ }()
+
+ session := newSession(client)
+ if err := session.authPlain("n2usenet@virebent.art", "test-password"); err != nil {
+ t.Fatalf("authPlain returned error: %v", err)
+ }
+
+ line := <-command
+ const prefix = "AUTH PLAIN "
+ if !strings.HasPrefix(line, prefix) {
+ t.Fatalf("unexpected AUTH command: %q", line)
+ }
+ token := strings.TrimSpace(strings.TrimPrefix(line, prefix))
+ decoded, err := base64.StdEncoding.DecodeString(token)
+ if err != nil {
+ t.Fatalf("decode AUTH payload: %v", err)
+ }
+ if got, want := string(decoded), "\x00n2usenet@virebent.art\x00test-password"; got != want {
+ t.Fatalf("unexpected AUTH payload: got %q want %q", got, want)
+ }
+}
+
+func TestSessionAuthPlainRejectsNUL(t *testing.T) {
+ session := &session{}
+ if err := session.authPlain("n2usenet\x00admin", "test-password"); err == nil {
+ t.Fatal("authPlain accepted a username containing NUL")
+ }
+ if err := session.authPlain("n2usenet", "test\x00password"); err == nil {
+ t.Fatal("authPlain accepted a password containing NUL")
+ }
+}
+
+func TestCheckDoesNotStartMailTransaction(t *testing.T) {
+ server, client := net.Pipe()
+ defer client.Close()
+
+ commands := make(chan string, 8)
+ go func() {
+ defer close(commands)
+ defer server.Close()
+ r := bufio.NewReader(server)
+ w := bufio.NewWriter(server)
+ _, _ = w.WriteString("220 test\r\n")
+ _ = w.Flush()
+ for {
+ line, err := r.ReadString('\n')
+ if err != nil {
+ return
+ }
+ commands <- line
+ switch {
+ case strings.HasPrefix(line, "EHLO "):
+ _, _ = w.WriteString("250 test\r\n")
+ case strings.HasPrefix(line, "QUIT"):
+ _, _ = w.WriteString("221 bye\r\n")
+ _ = w.Flush()
+ return
+ default:
+ _, _ = w.WriteString("500 unexpected\r\n")
+ }
+ _ = w.Flush()
+ }
+ }()
+
+ mailer := New(Config{
+ Host: "mail.virebent.art",
+ Port: 25,
+ HELO: "n2usenet.virebent.art",
+ RequireTLS: false,
+ Timeout: 5 * time.Second,
+ }, func(context.Context, string, string) (net.Conn, error) {
+ return client, nil
+ })
+ if err := mailer.Check(context.Background()); err != nil {
+ t.Fatalf("Check returned error: %v", err)
+ }
+ for command := range commands {
+ if strings.HasPrefix(command, "MAIL ") || strings.HasPrefix(command, "RCPT ") || strings.HasPrefix(command, "DATA") {
+ t.Fatalf("health check started a mail transaction: %q", command)
+ }
+ }
+}