1
0
mirror of https://blitiri.com.ar/repos/chasquid synced 2026-10-29 15:02:30 +00:00

smtpsrv: Use SNI as hostname only if it matches one of our certs

Today, the client can give us any SNI, and still complete a valid
negotiation, which means it can be attacker-controlled (including having
problematic characters).

We use that SNI as our hostname in a variety of places, including
Received and Authentication-Results headers. This creates an
opportunity for an attacker to manipulate those headers.

To fix that problem, this patch only takes the SNI if it matches one of
our certs' hostnames. Otherwise we use the configured hostname (same as
if there was no SNI today).

Once Go 1.27 is our minimum supported version, we can simplify this
further by checking the SNI against ConnectionState.LocalCertificate.
This commit is contained in:
Alberto Bertogli
2026-10-12 21:01:21 +01:00
parent 9de93edf77
commit b3a500d9e4
3 changed files with 150 additions and 2 deletions
+31 -2
View File
@@ -214,7 +214,7 @@ func (c *Conn) Handle() {
cstate := tc.ConnectionState() cstate := tc.ConnectionState()
c.tlsConnState = &cstate c.tlsConnState = &cstate
if name := c.tlsConnState.ServerName; name != "" { if name := c.validServerName(); name != "" {
c.hostname = name c.hostname = name
} }
} }
@@ -837,6 +837,35 @@ func (c *Conn) addReceivedHeader() {
} }
} }
// validServerName returns the server name the client requested via TLS SNI,
// if one of our certificates is valid for it; otherwise it returns "".
//
// The SNI is controlled by the client, and Go accepts almost any value (e.g.
// with spaces, ";", or CR/LF), so we must not use it blindly: it ends up in
// our greeting, and the Received and Authentication-Results headers.
// VerifyHostname only accepts valid hostnames, and then checks them against
// the certificate.
//
// TODO: Once we can depend on Go 1.27, use ConnectionState.LocalCertificate
// to check against the certificate we actually presented. Today they are
// almost always the same, and when not, the name is still valid for one of
// our certificates.
func (c *Conn) validServerName() string {
name := c.tlsConnState.ServerName
if name == "" {
return ""
}
for _, cert := range c.tlsConfig.Certificates {
if cert.Leaf != nil && cert.Leaf.VerifyHostname(name) == nil {
return name
}
}
c.tr.Debugf("ignoring SNI %q, no certificate is valid for it", name)
return ""
}
// Escape "(" and ")" with quoted-pairs, for use inside header comments. // Escape "(" and ")" with quoted-pairs, for use inside header comments.
// https://datatracker.ietf.org/doc/html/rfc5322#section-3.2.2 // https://datatracker.ietf.org/doc/html/rfc5322#section-3.2.2
var commentEscaper = strings.NewReplacer("(", `\(`, ")", `\)`) var commentEscaper = strings.NewReplacer("(", `\(`, ")", `\)`)
@@ -1173,7 +1202,7 @@ func (c *Conn) STARTTLS(params string) (code int, msg string) {
// If the client requested a specific server and we complied, that's our // If the client requested a specific server and we complied, that's our
// identity from now on. // identity from now on.
if name := c.tlsConnState.ServerName; name != "" { if name := c.validServerName(); name != "" {
c.hostname = name c.hostname = name
} }
+70
View File
@@ -1,9 +1,14 @@
package smtpsrv package smtpsrv
import ( import (
"crypto/ed25519"
"crypto/tls"
"crypto/x509"
"math/big"
"net" "net"
"strings" "strings"
"testing" "testing"
"time"
"blitiri.com.ar/go/chasquid/internal/domaininfo" "blitiri.com.ar/go/chasquid/internal/domaininfo"
"blitiri.com.ar/go/chasquid/internal/trace" "blitiri.com.ar/go/chasquid/internal/trace"
@@ -153,6 +158,71 @@ func TestReceivedHeaderEnvelopeFrom(t *testing.T) {
} }
} }
func TestValidServerName(t *testing.T) {
// Self-signed certificate valid for mx.example.com and *.example.net.
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
DNSNames: []string{"mx.example.com", "*.example.net"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
}
der, err := x509.CreateCertificate(nil, tmpl, tmpl, pub, priv)
if err != nil {
t.Fatal(err)
}
leaf, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
cert := tls.Certificate{
Certificate: [][]byte{der},
PrivateKey: priv,
Leaf: leaf,
}
cases := []struct {
sni string
want string
}{
{"", ""},
{"mx.example.com", "mx.example.com"},
{"MX.Example.COM", "MX.Example.COM"},
{"a.example.net", "a.example.net"},
{"a.b.example.net", ""},
{"other.example.com", ""},
{"mx.example.com; dkim=pass header.d=example.org", ""},
{"mx.example.com\r\nX-Injected: yes", ""},
{"a;b.example.net", ""},
}
for _, c := range cases {
conn := &Conn{
tlsConfig: &tls.Config{Certificates: []tls.Certificate{cert}},
tlsConnState: &tls.ConnectionState{ServerName: c.sni},
tr: trace.New("test", "TestValidServerName"),
}
if got := conn.validServerName(); got != c.want {
t.Errorf("SNI %q: got %q, want %q", c.sni, got, c.want)
}
conn.tr.Finish()
}
// Without the parsed leaf, we can't check, so the SNI is not used.
cert.Leaf = nil
conn := &Conn{
tlsConfig: &tls.Config{Certificates: []tls.Certificate{cert}},
tlsConnState: &tls.ConnectionState{ServerName: "mx.example.com"},
tr: trace.New("test", "TestValidServerName"),
}
if got := conn.validServerName(); got != "" {
t.Errorf("without leaf: got %q, want \"\"", got)
}
conn.tr.Finish()
}
func TestSanitizeEHLODomain(t *testing.T) { func TestSanitizeEHLODomain(t *testing.T) {
equal := []string{ equal := []string{
"domain", "do.main", "do-main", "domain", "do.main", "do-main",
+49
View File
@@ -550,6 +550,55 @@ func TestStartTLSOnTLS(t *testing.T) {
} }
} }
// The server name requested via SNI must only be used as our hostname if one
// of our certificates is valid for it.
func TestServerNameFromSNI(t *testing.T) {
cases := []struct {
sni string
want string
}{
{"localhost", "localhost"},
{"other.example.com", "localhost"},
{"localhost; dkim=pass header.d=example.com", "localhost"},
}
for _, c := range cases {
cfg := tlsConfig.Clone()
cfg.ServerName = c.sni
cfg.InsecureSkipVerify = true
// STARTTLS, and then EHLO, whose reply starts with our hostname.
sc, err := smtp.Dial(smtpAddr)
if err != nil {
t.Fatalf("smtp.Dial: %v", err)
}
if err := sc.StartTLS(cfg); err != nil {
t.Fatalf("%q: StartTLS: %v", c.sni, err)
}
msg := simpleCmd(t, sc, "EHLO test", 250)
if !strings.HasPrefix(msg, c.want+" ") {
t.Errorf("%q: STARTTLS: EHLO reply %q, expected hostname %q",
c.sni, msg, c.want)
}
sc.Close()
// TLS-wrapped, where the greeting contains our hostname.
conn, err := tls.Dial("tcp", submissionTLSAddr, cfg)
if err != nil {
t.Fatalf("%q: tls.Dial: %v", c.sni, err)
}
_, msg, err = textproto.NewConn(conn).ReadResponse(220)
if err != nil {
t.Fatalf("%q: reading greeting: %v", c.sni, err)
}
if !strings.HasPrefix(msg, c.want+" ") {
t.Errorf("%q: TLS: greeting %q, expected hostname %q",
c.sni, msg, c.want)
}
conn.Close()
}
}
func TestAddDKIMSigner(t *testing.T) { func TestAddDKIMSigner(t *testing.T) {
s := NewServer() s := NewServer()
err := s.AddDKIMSigner("example.com", "selector", "keyfile-does-not-exist") err := s.AddDKIMSigner("example.com", "selector", "keyfile-does-not-exist")