1
0
mirror of https://blitiri.com.ar/repos/chasquid synced 2026-10-28 15:02:28 +00:00
Files
go-chasquid-smtp/internal/smtpsrv/conn_test.go
T
Alberto Bertogli b3a500d9e4 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.
2026-10-12 21:16:01 +01:00

307 lines
7.3 KiB
Go

package smtpsrv
import (
"crypto/ed25519"
"crypto/tls"
"crypto/x509"
"math/big"
"net"
"strings"
"testing"
"time"
"blitiri.com.ar/go/chasquid/internal/domaininfo"
"blitiri.com.ar/go/chasquid/internal/trace"
"blitiri.com.ar/go/spf"
)
func TestSecLevel(t *testing.T) {
// We can't simulate this externally because of the SPF record
// requirement, so do a narrow test on Conn.secLevelCheck.
dinfo, err := domaininfo.New(t.ArtifactDir())
if err != nil {
t.Fatalf("Failed to create domain info: %v", err)
}
c := &Conn{
tr: trace.New("testconn", "testconn"),
dinfo: dinfo,
}
// No SPF, skip security checks.
c.spfResult = spf.None
c.onTLS = true
if !c.secLevelCheck("from@slc") {
t.Fatalf("TLS seclevel failed")
}
c.onTLS = false
if !c.secLevelCheck("from@slc") {
t.Fatalf("plain seclevel failed, even though SPF does not exist")
}
// Now the real checks, once SPF passes.
c.spfResult = spf.Pass
if !c.secLevelCheck("from@slc") {
t.Fatalf("plain seclevel failed")
}
c.onTLS = true
if !c.secLevelCheck("from@slc") {
t.Fatalf("TLS seclevel failed")
}
c.onTLS = false
if c.secLevelCheck("from@slc") {
t.Fatalf("plain seclevel worked, downgrade was allowed")
}
}
func TestIsHeader(t *testing.T) {
no := []string{
"a", "\n", "\n\n", " \n", " ",
"a:b", "a: b\nx: y",
"\na:b\n", " a\nb:c\n",
}
for _, s := range no {
if isHeader([]byte(s)) {
t.Errorf("%q accepted as header, should be rejected", s)
}
}
yes := []string{
"", "a:b\n",
"X-Post-Data: success\n",
}
for _, s := range yes {
if !isHeader([]byte(s)) {
t.Errorf("%q rejected as header, should be accepted", s)
}
}
}
func TestAddrLiteral(t *testing.T) {
// TCP addresses.
casesTCP := []struct {
addr net.IP
expected string
}{
{net.IPv4(1, 2, 3, 4), "1.2.3.4"},
{net.IPv4(0, 0, 0, 0), "0.0.0.0"},
{net.ParseIP("1.2.3.4"), "1.2.3.4"},
{net.ParseIP("2001:db8::68"), "IPv6:2001:db8::68"},
{net.ParseIP("::1"), "IPv6:::1"},
}
for _, c := range casesTCP {
tcp := &net.TCPAddr{
IP: c.addr,
Port: 12345,
}
s := addrLiteral(tcp)
if s != c.expected {
t.Errorf("%v: expected %q, got %q", tcp, c.expected, s)
}
}
// Non-TCP addresses. We expect these to match addr.String().
casesOther := []net.Addr{
&net.UDPAddr{
IP: net.ParseIP("1.2.3.4"),
Port: 12345,
},
}
for _, addr := range casesOther {
s := addrLiteral(addr)
if s != addr.String() {
t.Errorf("%v: expected %q, got %q", addr, addr.String(), s)
}
}
}
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 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) {
equal := []string{
"domain", "do.main", "do-main",
"1.2.3.4", "a:b:c", "[a:b:c]",
"abz", "AbZ",
}
for _, str := range equal {
if got := sanitizeEHLODomain(str); got != str {
t.Errorf("sanitizeEHLODomain(%q) returned %q, expected %q",
str, got, str)
}
}
invalid := []struct {
str string
expected string
}{
{"ñaca", "aca"}, {"a\nb", "ab"}, {"a\x00b", "ab"}, {"a\x7fb", "ab"},
{"a/z", "az"}, {"a;b", "ab"}, {"a$b", "ab"}, {"a^b", "ab"},
{"a b", "ab"}, {"a+b", "ab"}, {"a@b", "ab"}, {`a"b`, "ab"},
{`a\b`, "ab"},
}
for _, c := range invalid {
if got := sanitizeEHLODomain(c.str); got != c.expected {
t.Errorf("sanitizeEHLODomain(%q) returned %q, expected %q",
c.str, got, c.expected)
}
}
}
func TestEHLODomainIsSanitized(t *testing.T) {
cases := []struct {
params string
expected string
}{
{"mx.example.com", "mx.example.com"},
{"[1.2.3.4] extra params", "[1.2.3.4]"},
{"evil;(x", "evilx"},
{"!!!", invalidEHLODomain},
}
for _, c := range cases {
for _, cmd := range []string{"HELO", "EHLO"} {
conn := &Conn{}
if cmd == "HELO" {
conn.HELO(c.params)
} else {
conn.EHLO(c.params)
}
if conn.ehloDomain != c.expected {
t.Errorf("%s %q: got EHLO domain %q, expected %q",
cmd, c.params, conn.ehloDomain, c.expected)
}
}
}
}
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)
}
}
}