git » chasquid » commit 9de93ed

smtpsrv: Reject MAIL FROM/RCPT TO addresses with control characters

author Alberto Bertogli
2026-10-12 19:42:51 UTC
committer Alberto Bertogli
2026-10-12 19:47:51 UTC
parent f3374e7923543fedcf1487751b50efa727c9fd76

smtpsrv: Reject MAIL FROM/RCPT TO addresses with control characters

Today we rely on net/mail.ParseAddress to do most of the validation of
the addresses, and it is quite effective at it. But it does not reject
addresses with control characters, which could end up verbatim in
problematic places (e.g. logs).

Specifically the C1 characters, like U+009B (Control Sequence
Introducer) will pass through and could be problematic, since it is
interpreted by some terminals.

It also allows (quoted) parenthesis, which could confuse Received header
parsers. This is mainly harmless, but it can be confusing when
troubleshooting.

Today, we also reject addresses with more than one @, but as a
side-effect of domain validation.

To reduce the chances of problems later, this patch rejects addresses if
they contain control characters (which is a behaviour change), or more
than one @ (which is pre-existing, but now we fail in a more explicit
way).

It also escapes the addresses containing parenthesis in the Received
header, to make debugging info clearer, and keep the header structure
compliant.

internal/smtpsrv/conn.go +26 -2
internal/smtpsrv/conn_test.go +66 -0
internal/smtpsrv/server_test.go +37 -0

