fix(email): cap what one SMTP session reads from the server

net/smtp buffers a server reply of any length, so a mail server, or
anyone on the path before STARTTLS, could send a greeting or EHLO reply
that never ends and grow the panel's heap until the 30 s deadline, on
every notification.

Each session now reads at most 1 MiB from the server, below TLS, and
fails with a clear error past it.
This commit is contained in:
MHSanaei
2026-11-01 13:18:54 +01:00
parent 06cb1742a1
commit 8bbfda0510
2 changed files with 101 additions and 20 deletions
+52 -20
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand" "crypto/rand"
"crypto/tls" "crypto/tls"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"mime" "mime"
"net" "net"
@@ -42,6 +43,54 @@ const smtpConnectTimeout = 10 * time.Second
var smtpDeadline = 30 * 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. // Send sends an HTML email to all configured recipients.
func (s *EmailService) Send(subject, body string) error { func (s *EmailService) Send(subject, body string) error {
host, err := s.settingService.GetSmtpHost() host, err := s.settingService.GetSmtpHost()
@@ -139,19 +188,7 @@ func (s *EmailService) TestConnection() SMTPTestResult {
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port)) addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
// Stage 1: Connect // Stage 1: Connect
var conn net.Conn conn, err := dialSMTP(addr, host, 5*time.Second, encryptionType == "tls")
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)
}
if err != nil { if err != nil {
return SMTPTestResult{false, "connect", classifySMTPError(err)} 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 { func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error {
// Dial with explicit timeout conn, err := dialSMTP(addr, host, smtpConnectTimeout, true)
dialer := &net.Dialer{Timeout: smtpConnectTimeout}
conn, err := (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{
ServerName: host,
InsecureSkipVerify: false,
}}).DialContext(context.Background(), "tcp", addr)
if err != nil { if err != nil {
return err 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 // timeout and arms a connection deadline, so a server that never speaks or
// stalls mid-protocol cannot block the sender goroutine past smtpDeadline. // 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 { 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 { if err != nil {
return err return err
} }
@@ -1,6 +1,9 @@
package email package email
import ( import (
"bufio"
"bytes"
"errors"
"net" "net"
"testing" "testing"
"time" "time"
@@ -44,3 +47,49 @@ func TestSendPlainReturnsOnStalledServer(t *testing.T) {
t.Fatal("sendPlain did not return on a stalled server within the deadline") 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)
}
})
}
}