- 会话 cookie 增加 Secure 标志;新增 [web].cookie_secure 配置 (默认 true,仅本地 HTTP 调试关闭;缺失字段按安全默认处理) - SMTP/IMAP/POP3 认证接入封禁体系(store.RecordAuthFailure 与 Web 共用 ban_entries):失败计数达 ban.max_fail_attempts 即封禁 IP, 已封禁 IP 拒绝认证,堵住协议层暴力破解 - 附件存储路径遍历防护重写:FullPath 白名单校验(UUID 文件名格式) + baseDir 前缀兜底,非法路径返回错误;Save 扩展名白名单化 - 初始管理员不再使用 admin/admin:密码取 MAILGO_ADMIN_PASSWORD 或 随机生成并打印一次;新增 MustChangePassword 首登强制改密 (管理员重置密码同样触发) - 外发中继默认验证 TLS 证书(保护 AUTH 凭据,防 MITM),直投 MX 保持机会式 TLS;新增 outbound.relay_tls_insecure 开关(默认 false) - 新增安全响应头中间件:HSTS、X-Frame-Options DENY、nosniff、 Referrer-Policy、基础 CSP(frame-ancestors 'none' 防点击劫持, connect-src/form-action 'self' 防数据外泄) - LDAP/OAuth 登录错误统一为通用文案,原始错误只写日志, 不再回显邮箱/内部细节(防用户枚举与信息泄露) - 新增 25 个回归测试:cookie 标志、封禁阈值、路径遍历用例、 中继 TLS 验证(自签证书 STARTTLS 集成)、安全头、OAuth 文案 部署注意:升级后所有会话失效需重新登录;若直接以 HTTP 提供 服务需显式配置 cookie_secure = false。
520 lines
14 KiB
Go
520 lines
14 KiB
Go
package outbound
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"math/big"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeSMTPServer captures a single SMTP transaction for inspection.
|
|
type fakeSMTPServer struct {
|
|
ln net.Listener
|
|
done chan struct{}
|
|
gotData []byte
|
|
cmds []string
|
|
err error
|
|
}
|
|
|
|
func startFakeSMTPServer(t *testing.T) *fakeSMTPServer {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
f := &fakeSMTPServer{ln: ln, done: make(chan struct{})}
|
|
go f.serve()
|
|
t.Cleanup(func() {
|
|
ln.Close()
|
|
<-f.done
|
|
})
|
|
return f
|
|
}
|
|
|
|
func (f *fakeSMTPServer) serve() {
|
|
defer close(f.done)
|
|
conn, err := f.ln.Accept()
|
|
if err != nil {
|
|
f.err = err
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
r := bufio.NewReader(conn)
|
|
w := bufio.NewWriter(conn)
|
|
|
|
write := func(s string) {
|
|
_, _ = w.WriteString(s)
|
|
_ = w.Flush()
|
|
}
|
|
|
|
write("220 fake.test ESMTP ready\r\n")
|
|
|
|
for {
|
|
line, err := r.ReadString('\n')
|
|
if err != nil {
|
|
f.err = err
|
|
return
|
|
}
|
|
line = strings.TrimRight(line, "\r\n")
|
|
f.cmds = append(f.cmds, line)
|
|
|
|
switch {
|
|
case strings.HasPrefix(strings.ToUpper(line), "EHLO"), strings.HasPrefix(strings.ToUpper(line), "HELO"):
|
|
write("250-fake.test\r\n250 8BITMIME\r\n")
|
|
case strings.HasPrefix(strings.ToUpper(line), "MAIL FROM"):
|
|
write("250 2.0.0 ok\r\n")
|
|
case strings.HasPrefix(strings.ToUpper(line), "RCPT TO"):
|
|
write("250 2.0.0 ok\r\n")
|
|
case strings.HasPrefix(strings.ToUpper(line), "DATA"):
|
|
write("354 go ahead\r\n")
|
|
// Read until the terminating dot line, un-stuffing dot lines
|
|
// exactly like a real SMTP receiver.
|
|
for {
|
|
dl, err := r.ReadString('\n')
|
|
if err != nil {
|
|
f.err = err
|
|
return
|
|
}
|
|
if strings.TrimRight(dl, "\r\n") == "." {
|
|
break
|
|
}
|
|
if strings.HasPrefix(dl, "..") {
|
|
dl = dl[1:]
|
|
}
|
|
f.gotData = append(f.gotData, []byte(dl)...)
|
|
}
|
|
write("250 2.0.0 queued\r\n")
|
|
case strings.HasPrefix(strings.ToUpper(line), "QUIT"):
|
|
write("221 bye\r\n")
|
|
return
|
|
default:
|
|
write("500 huh\r\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *fakeSMTPServer) port() int {
|
|
return f.ln.Addr().(*net.TCPAddr).Port
|
|
}
|
|
|
|
func TestMailerWireFormat(t *testing.T) {
|
|
f := startFakeSMTPServer(t)
|
|
|
|
m := NewMailer("mail.lmve.net", 10*time.Second)
|
|
m.Port = f.port()
|
|
|
|
input := []byte("From: a@lmve.net\r\n" +
|
|
"To: b@fake.test\r\n" +
|
|
"Subject: wire test\r\n" +
|
|
"Content-Type: text/plain; charset=utf-8\r\n" +
|
|
"\r\n" +
|
|
"line one\r\n" +
|
|
".leading dot must be stuffed\r\n" +
|
|
"line three\r\n")
|
|
|
|
resp, err := m.deliverToHost("127.0.0.1", "a@lmve.net", "b@fake.test", input)
|
|
if err != nil {
|
|
t.Fatalf("deliverToHost: %v", err)
|
|
}
|
|
if !strings.HasPrefix(resp, "250") {
|
|
t.Fatalf("unexpected response: %q", resp)
|
|
}
|
|
|
|
if f.err != nil {
|
|
t.Fatalf("fake server error: %v", f.err)
|
|
}
|
|
if len(f.gotData) == 0 {
|
|
t.Fatal("no DATA received")
|
|
}
|
|
|
|
// The captured data must be dot-UNstuffed: identical to the input.
|
|
// (The fake server un-stuffs dot lines like a real receiver, so a match
|
|
// here proves the mailer applied correct dot-stuffing on the wire.)
|
|
if string(f.gotData) != string(input) {
|
|
t.Fatalf("wire data mismatch.\ngot: %q\nwant: %q", f.gotData, input)
|
|
}
|
|
}
|
|
|
|
func TestMailer8BitMIME(t *testing.T) {
|
|
f := startFakeSMTPServer(t)
|
|
|
|
m := NewMailer("mail.lmve.net", 10*time.Second)
|
|
m.Port = f.port()
|
|
|
|
// 8-bit body (UTF-8) with a server that advertises 8BITMIME.
|
|
input := []byte("From: a@lmve.net\r\nTo: b@fake.test\r\nSubject: 8bit\r\n\r\n你好世界\r\n")
|
|
if _, err := m.deliverToHost("127.0.0.1", "a@lmve.net", "b@fake.test", input); err != nil {
|
|
t.Fatalf("deliverToHost: %v", err)
|
|
}
|
|
|
|
found := false
|
|
for _, cmd := range f.cmds {
|
|
if strings.HasPrefix(strings.ToUpper(cmd), "MAIL FROM") {
|
|
if strings.Contains(strings.ToUpper(cmd), "BODY=8BITMIME") {
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected MAIL FROM with BODY=8BITMIME, got: %v", f.cmds)
|
|
}
|
|
}
|
|
|
|
func TestMailerPermanentFailure(t *testing.T) {
|
|
// A fake server that rejects the recipient with 550.
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
defer ln.Close()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
r := bufio.NewReader(conn)
|
|
w := bufio.NewWriter(conn)
|
|
_, _ = w.WriteString("220 reject.test ESMTP\r\n")
|
|
_ = w.Flush()
|
|
for {
|
|
line, err := r.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(strings.ToUpper(line), "EHLO"):
|
|
_, _ = w.WriteString("250 reject.test\r\n")
|
|
_ = w.Flush()
|
|
case strings.HasPrefix(strings.ToUpper(line), "MAIL"):
|
|
_, _ = w.WriteString("250 ok\r\n")
|
|
_ = w.Flush()
|
|
case strings.HasPrefix(strings.ToUpper(line), "RCPT"):
|
|
_, _ = w.WriteString("550 5.1.1 no such user\r\n")
|
|
_ = w.Flush()
|
|
case strings.HasPrefix(strings.ToUpper(line), "QUIT"):
|
|
_, _ = w.WriteString("221 bye\r\n")
|
|
_ = w.Flush()
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
m := NewMailer("mail.lmve.net", 10*time.Second)
|
|
m.Port = ln.Addr().(*net.TCPAddr).Port
|
|
|
|
input := []byte("From: a@lmve.net\r\nTo: b@reject.test\r\nSubject: t\r\n\r\nbody\r\n")
|
|
_, err = m.deliverToHost("127.0.0.1", "a@lmve.net", "b@reject.test", input)
|
|
if err == nil {
|
|
t.Fatal("expected error for 550 rejection")
|
|
}
|
|
de, ok := err.(*DeliveryError)
|
|
if !ok {
|
|
t.Fatalf("expected *DeliveryError, got %T: %v", err, err)
|
|
}
|
|
if !de.Permanent {
|
|
t.Fatalf("expected permanent error, got %+v", de)
|
|
}
|
|
if de.Code != 550 {
|
|
t.Fatalf("expected code 550, got %d", de.Code)
|
|
}
|
|
<-done
|
|
}
|
|
|
|
func TestMailerSmarthostRelay(t *testing.T) {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
defer ln.Close()
|
|
|
|
type result struct {
|
|
gotData []byte
|
|
authLine string
|
|
}
|
|
ch := make(chan result, 1)
|
|
|
|
go func() {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
ch <- result{}
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
r := bufio.NewReader(conn)
|
|
w := bufio.NewWriter(conn)
|
|
_, _ = w.WriteString("220 relay.test ESMTP\r\n")
|
|
_ = w.Flush()
|
|
|
|
var authLine string
|
|
var got []byte
|
|
for {
|
|
line, err := r.ReadString('\n')
|
|
if err != nil {
|
|
break
|
|
}
|
|
trimmed := strings.TrimRight(line, "\r\n")
|
|
up := strings.ToUpper(trimmed)
|
|
switch {
|
|
case strings.HasPrefix(up, "EHLO"):
|
|
_, _ = w.WriteString("250-relay.test\r\n250-8BITMIME\r\n250 AUTH PLAIN\r\n")
|
|
_ = w.Flush()
|
|
case strings.HasPrefix(up, "AUTH PLAIN"):
|
|
authLine = trimmed
|
|
_, _ = w.WriteString("235 2.0.0 ok\r\n")
|
|
_ = w.Flush()
|
|
case strings.HasPrefix(up, "MAIL FROM"):
|
|
if authLine == "" {
|
|
_, _ = w.WriteString("530 5.7.0 auth required\r\n")
|
|
_ = w.Flush()
|
|
break
|
|
}
|
|
_, _ = w.WriteString("250 ok\r\n")
|
|
_ = w.Flush()
|
|
case strings.HasPrefix(up, "RCPT TO"):
|
|
_, _ = w.WriteString("250 ok\r\n")
|
|
_ = w.Flush()
|
|
case strings.HasPrefix(up, "DATA"):
|
|
_, _ = w.WriteString("354 go\r\n")
|
|
_ = w.Flush()
|
|
for {
|
|
dl, err := r.ReadString('\n')
|
|
if err != nil {
|
|
break
|
|
}
|
|
if strings.TrimRight(dl, "\r\n") == "." {
|
|
break
|
|
}
|
|
if strings.HasPrefix(dl, "..") {
|
|
dl = dl[1:]
|
|
}
|
|
got = append(got, []byte(dl)...)
|
|
}
|
|
_, _ = w.WriteString("250 queued\r\n")
|
|
_ = w.Flush()
|
|
case strings.HasPrefix(up, "QUIT"):
|
|
_, _ = w.WriteString("221 bye\r\n")
|
|
_ = w.Flush()
|
|
ch <- result{gotData: got, authLine: authLine}
|
|
return
|
|
}
|
|
}
|
|
ch <- result{}
|
|
}()
|
|
|
|
m := NewMailer("mail.lmve.net", 10*time.Second)
|
|
m.Relay = &RelayConfig{
|
|
Host: "127.0.0.1",
|
|
Port: ln.Addr().(*net.TCPAddr).Port,
|
|
Username: "relay-user",
|
|
Password: "relay-pass",
|
|
StartTLS: false,
|
|
}
|
|
|
|
// The recipient domain does not even exist — with a relay configured,
|
|
// no MX lookup happens and the relay still receives the message.
|
|
input := []byte("From: a@lmve.net\r\nTo: b@bogus-domain.invalid\r\nSubject: relay\r\n\r\nbody\r\n")
|
|
resp, err := m.Deliver("a@lmve.net", "b@bogus-domain.invalid", input)
|
|
if err != nil {
|
|
t.Fatalf("Deliver via relay: %v", err)
|
|
}
|
|
if !strings.HasPrefix(resp, "250") {
|
|
t.Fatalf("unexpected relay response: %q", resp)
|
|
}
|
|
|
|
res := <-ch
|
|
if res.authLine == "" {
|
|
t.Fatal("relay did not receive AUTH PLAIN")
|
|
}
|
|
wantAuth := "AUTH PLAIN " + base64.StdEncoding.EncodeToString([]byte("\x00relay-user\x00relay-pass"))
|
|
if res.authLine != wantAuth {
|
|
t.Fatalf("auth line mismatch: got %q want %q", res.authLine, wantAuth)
|
|
}
|
|
if string(res.gotData) != string(input) {
|
|
t.Fatalf("relay data mismatch.\ngot: %q\nwant: %q", res.gotData, input)
|
|
}
|
|
}
|
|
|
|
// startTLSSMTPServer 起一个支持 STARTTLS 的 SMTP 服务器(自签证书),
|
|
// 供 relay TLS 验证测试使用:未升级 TLS 时广告 STARTTLS 能力,
|
|
// 收到 STARTTLS 后升级为 TLS 并重新 EHLO。
|
|
func startTLSSMTPServer(t *testing.T) (addr string, cleanup func()) {
|
|
t.Helper()
|
|
cert, err := tls.X509KeyPair(makeSelfSignedCertPEM(t))
|
|
if err != nil {
|
|
t.Fatalf("load self-signed cert: %v", err)
|
|
}
|
|
tlsCfg := &tls.Config{Certificates: []tls.Certificate{cert}}
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
|
|
go func() {
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go func() {
|
|
defer conn.Close()
|
|
r := bufio.NewReader(conn)
|
|
w := bufio.NewWriter(conn)
|
|
_, _ = w.WriteString("220 relay.test ESMTP ready\r\n")
|
|
_ = w.Flush()
|
|
for {
|
|
line, err := r.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
up := strings.ToUpper(strings.TrimRight(line, "\r\n"))
|
|
switch {
|
|
case strings.HasPrefix(up, "STARTTLS"):
|
|
_, _ = w.WriteString("220 2.0.0 ready to start TLS\r\n")
|
|
_ = w.Flush()
|
|
tlsConn := tls.Server(conn, tlsCfg)
|
|
if err := tlsConn.Handshake(); err != nil {
|
|
return
|
|
}
|
|
conn = tlsConn
|
|
r = bufio.NewReader(conn)
|
|
w = bufio.NewWriter(conn)
|
|
case strings.HasPrefix(up, "EHLO"), strings.HasPrefix(up, "HELO"):
|
|
_, _ = w.WriteString("250-relay.test\r\n250-STARTTLS\r\n250 8BITMIME\r\n")
|
|
_ = w.Flush()
|
|
case strings.HasPrefix(up, "AUTH PLAIN"):
|
|
_, _ = w.WriteString("235 2.0.0 ok\r\n")
|
|
_ = w.Flush()
|
|
case strings.HasPrefix(up, "DATA"):
|
|
_, _ = w.WriteString("354 go ahead\r\n")
|
|
_ = w.Flush()
|
|
for {
|
|
dl, err := r.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
if strings.TrimRight(dl, "\r\n") == "." {
|
|
break
|
|
}
|
|
}
|
|
_, _ = w.WriteString("250 2.0.0 queued\r\n")
|
|
_ = w.Flush()
|
|
case strings.HasPrefix(up, "QUIT"):
|
|
_, _ = w.WriteString("221 bye\r\n")
|
|
_ = w.Flush()
|
|
return
|
|
default:
|
|
_, _ = w.WriteString("250 ok\r\n")
|
|
_ = w.Flush()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
}()
|
|
|
|
addr = ln.Addr().String()
|
|
return addr, func() { ln.Close() }
|
|
}
|
|
|
|
// makeSelfSignedCertPEM 生成一对自签证书(CN=relay.test)。
|
|
func makeSelfSignedCertPEM(t *testing.T) (certPEM, keyPEM []byte) {
|
|
t.Helper()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatalf("generate key: %v", err)
|
|
}
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{CommonName: "relay.test"},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().Add(24 * time.Hour),
|
|
DNSNames: []string{"relay.test"},
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatalf("create certificate: %v", err)
|
|
}
|
|
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
|
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
|
return certPEM, keyPEM
|
|
}
|
|
|
|
// TestMailerRelayRejectsUntrustedCert 中继使用自签证书时默认必须拒绝
|
|
// (证书验证开启,防止凭据被中间人截获)。
|
|
func TestMailerRelayRejectsUntrustedCert(t *testing.T) {
|
|
addr, cleanup := startTLSSMTPServer(t)
|
|
defer cleanup()
|
|
|
|
m := NewMailer("mail.lmve.net", 5*time.Second)
|
|
m.Relay = &RelayConfig{
|
|
Host: "127.0.0.1",
|
|
Port: mustPort(t, addr),
|
|
Username: "relay-user",
|
|
Password: "relay-pass",
|
|
TLSInsecure: false,
|
|
}
|
|
|
|
input := []byte("From: a@lmve.net\r\nTo: b@bogus-domain.invalid\r\nSubject: relay\r\n\r\nbody\r\n")
|
|
_, err := m.Deliver("a@lmve.net", "b@bogus-domain.invalid", input)
|
|
if err == nil {
|
|
t.Fatal("relay with untrusted self-signed cert should be rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), "certificate") {
|
|
t.Fatalf("expected certificate verification error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestMailerRelayInsecureSkipsVerification 显式开启 relay_tls_insecure
|
|
// 后,自签证书的中继可以完成 TLS 握手并进入 SMTP 会话。
|
|
func TestMailerRelayInsecureSkipsVerification(t *testing.T) {
|
|
addr, cleanup := startTLSSMTPServer(t)
|
|
defer cleanup()
|
|
|
|
m := NewMailer("mail.lmve.net", 5*time.Second)
|
|
m.Relay = &RelayConfig{
|
|
Host: "127.0.0.1",
|
|
Port: mustPort(t, addr),
|
|
Username: "relay-user",
|
|
Password: "relay-pass",
|
|
TLSInsecure: true,
|
|
}
|
|
|
|
input := []byte("From: a@lmve.net\r\nTo: b@bogus-domain.invalid\r\nSubject: relay\r\n\r\nbody\r\n")
|
|
resp, err := m.Deliver("a@lmve.net", "b@bogus-domain.invalid", input)
|
|
if err != nil {
|
|
t.Fatalf("relay with TLSInsecure should proceed: %v", err)
|
|
}
|
|
if !strings.HasPrefix(resp, "250") {
|
|
t.Fatalf("unexpected response: %q", resp)
|
|
}
|
|
}
|
|
|
|
func mustPort(t *testing.T, addr string) int {
|
|
t.Helper()
|
|
_, portStr, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
t.Fatalf("split %q: %v", addr, err)
|
|
}
|
|
port, err := strconv.Atoi(portStr)
|
|
if err != nil {
|
|
t.Fatalf("port %q: %v", portStr, err)
|
|
}
|
|
return port
|
|
}
|