diff --git a/internal/smtpsrv/conn.go b/internal/smtpsrv/conn.go
index ba01e0e..a35bae2 100644
--- a/internal/smtpsrv/conn.go
+++ b/internal/smtpsrv/conn.go
@@ -19,6 +19,7 @@ import (
 	"strings"
 	"syscall"
 	"time"
+	"unicode"
 
 	"blitiri.com.ar/go/chasquid/internal/aliases"
 	"blitiri.com.ar/go/chasquid/internal/auth"
@@ -468,6 +469,10 @@ func (c *Conn) MAIL(params string) (code int, msg string) {
 			return 501, "5.1.8 Sender address must contain a domain"
 		}
 
+		if !isSafeAddress(addr) {
+			return 501, "5.1.7 Sender address malformed"
+		}
+
 		// https://tools.ietf.org/html/rfc5321#section-4.5.3.1.3
 		if len(addr) > 256 {
 			return 501, "5.1.7 Sender address too long"
@@ -594,7 +599,7 @@ func (c *Conn) RCPT(params string) (code int, msg string) {
 	}
 
 	e, err := mail.ParseAddress(rawAddr)
-	if err != nil || e.Address == "" {
+	if err != nil || e.Address == "" || !isSafeAddress(e.Address) {
 		return 501, "5.1.3 Malformed destination address"
 	}
 
@@ -788,7 +793,10 @@ func (c *Conn) addReceivedHeader() {
 	}
 
 	// Note we must NOT include c.rcptTo, that would leak BCCs.
-	received += fmt.Sprintf("envelope from %q)\n", c.mailFrom)
+	// We are inside a comment, where quotes have no special meaning, so we
+	// also escape "(" and ")" to prevent them from altering its structure.
+	received += fmt.Sprintf("envelope from %s)\n",
+		commentEscaper.Replace(fmt.Sprintf("%q", c.mailFrom)))
 
 	// This should be the last part in the Received header, by RFC.
 	// The ";" is a mandatory separator. The date format is not standard but
@@ -829,6 +837,10 @@ func (c *Conn) addReceivedHeader() {
 	}
 }
 
+// Escape "(" and ")" with quoted-pairs, for use inside header comments.
+// https://datatracker.ietf.org/doc/html/rfc5322#section-3.2.2
+var commentEscaper = strings.NewReplacer("(", `\(`, ")", `\)`)
+
 // addrLiteral converts a net.Addr (must be TCP) into a string for use as
 // address literal, compliant with
 // https://tools.ietf.org/html/rfc5321#section-4.1.3.
@@ -871,6 +883,18 @@ func checkData(data []byte) error {
 	return nil
 }
 
+// isSafeAddress checks that the address (as returned by mail.ParseAddress,
+// which removes the quoting from the local part) doesn't contain characters
+// that could be problematic where we use it:
+//   - Control characters (e.g. in logs or hooks). CR, LF and other
+//     whitespace are already excluded by how we parse the commands.
+//   - More than one "@" (possible with quoted local parts like "a@b"@c), as
+//     we split addresses on the first one.
+func isSafeAddress(addr string) bool {
+	return strings.Count(addr, "@") == 1 &&
+		!strings.ContainsFunc(addr, unicode.IsControl)
+}
+
 // Placeholder EHLO domain, used when the one given by the client has no valid
 // characters. The .invalid TLD is reserved for names that are guaranteed to
 // be invalid (RFC 6761).
diff --git a/internal/smtpsrv/conn_test.go b/internal/smtpsrv/conn_test.go
index 75e538a..552c452 100644
--- a/internal/smtpsrv/conn_test.go
+++ b/internal/smtpsrv/conn_test.go
@@ -2,6 +2,7 @@ package smtpsrv
 
 import (
 	"net"
+	"strings"
 	"testing"
 
 	"blitiri.com.ar/go/chasquid/internal/domaininfo"
@@ -113,6 +114,45 @@ func TestAddrLiteral(t *testing.T) {
 	}
 }
 
+func TestReceivedHeaderEnvelopeFrom(t *testing.T) {
+	c := &Conn{
+		hostname:   "mx.example.com",
+		ehloDomain: "client.example.com",
+		remoteAddr: &net.TCPAddr{IP: net.ParseIP("192.0.2.1"), Port: 1234},
+		tr:         trace.New("test", "TestReceivedHeaderEnvelopeFrom"),
+		// The local part could have been quoted, see isSafeAddress.
+		mailFrom: `x)(y; z\@example.org`,
+		data:     []byte("Subject: test\n\nbody\n"),
+	}
+	defer c.tr.Finish()
+	c.addReceivedHeader()
+
+	hdr, _, _ := strings.Cut(string(c.data), "Subject:")
+	want := `envelope from "x\)\(y; z\\@example.org")`
+	if !strings.Contains(hdr, want) {
+		t.Errorf("Received header does not contain %q:\n%s", want, hdr)
+	}
+
+	// Parentheses must be balanced, considering the escaped ones.
+	depth := 0
+	for i := 0; i < len(hdr); i++ {
+		switch hdr[i] {
+		case '\\':
+			i++ // Skip the escaped character.
+		case '(':
+			depth++
+		case ')':
+			depth--
+			if depth < 0 {
+				t.Fatalf("unbalanced ')' at %d:\n%s", i, hdr)
+			}
+		}
+	}
+	if depth != 0 {
+		t.Errorf("unbalanced comments (depth %d):\n%s", depth, hdr)
+	}
+}
+
 func TestSanitizeEHLODomain(t *testing.T) {
 	equal := []string{
 		"domain", "do.main", "do-main",
@@ -168,3 +208,29 @@ func TestEHLODomainIsSanitized(t *testing.T) {
 		}
 	}
 }
+
+func TestIsSafeAddress(t *testing.T) {
+	safe := []string{
+		"john@example.com", "ñandú@example.com", "用户@例子.广告",
+		// Characters that need quoting are fine, we're lenient with them.
+		"a;b@example.com", "a(b)@example.com", `a"b@example.com`,
+	}
+	for _, addr := range safe {
+		if !isSafeAddress(addr) {
+			t.Errorf("%q: expected safe, got unsafe", addr)
+		}
+	}
+
+	unsafe := []string{
+		// More than one "@" (or none).
+		"a@b@example.com", "@a@example.com", "example.com",
+		// Control characters (C1, U+009B is CSI).
+		"a\u0081b@example.com", "a\u009b31mb@example.com",
+		"a@exam\u0085ple.com",
+	}
+	for _, addr := range unsafe {
+		if isSafeAddress(addr) {
+			t.Errorf("%q: expected unsafe, got safe", addr)
+		}
+	}
+}
diff --git a/internal/smtpsrv/server_test.go b/internal/smtpsrv/server_test.go
index 7fd3d84..3540237 100644
--- a/internal/smtpsrv/server_test.go
+++ b/internal/smtpsrv/server_test.go
@@ -277,6 +277,43 @@ func TestMalformedDomain(t *testing.T) {
 	expectTPErr(t, err, 501, "5.1.2 Malformed destination domain")
 }
 
+func TestUnsafeAddress(t *testing.T) {
+	// Unauthenticated MAIL FROM.
+	c := mustDial(t, ModeSMTP, false)
+	defer c.Close()
+
+	err := c.Mail(`"a@b"@from`)
+	expectTPErr(t, err, 501, "5.1.7 Sender address malformed")
+
+	err = c.Mail("a\u009bb@from")
+	expectTPErr(t, err, 501, "5.1.7 Sender address malformed")
+
+	// Characters that need quoting are fine.
+	if err := c.Mail(`"a;b"@from`); err != nil {
+		t.Errorf("Mail: %v", err)
+	}
+
+	// Authenticated RCPT TO. We need to authenticate, otherwise it may get
+	// rejected as relaying.
+	c = mustDial(t, ModeSubmission, true)
+	defer c.Close()
+
+	auth := smtp.PlainAuth("", "testuser@localhost", "testpasswd", "127.0.0.1")
+	if err := c.Auth(auth); err != nil {
+		t.Fatalf("Auth: %v", err)
+	}
+
+	if err := c.Mail("testuser@localhost"); err != nil {
+		t.Fatalf("Mail: %v", err)
+	}
+
+	err = c.Rcpt(`"a@b"@remote`)
+	expectTPErr(t, err, 501, "5.1.3 Malformed destination address")
+
+	err = c.Rcpt("a\u009bb@remote")
+	expectTPErr(t, err, 501, "5.1.3 Malformed destination address")
+}
+
 func TestNullMailFrom(t *testing.T) {
 	c := mustDial(t, ModeSMTP, false)
 	defer c.Close()