diff --git a/internal/web/service/email/email.go b/internal/web/service/email/email.go index 7031183fa..f6f2a0c95 100644 --- a/internal/web/service/email/email.go +++ b/internal/web/service/email/email.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "crypto/tls" "encoding/hex" + "errors" "fmt" "mime" "net" @@ -42,6 +43,54 @@ const smtpConnectTimeout = 10 * time.Second var smtpDeadline = 30 * time.Second +// smtpMaxServerBytes caps what one session reads from the server, TLS handshake +// included: net/smtp buffers a reply of any length, so the deadline alone is no bound. +const smtpMaxServerBytes = 1 << 20 + +var errSMTPReplyTooLarge = errors.New("smtp server sent more data than a session may read") + +// limitedConn fails every Read once the peer has sent remaining bytes. +type limitedConn struct { + net.Conn + remaining int64 +} + +func (c *limitedConn) Read(p []byte) (int, error) { + if c.remaining <= 0 { + return 0, errSMTPReplyTooLarge + } + if int64(len(p)) > c.remaining { + p = p[:c.remaining] + } + n, err := c.Conn.Read(p) + c.remaining -= int64(n) + return n, err +} + +// dialSMTP opens a connection capped at smtpMaxServerBytes; timeout covers the +// dial and, with implicitTLS, the handshake. +func dialSMTP(addr, host string, timeout time.Duration, implicitTLS bool) (net.Conn, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + raw, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr) + if err != nil { + return nil, err + } + // The cap sits below TLS: net/smtp allows PLAIN auth only on a *tls.Conn, + // and this keeps it in force across a later STARTTLS upgrade. + limited := &limitedConn{Conn: raw, remaining: smtpMaxServerBytes} + if !implicitTLS { + return limited, nil + } + conn := tls.Client(limited, &tls.Config{ServerName: host}) + if err = conn.HandshakeContext(ctx); err != nil { + raw.Close() + return nil, err + } + return conn, nil +} + // Send sends an HTML email to all configured recipients. func (s *EmailService) Send(subject, body string) error { host, err := s.settingService.GetSmtpHost() @@ -139,19 +188,7 @@ func (s *EmailService) TestConnection() SMTPTestResult { addr := net.JoinHostPort(host, fmt.Sprintf("%d", port)) // Stage 1: Connect - var conn net.Conn - dialer := &net.Dialer{Timeout: 5 * time.Second} - - switch encryptionType { - case "tls": - conn, err = (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{ - ServerName: host, - InsecureSkipVerify: false, - }}).DialContext(context.Background(), "tcp", addr) - default: - conn, err = dialer.Dial("tcp", addr) - } - + conn, err := dialSMTP(addr, host, 5*time.Second, encryptionType == "tls") if err != nil { return SMTPTestResult{false, "connect", classifySMTPError(err)} } @@ -216,12 +253,7 @@ func (s *EmailService) TestConnection() SMTPTestResult { } func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error { - // Dial with explicit timeout - dialer := &net.Dialer{Timeout: smtpConnectTimeout} - conn, err := (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{ - ServerName: host, - InsecureSkipVerify: false, - }}).DialContext(context.Background(), "tcp", addr) + conn, err := dialSMTP(addr, host, smtpConnectTimeout, true) if err != nil { return err } @@ -266,7 +298,7 @@ func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to // timeout and arms a connection deadline, so a server that never speaks or // stalls mid-protocol cannot block the sender goroutine past smtpDeadline. func (s *EmailService) sendPlain(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error { - conn, err := (&net.Dialer{Timeout: smtpConnectTimeout}).Dial("tcp", addr) + conn, err := dialSMTP(addr, host, smtpConnectTimeout, false) if err != nil { return err } diff --git a/internal/web/service/email/email_deadline_test.go b/internal/web/service/email/email_deadline_test.go index 17d36c423..dc926f2aa 100644 --- a/internal/web/service/email/email_deadline_test.go +++ b/internal/web/service/email/email_deadline_test.go @@ -1,6 +1,9 @@ package email import ( + "bufio" + "bytes" + "errors" "net" "testing" "time" @@ -44,3 +47,49 @@ func TestSendPlainReturnsOnStalledServer(t *testing.T) { t.Fatal("sendPlain did not return on a stalled server within the deadline") } } + +// The flood is capped well above smtpMaxServerBytes: without the cap the client +// buffers it all and then fails on the closed socket, not errSMTPReplyTooLarge. +func TestSendPlainStopsReadingOversizedReply(t *testing.T) { + tests := []struct { + name string + greeting string + flood []byte + }{ + {"greeting line that never ends", "220 ", bytes.Repeat([]byte("a"), 64<<10)}, + {"EHLO reply that never ends", "220 ready\r\n", bytes.Repeat([]byte("250-X\r\n"), 8<<10)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + _, _ = conn.Write([]byte(tt.greeting)) + if tt.greeting[len(tt.greeting)-1] == '\n' { + _, _ = bufio.NewReader(conn).ReadString('\n') + } + for sent := 0; sent < 16<<20; sent += len(tt.flood) { + if _, err := conn.Write(tt.flood); err != nil { + return + } + } + }() + + s := &EmailService{} + err = s.sendPlain(ln.Addr().String(), nil, "from@example.com", + []string{"to@example.com"}, []byte("body"), "example.com") + if !errors.Is(err, errSMTPReplyTooLarge) { + t.Fatalf("sendPlain error = %v, want %v", err, errSMTPReplyTooLarge) + } + }) + } +}