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 9de93edf77 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.
2026-10-12 20:47:51 +01:00

237 lines
5.5 KiB
Go

package smtpsrv
import (
"net"
"strings"
"testing"
"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 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)
}
}
}