安全审计 #23/#24/#25/#27(security_todo.md P3): - #23 用户名/域名格式校验:用户名白名单 ^[a-zA-Z0-9._-]{1,64}$ (杜绝含 @ 用户名导致的 GetByEmail 解析歧义),域名按标签校验 (字母/数字/连字符、标签 ≤63、总长 ≤253,允许单标签内部域名); CreateUser/UpdateUser/CreateDomain 接入,新增 renderUserFormError 共用错误渲染 - #24 密码最小长度 8 位:统一 minPasswordLength,接入自助修改、 管理员创建、管理员重置三处,新增中/英/日 i18n 文案 - #25 POP3/IMAP 明文认证限制:配置了 TLS 且来源非回环时必须先 STLS/STARTTLS 再认证;新增 store.IsLoopbackIP 与 [imap]/[pop3] allow_insecure_auth 配置项(默认 false 强制)。 顺带修复存量缺陷:POP3S 隐式 TLS 端口 tlsActive 恒为 false (原仅 STLS 命令置位),连接中心注册的 TLS 标记同步修正。 部署注意:默认行为收紧,内网明文老客户端需改用 TLS 或显式 配置 allow_insecure_auth = true - #27 IncrementFail 并发首建窗口丢失更新:冲突 INSERT 由 DoNothing 改为 fail_count 自增,TestIncrementFailConcurrent 在 -race -count=5 下从偶发失败变为稳定通过 - 新增 17 项单测(校验矩阵/handler 拒绝放行路径/settings 密码/ POP3 明文拒绝与放行/回环判定) - security_todo.md 勾选 #23/#24/#25/#27;登记存量备注:4 个 cache=shared 测试在 -count>1 下非幂等(UNIQUE 冲突,与 CI 默认单轮跑法无关)
435 lines
12 KiB
Go
435 lines
12 KiB
Go
package pop3_server
|
||
|
||
import (
|
||
"bufio"
|
||
"crypto/rand"
|
||
"crypto/rsa"
|
||
"crypto/x509"
|
||
"crypto/x509/pkix"
|
||
"encoding/pem"
|
||
"log"
|
||
"math/big"
|
||
"net"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"mail_go/config"
|
||
"mail_go/internal/db"
|
||
"mail_go/internal/storage"
|
||
"mail_go/internal/store"
|
||
"mail_go/internal/tlsutil"
|
||
|
||
"golang.org/x/crypto/bcrypt"
|
||
"gorm.io/driver/sqlite"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
func newTestServer(t *testing.T) *POP3Server {
|
||
t.Helper()
|
||
gdb, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||
if err != nil {
|
||
t.Fatalf("open sqlite: %v", err)
|
||
}
|
||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}, &db.ProtocolLog{}); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
stores := store.NewStores(gdb)
|
||
return &POP3Server{
|
||
stores: stores,
|
||
cfg: config.POP3Config{},
|
||
banCfg: config.BanConfig{MaxFailAttempts: 5, BanDurationMin: 30},
|
||
}
|
||
}
|
||
|
||
// TestHandleConnLogsAuthFailure 验证认证失败的 POP3 会话写入协议日志。
|
||
func TestHandleConnLogsAuthFailure(t *testing.T) {
|
||
s := newTestServer(t)
|
||
|
||
server, client := net.Pipe()
|
||
defer server.Close()
|
||
defer client.Close()
|
||
|
||
done := make(chan struct{})
|
||
go func() {
|
||
defer close(done)
|
||
s.handleConn(server, 110)
|
||
}()
|
||
|
||
br := bufio.NewReader(client)
|
||
// 等待 greeting
|
||
if _, err := br.ReadString('\n'); err != nil {
|
||
t.Fatalf("greeting: %v", err)
|
||
}
|
||
client.Write([]byte("USER no-such-user\r\n"))
|
||
br.ReadString('\n')
|
||
client.Write([]byte("PASS wrong-pass\r\n"))
|
||
br.ReadString('\n')
|
||
client.Write([]byte("QUIT\r\n"))
|
||
br.ReadString('\n')
|
||
client.Close()
|
||
|
||
select {
|
||
case <-done:
|
||
case <-time.After(5 * time.Second):
|
||
t.Fatal("handleConn did not return")
|
||
}
|
||
|
||
logs, total, err := s.stores.ProtocolLogs.List(1, 10, store.ProtocolLogFilter{})
|
||
if err != nil {
|
||
t.Fatalf("list: %v", err)
|
||
}
|
||
if total != 1 {
|
||
t.Fatalf("expected 1 log, got %d", total)
|
||
}
|
||
log := logs[0]
|
||
if log.Protocol != db.ProtocolPOP3 || log.Port != 110 {
|
||
t.Fatalf("unexpected log: %+v", log)
|
||
}
|
||
if log.Success {
|
||
t.Fatalf("expected failure, got %+v", log)
|
||
}
|
||
if log.Username != "no-such-user" {
|
||
t.Fatalf("username = %q, want no-such-user", log.Username)
|
||
}
|
||
if !strings.Contains(log.Detail, "USER") || !strings.Contains(log.Detail, "PASS") {
|
||
t.Fatalf("detail missing commands: %q", log.Detail)
|
||
}
|
||
}
|
||
|
||
// TestHandleConnLogsSuccess 验证认证成功的 POP3 会话写入成功日志。
|
||
func TestHandleConnLogsSuccess(t *testing.T) {
|
||
s := newTestServer(t)
|
||
|
||
domain := &db.Domain{Name: "example.com"}
|
||
if err := s.stores.Domains.Create(domain); err != nil {
|
||
t.Fatalf("create domain: %v", err)
|
||
}
|
||
user := &db.User{Username: "alice", DomainID: domain.ID, IsActive: true}
|
||
hashed, err := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
t.Fatalf("hash: %v", err)
|
||
}
|
||
user.PasswordHash = string(hashed)
|
||
if err := s.stores.Users.Create(user); err != nil {
|
||
t.Fatalf("create user: %v", err)
|
||
}
|
||
|
||
server, client := net.Pipe()
|
||
defer server.Close()
|
||
defer client.Close()
|
||
|
||
done := make(chan struct{})
|
||
go func() {
|
||
defer close(done)
|
||
s.handleConn(server, 110)
|
||
}()
|
||
|
||
br := bufio.NewReader(client)
|
||
br.ReadString('\n')
|
||
client.Write([]byte("USER alice@example.com\r\n"))
|
||
br.ReadString('\n')
|
||
client.Write([]byte("PASS secret123\r\n"))
|
||
br.ReadString('\n')
|
||
client.Write([]byte("STAT\r\n"))
|
||
br.ReadString('\n')
|
||
client.Write([]byte("QUIT\r\n"))
|
||
br.ReadString('\n')
|
||
client.Close()
|
||
|
||
select {
|
||
case <-done:
|
||
case <-time.After(5 * time.Second):
|
||
t.Fatal("handleConn did not return")
|
||
}
|
||
|
||
logs, total, err := s.stores.ProtocolLogs.List(1, 10, store.ProtocolLogFilter{})
|
||
if err != nil {
|
||
t.Fatalf("list: %v", err)
|
||
}
|
||
if total != 1 {
|
||
t.Fatalf("expected 1 log, got %d", total)
|
||
}
|
||
if !logs[0].Success {
|
||
t.Fatalf("expected success, got %+v", logs[0])
|
||
}
|
||
if logs[0].Username != "alice@example.com" {
|
||
t.Fatalf("username = %q", logs[0].Username)
|
||
}
|
||
}
|
||
|
||
// TestPop3CommandDetail 验证命令摘要生成。
|
||
func TestPop3CommandDetail(t *testing.T) {
|
||
counts := map[string]int{"USER": 1, "PASS": 1, "RETR": 3, "DELE": 1, "NOOP": 2}
|
||
got := pop3CommandDetail(counts, 2)
|
||
if got != "USER PASS RETR×3 DELE 删除2" {
|
||
t.Fatalf("detail = %q", got)
|
||
}
|
||
if empty := pop3CommandDetail(nil, 0); empty != "连接建立,无命令" {
|
||
t.Fatalf("empty detail = %q", empty)
|
||
}
|
||
}
|
||
|
||
// mockPusher 记录推送调用的测试桩。
|
||
type mockPusher struct {
|
||
expunged []struct {
|
||
Email string
|
||
Mailbox string
|
||
Seqs []uint32
|
||
}
|
||
}
|
||
|
||
func (m *mockPusher) PushNewMessage(string, *db.Message) {}
|
||
func (m *mockPusher) PushFlagsChanged(string, string, *db.Message) {}
|
||
func (m *mockPusher) PushExpunged(email, mailbox string, seqs []uint32) {
|
||
m.expunged = append(m.expunged, struct {
|
||
Email string
|
||
Mailbox string
|
||
Seqs []uint32
|
||
}{email, mailbox, seqs})
|
||
}
|
||
|
||
// TestExpungePushesIMAPUpdate 验证 POP3 删除邮件后向 IMAP 推送 Expunge。
|
||
func TestExpungePushesIMAPUpdate(t *testing.T) {
|
||
s := newTestServer(t)
|
||
pusher := &mockPusher{}
|
||
s.pusher = pusher
|
||
|
||
domain := &db.Domain{Name: "example.com"}
|
||
if err := s.stores.Domains.Create(domain); err != nil {
|
||
t.Fatalf("create domain: %v", err)
|
||
}
|
||
user := &db.User{Username: "alice", DomainID: domain.ID, IsActive: true}
|
||
hashed, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost)
|
||
user.PasswordHash = string(hashed)
|
||
if err := s.stores.Users.Create(user); err != nil {
|
||
t.Fatalf("create user: %v", err)
|
||
}
|
||
for i := 0; i < 3; i++ {
|
||
msg := &db.Message{UserID: user.ID, Folder: "INBOX", FromAddr: "x@y", Subject: "m", Date: time.Now()}
|
||
if err := s.stores.Mails.Create(msg); err != nil {
|
||
t.Fatalf("create message %d: %v", i, err)
|
||
}
|
||
}
|
||
|
||
server, client := net.Pipe()
|
||
defer server.Close()
|
||
defer client.Close()
|
||
|
||
done := make(chan struct{})
|
||
go func() {
|
||
defer close(done)
|
||
s.handleConn(server, 110)
|
||
}()
|
||
|
||
br := bufio.NewReader(client)
|
||
br.ReadString('\n')
|
||
client.Write([]byte("USER alice@example.com\r\n"))
|
||
br.ReadString('\n')
|
||
client.Write([]byte("PASS secret123\r\n"))
|
||
br.ReadString('\n')
|
||
// 删除第 1 封后退出
|
||
client.Write([]byte("DELE 1\r\n"))
|
||
br.ReadString('\n')
|
||
client.Write([]byte("QUIT\r\n"))
|
||
br.ReadString('\n')
|
||
client.Close()
|
||
|
||
select {
|
||
case <-done:
|
||
case <-time.After(5 * time.Second):
|
||
t.Fatal("handleConn did not return")
|
||
}
|
||
|
||
if len(pusher.expunged) != 1 {
|
||
t.Fatalf("expunge pushes = %d, want 1", len(pusher.expunged))
|
||
}
|
||
p := pusher.expunged[0]
|
||
if p.Email != "alice@example.com" || p.Mailbox != "INBOX" {
|
||
t.Fatalf("push target = %s/%s", p.Email, p.Mailbox)
|
||
}
|
||
if len(p.Seqs) != 1 || p.Seqs[0] != 1 {
|
||
t.Fatalf("seqs = %v, want [1]", p.Seqs)
|
||
}
|
||
// 邮件确实已删除
|
||
if n, _ := s.stores.Mails.CountByUserAndFolder(user.ID, "INBOX"); n != 2 {
|
||
t.Fatalf("inbox count = %d, want 2", n)
|
||
}
|
||
}
|
||
|
||
// TestExpungeDeletedRemovesAttachmentsAndRefundsQuota 验证 POP3 删除邮件时
|
||
// 附件文件被清理、配额被回退(与 Web/IMAP 删除路径同源)。
|
||
func TestExpungeDeletedRemovesAttachmentsAndRefundsQuota(t *testing.T) {
|
||
s := newTestServer(t)
|
||
attDir := t.TempDir()
|
||
attStorage := storage.NewAttachmentStorage(attDir)
|
||
s.storage = attStorage
|
||
|
||
domain := &db.Domain{Name: "example.com"}
|
||
if err := s.stores.Domains.Create(domain); err != nil {
|
||
t.Fatalf("create domain: %v", err)
|
||
}
|
||
user := &db.User{Username: "alice", DomainID: domain.ID, IsActive: true, QuotaBytes: 1 << 20, UsedBytes: 11}
|
||
if err := s.stores.Users.Create(user); err != nil {
|
||
t.Fatalf("create user: %v", err)
|
||
}
|
||
msg := &db.Message{UserID: user.ID, Folder: "INBOX", FromAddr: "x@y", Subject: "with attachment", Date: time.Now()}
|
||
if err := s.stores.Mails.Create(msg); err != nil {
|
||
t.Fatalf("create message: %v", err)
|
||
}
|
||
relPath, err := attStorage.Save("a.txt", []byte("hello world"))
|
||
if err != nil {
|
||
t.Fatalf("save attachment: %v", err)
|
||
}
|
||
att := &db.Attachment{MessageID: msg.ID, FileName: "a.txt", FilePath: relPath, FileSize: 11}
|
||
if err := s.stores.Attachments.Create(att); err != nil {
|
||
t.Fatalf("create attachment: %v", err)
|
||
}
|
||
|
||
msgs := []pop3Message{{id: msg.ID, raw: "x", size: 1, message: msg}}
|
||
if n := s.expungeDeleted(msgs, map[int]bool{1: true}, user); n != 1 {
|
||
t.Fatalf("expungeDeleted = %d, want 1", n)
|
||
}
|
||
|
||
// 附件文件已删除
|
||
if _, err := os.Stat(filepath.Join(attDir, relPath)); !os.IsNotExist(err) {
|
||
t.Fatalf("attachment file should be gone (err=%v)", err)
|
||
}
|
||
// 附件记录已删除
|
||
if _, err := s.stores.Attachments.GetByID(att.ID); err == nil {
|
||
t.Fatal("attachment record should be gone")
|
||
}
|
||
// 邮件已删除
|
||
if _, err := s.stores.Mails.GetByID(msg.ID); err == nil {
|
||
t.Fatal("message should be gone")
|
||
}
|
||
// 配额已回退
|
||
u, err := s.stores.Users.GetByID(user.ID)
|
||
if err != nil {
|
||
t.Fatalf("GetByID: %v", err)
|
||
}
|
||
if u.UsedBytes != 0 {
|
||
t.Fatalf("used_bytes = %d, want 0 after refund", u.UsedBytes)
|
||
}
|
||
}
|
||
|
||
// writeSelfSignedCert 生成测试用自签名证书(模拟配置了 TLS 的部署)。
|
||
func writeSelfSignedCert(t *testing.T, certPath, keyPath string) {
|
||
t.Helper()
|
||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
tmpl := x509.Certificate{
|
||
SerialNumber: big.NewInt(1),
|
||
Subject: pkix.Name{CommonName: "localhost"},
|
||
NotBefore: time.Now(),
|
||
NotAfter: time.Now().Add(24 * time.Hour),
|
||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||
BasicConstraintsValid: true,
|
||
DNSNames: []string{"localhost"},
|
||
}
|
||
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
certOut, err := os.Create(certPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer certOut.Close()
|
||
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer keyOut.Close()
|
||
if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// runPop3Commands 执行一系列命令,返回每条命令的响应(不含 greeting)。
|
||
func runPop3Commands(t *testing.T, s *POP3Server, cmds []string) map[string]string {
|
||
t.Helper()
|
||
server, client := net.Pipe()
|
||
defer server.Close()
|
||
defer client.Close()
|
||
|
||
done := make(chan struct{})
|
||
go func() {
|
||
defer close(done)
|
||
s.handleConn(server, 110)
|
||
}()
|
||
|
||
br := bufio.NewReader(client)
|
||
if _, err := br.ReadString('\n'); err != nil { // greeting
|
||
t.Fatalf("greeting: %v", err)
|
||
}
|
||
replies := make(map[string]string, len(cmds))
|
||
for _, cmd := range cmds {
|
||
client.Write([]byte(cmd + "\r\n"))
|
||
line, err := br.ReadString('\n')
|
||
if err != nil {
|
||
t.Fatalf("read reply for %q: %v", cmd, err)
|
||
}
|
||
replies[cmd] = line
|
||
}
|
||
client.Close()
|
||
select {
|
||
case <-done:
|
||
case <-time.After(5 * time.Second):
|
||
t.Fatal("handleConn did not return")
|
||
}
|
||
return replies
|
||
}
|
||
|
||
// TestPlainAuthRejectedWithoutTLSOnNonLoopback 验证明文认证限制(#25):
|
||
// 配置了 TLS 且来源非回环时,PASS 必须先 STLS,否则拒绝。
|
||
func TestPlainAuthRejectedWithoutTLSOnNonLoopback(t *testing.T) {
|
||
s := newTestServer(t)
|
||
certDir := t.TempDir()
|
||
certPath := filepath.Join(certDir, "cert.pem")
|
||
keyPath := filepath.Join(certDir, "key.pem")
|
||
writeSelfSignedCert(t, certPath, keyPath)
|
||
loader, err := tlsutil.NewLoader(certPath, keyPath, nil, log.Printf)
|
||
if err != nil {
|
||
t.Fatalf("loader: %v", err)
|
||
}
|
||
s.tlsLoader = loader
|
||
|
||
replies := runPop3Commands(t, s, []string{"USER alice@example.com", "PASS wrong", "QUIT"})
|
||
if !strings.Contains(replies["PASS wrong"], "TLS required") {
|
||
t.Fatalf("PASS reply = %q, want TLS required", replies["PASS wrong"])
|
||
}
|
||
}
|
||
|
||
// TestPlainAuthAllowedWithInsecureAuthOption 验证 allow_insecure_auth
|
||
// 显式放行时,PASS 正常进入认证流程(仅返回认证失败而非 TLS 拒绝)。
|
||
func TestPlainAuthAllowedWithInsecureAuthOption(t *testing.T) {
|
||
s := newTestServer(t)
|
||
certDir := t.TempDir()
|
||
certPath := filepath.Join(certDir, "cert.pem")
|
||
keyPath := filepath.Join(certDir, "key.pem")
|
||
writeSelfSignedCert(t, certPath, keyPath)
|
||
loader, err := tlsutil.NewLoader(certPath, keyPath, nil, log.Printf)
|
||
if err != nil {
|
||
t.Fatalf("loader: %v", err)
|
||
}
|
||
s.tlsLoader = loader
|
||
s.cfg.AllowInsecureAuth = true
|
||
|
||
replies := runPop3Commands(t, s, []string{"USER no-such-user", "PASS wrong", "QUIT"})
|
||
if strings.Contains(replies["PASS wrong"], "TLS required") {
|
||
t.Fatalf("PASS reply = %q, should reach authentication", replies["PASS wrong"])
|
||
}
|
||
if !strings.Contains(replies["PASS wrong"], "-ERR") {
|
||
t.Fatalf("PASS reply = %q, want -ERR", replies["PASS wrong"])
|
||
}
|
||
}
|