Files
kevin f2a6203714 fix(security): 管理端输入校验/密码最小长度/明文认证限制,修复封禁计数丢失更新
安全审计 #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
  默认单轮跑法无关)
2026-08-28 15:09:55 +08:00

435 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"])
}
}