mirror of
https://blitiri.com.ar/repos/chasquid
synced 2026-10-28 15:02:28 +00:00
9de93edf77
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.
237 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|