fix(quota): 入站投递强制附件配额,删除路径清理附件并回退配额

安全审计 #19/#20(security_todo.md P1):

- 新增 store.PurgeMessages 共享清理入口:删附件文件、回退配额、
  删附件记录、删邮件记录,Web/IMAP/POP3 三条删除路径同源
- IMAP EXPUNGE(含 UID 分支)与 POP3 expungeDeleted 接入,修复
  协议删除只删记录导致的"幽灵配额"与附件文件泄漏
- Web purgeMessages 改为委托同一入口,消除三处重复实现
- SMTP 入站投递前按收件人原子预扣附件配额(TryReserveQuota),
  任一收件人超限整封拒收并返回 452 临时失败(避免 go-smtp 将
  普通 error 映射为 554 永久失败);Sent 副本尽力而为不回滚整封
- saveMessage 返回实际落库字节数,reconcileQuota 对账退还差额
  (覆盖收件人被删/落库失败/部分附件失败)
- NewIMAPServer/NewPOP3Server 注入 AttachmentStorage
- 新增 4 项单测(配额拒收/正常记账/对账回退/POP3 删除清理)
- security_todo.md 勾选 #19/#20,登记存量发现 #27(IncrementFail
  并发首建窗口丢失更新,待决策)
This commit is contained in:
2026-08-28 13:59:50 +08:00
parent 17d8dc1567
commit d2b3caa81f
12 changed files with 491 additions and 41 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ func startIntegrationServer(t *testing.T) (*store.Stores, string) {
t.Fatalf("create user: %v", err)
}
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, connhub.New())
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, connhub.New(), nil)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
+2 -2
View File
@@ -41,7 +41,7 @@ func newTestServer(t *testing.T) (*IMAPServer, *store.Stores) {
t.Fatalf("create user: %v", err)
}
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, connhub.New())
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, connhub.New(), nil)
return srv, stores
}
@@ -91,7 +91,7 @@ func TestPushNewMessageNoSession(t *testing.T) {
func TestPushNewMessageNilSafe(t *testing.T) {
var srv *IMAPServer
srv.PushNewMessage("a@b", &db.Message{ID: 1}) // 不应 panic
srv = NewIMAPServer(config.IMAPConfig{}, nil, nil, config.BanConfig{}, nil)
srv = NewIMAPServer(config.IMAPConfig{}, nil, nil, config.BanConfig{}, nil, nil)
srv.PushNewMessage("", &db.Message{ID: 1}) // 空邮箱
srv.PushNewMessage("a@b", nil) // 空消息
srv.PushFlagsChanged("", "", nil)
+4 -1
View File
@@ -11,6 +11,7 @@ import (
"mail_go/config"
"mail_go/internal/connhub"
"mail_go/internal/db"
"mail_go/internal/storage"
"mail_go/internal/store"
"mail_go/internal/tlsutil"
@@ -36,6 +37,7 @@ type IMAPServer struct {
banCfg config.BanConfig
tlsLoader *tlsutil.Loader
hub *connhub.Hub
storage *storage.AttachmentStorage // 附件清理(EXPUNGE 时删文件 + 退配额),可空
// svc 邮箱服务层(文件夹目录/消息操作),IMAP 会话与 Web 共用。
svc *MailboxService
@@ -49,13 +51,14 @@ type IMAPServer struct {
// NewIMAPServer creates a new IMAP server instance. tlsLoader may be nil
// when TLS is not configured.
func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub) *IMAPServer {
func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub, attStorage *storage.AttachmentStorage) *IMAPServer {
return &IMAPServer{
stores: stores,
cfg: cfg,
banCfg: banCfg,
tlsLoader: tlsLoader,
hub: hub,
storage: attStorage,
svc: NewMailboxService(stores),
hubs: make(map[string]*mailboxHub),
sessions: make(map[*imapSession]struct{}),
+5 -6
View File
@@ -969,6 +969,7 @@ func (s *imapSession) Expunge(w *imapserver.ExpungeWriter, uids *imap.UIDSet) er
seq uint32
}
var targets []target
toPurge := make([]db.Message, 0, len(msgs))
for i := range msgs {
msg := &msgs[i]
if !msg.IsDeleted {
@@ -978,17 +979,15 @@ func (s *imapSession) Expunge(w *imapserver.ExpungeWriter, uids *imap.UIDSet) er
continue
}
targets = append(targets, target{id: msg.ID, seq: uint32(i + 1)})
toPurge = append(toPurge, *msg)
}
if len(targets) == 0 {
return nil
}
ids := make([]uint, len(targets))
for i, t := range targets {
ids[i] = t.id
}
if err := s.srv.stores.Mails.DeleteMany(ids); err != nil {
log.Printf("IMAP: failed to expunge %d messages: %v", len(ids), err)
// 永久删除:附件文件清理 + 配额回退 + 记录删除(与 Web/POP3 删除同源)
if err := s.srv.stores.PurgeMessages(s.srv.storage, userID, toPurge); err != nil {
log.Printf("IMAP: failed to expunge %d messages: %v", len(toPurge), err)
return err
}
+10 -5
View File
@@ -15,6 +15,7 @@ import (
"mail_go/internal/connhub"
"mail_go/internal/db"
"mail_go/internal/imap_server"
"mail_go/internal/storage"
"mail_go/internal/store"
"mail_go/internal/tlsutil"
)
@@ -23,6 +24,7 @@ import (
type POP3Server struct {
listener net.Listener
stores *store.Stores
storage *storage.AttachmentStorage // 附件清理(QUIT 删除时删文件 + 退配额),可空
cfg config.POP3Config
banCfg config.BanConfig
tlsLoader *tlsutil.Loader
@@ -32,9 +34,10 @@ type POP3Server struct {
}
// NewPOP3Server creates a new POP3 server instance. tlsLoader may be nil
// when TLS is not configured.
func NewPOP3Server(cfg config.POP3Config, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub, pusher imap_server.Pusher) *POP3Server {
return &POP3Server{stores: stores, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader, hub: hub, pusher: pusher}
// when TLS is not configured; attStorage may be nil (deletion still refunds
// quota and removes records, only file cleanup is skipped).
func NewPOP3Server(cfg config.POP3Config, stores *store.Stores, tlsLoader *tlsutil.Loader, banCfg config.BanConfig, hub *connhub.Hub, pusher imap_server.Pusher, attStorage *storage.AttachmentStorage) *POP3Server {
return &POP3Server{stores: stores, storage: attStorage, cfg: cfg, banCfg: banCfg, tlsLoader: tlsLoader, hub: hub, pusher: pusher}
}
func (s *POP3Server) tlsConfig() (*tls.Config, error) {
@@ -556,8 +559,10 @@ func (s *POP3Server) expungeDeleted(messages []pop3Message, deleted map[int]bool
var seqs []uint32
for seqNum, msgDeleted := range deleted {
if msgDeleted && seqNum >= 1 && seqNum <= len(messages) {
if err := s.stores.Mails.Delete(messages[seqNum-1].id); err != nil {
log.Printf("POP3: failed to delete message %d: %v", messages[seqNum-1].id, err)
m := messages[seqNum-1]
// 永久删除:附件文件清理 + 配额回退 + 记录删除(与 Web/IMAP 同源)
if err := s.stores.PurgeMessages(s.storage, user.ID, []db.Message{*m.message}); err != nil {
log.Printf("POP3: failed to delete message %d: %v", m.id, err)
continue
}
count++
+59
View File
@@ -3,12 +3,15 @@ package pop3_server
import (
"bufio"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"mail_go/config"
"mail_go/internal/db"
"mail_go/internal/storage"
"mail_go/internal/store"
"golang.org/x/crypto/bcrypt"
@@ -247,3 +250,59 @@ func TestExpungePushesIMAPUpdate(t *testing.T) {
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)
}
}
+120 -7
View File
@@ -342,19 +342,36 @@ func (s *smtpSession) Data(r io.Reader) error {
return err
}
// 附件配额:入站路径此前只记账不检查,外部匿名发件人可无限落盘直至
// 磁盘写满。口径与 Web 上传一致(只计附件)。任一本地收件人配额不足
// 则整封以 452 临时失败拒收(发件方按退避重试),避免部分投递;
// 附件总量在 DATA 阶段才可知,无法在 RCPT 阶段按收件人拒绝。
attTotal := attachmentSize(parsed)
rcptUser, charged, qerr := s.reserveLocalQuota(attTotal)
if qerr != nil {
return qerr
}
// Local recipients: deliver to INBOX.
localDelivered := 0
storedByUser := make(map[uint]int64, len(charged))
for _, rcpt := range s.localRcpts {
if _, reserved := rcptUser[rcpt]; attTotal > 0 && !reserved {
// 预扣阶段未覆盖的收件人(当时不存在):跳过,避免绕过配额
log.Printf("SMTP: recipient %s missing at quota reservation, skipping", rcpt)
continue
}
user, err := s.localUserByEmail(rcpt)
if err != nil {
log.Printf("SMTP: recipient not found %s, skipping", rcpt)
continue
}
msg, err := s.saveMessage(user.ID, "INBOX", parsed, data, false)
msg, stored, err := s.saveMessage(user.ID, "INBOX", parsed, data, false)
if err != nil {
log.Printf("SMTP: failed to create message for %s: %v", rcpt, err)
continue
}
storedByUser[user.ID] += stored
log.Printf("SMTP: message delivered to %s", rcpt)
localDelivered++
// 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知)
@@ -362,6 +379,9 @@ func (s *smtpSession) Data(r io.Reader) error {
pusher.PushNewMessage(user.Username+"@"+user.Domain.Name, msg)
}
}
// 配额对账:退还预扣量中未真正落库的部分(收件人被删、落库失败、
// 部分附件保存失败),保证配额与实际占用一致。
s.reconcileQuota(charged, storedByUser)
s.msgCount += localDelivered
// External recipients: queue for outbound delivery.
@@ -389,7 +409,20 @@ func (s *smtpSession) Data(r io.Reader) error {
s.msgCount += externalQueued
if s.authenticated && s.userID != 0 && s.mode != smtpModeInbound {
if _, err := s.saveMessage(s.userID, "Sent", parsed, data, true); err != nil {
// Sent 副本尽力而为:本地/外发投递已完成,不因配额回滚整封;
// 附件量先原子预扣,保存后按实际落库量对账退还。
if attTotal > 0 {
if ok, err := s.backend.server.stores.Users.TryReserveQuota(s.userID, attTotal); err != nil {
log.Printf("SMTP: quota check for sent copy of %s failed: %v", s.email, err)
} else if !ok {
log.Printf("SMTP: sender %s over quota, skipping sent copy", s.email)
} else if _, stored, err := s.saveMessage(s.userID, "Sent", parsed, data, true); err != nil {
_ = s.backend.server.stores.Users.UpdateUsedBytes(s.userID, -attTotal)
log.Printf("SMTP: failed to save sent copy for %s: %v", s.email, err)
} else if refund := attTotal - stored; refund > 0 {
_ = s.backend.server.stores.Users.UpdateUsedBytes(s.userID, -refund)
}
} else if _, _, err := s.saveMessage(s.userID, "Sent", parsed, data, true); err != nil {
log.Printf("SMTP: failed to save sent copy for %s: %v", s.email, err)
}
}
@@ -490,7 +523,85 @@ func parseSMTPMessage(data []byte) (*parsedSMTPMessage, error) {
return msg, nil
}
func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTPMessage, data []byte, read bool) (*db.Message, error) {
// attachmentSize 汇总邮件附件总字节数(配额记账口径与 Web 上传一致)。
func attachmentSize(parsed *parsedSMTPMessage) int64 {
total := int64(0)
for _, att := range parsed.attachments {
total += int64(len(att.data))
}
return total
}
// reserveLocalQuota 为本地收件人预扣附件配额(TryReserveQuota 原子预扣,
// 防止并发投递绕过配额)。任一收件人配额不足则回退全部已预扣量,并返回
// 452 临时失败(普通 error 会被 go-smtp 映射为 554 永久失败,语义错误)。
// 同一收件人重复 RCPT TO 时投递多份,预扣量按用户聚合。
// 返回 rcpt→userID 映射与各用户预扣量,供投递与对账使用。
func (s *smtpSession) reserveLocalQuota(attTotal int64) (map[string]uint, map[uint]int64, *smtp.SMTPError) {
rcptUser := make(map[string]uint, len(s.localRcpts))
if attTotal <= 0 {
return rcptUser, nil, nil
}
quotaNeed := make(map[uint]int64, len(s.localRcpts))
for _, rcpt := range s.localRcpts {
if _, ok := rcptUser[rcpt]; ok {
continue
}
user, err := s.localUserByEmail(rcpt)
if err != nil {
continue // 投递阶段同样会跳过
}
rcptUser[rcpt] = user.ID
quotaNeed[user.ID] += attTotal
}
charged := make(map[uint]int64, len(quotaNeed))
for uid, amount := range quotaNeed {
ok, err := s.backend.server.stores.Users.TryReserveQuota(uid, amount)
if err != nil {
s.refundQuota(charged)
s.recordFail("配额检查失败")
return nil, nil, &smtp.SMTPError{
Code: 452,
EnhancedCode: smtp.EnhancedCode{4, 3, 0},
Message: fmt.Sprintf("Temporary quota check failure: %v", err),
}
}
if !ok {
s.refundQuota(charged)
log.Printf("SMTP: recipient over quota (needs %d bytes), rejecting message", amount)
s.recordFail("收件人邮箱配额不足")
return nil, nil, &smtp.SMTPError{
Code: 452,
EnhancedCode: smtp.EnhancedCode{4, 2, 2},
Message: "Insufficient system storage",
}
}
charged[uid] = amount
}
return rcptUser, charged, nil
}
// refundQuota 回退预扣阶段已扣减的配额(整封拒收时保持原子性)。
func (s *smtpSession) refundQuota(charged map[uint]int64) {
for uid, amount := range charged {
_ = s.backend.server.stores.Users.UpdateUsedBytes(uid, -amount)
}
}
// reconcileQuota 对账:退还预扣量中未真正落库的部分(收件人被删、落库
// 失败、部分附件保存失败),使配额与实际占用一致。
func (s *smtpSession) reconcileQuota(charged, storedByUser map[uint]int64) {
for uid, amount := range charged {
if refund := amount - storedByUser[uid]; refund > 0 {
_ = s.backend.server.stores.Users.UpdateUsedBytes(uid, -refund)
}
}
}
// saveMessage 存储一封邮件及其附件。配额由调用方预扣并对账:本函数返回
// 实际落库的附件字节数(stored),调用方据此退还未落库的预扣量;
// Mails.Create 失败时附件尚未处理,调用方应退还全部预扣量。
func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTPMessage, data []byte, read bool) (*db.Message, int64, error) {
msg := &db.Message{
UserID: userID,
MessageID: parsed.messageID,
@@ -507,11 +618,13 @@ func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTP
Date: parsed.date,
}
if err := s.backend.server.stores.Mails.Create(msg); err != nil {
return nil, err
return nil, 0, err
}
// Persist attachments to disk and link them to the message so that the
// Web mail UI can list/download them and quota accounting stays correct.
// Web mail UI can list/download them. Quota is reserved by the caller
// (TryReserveQuota) and reconciled against the returned stored size.
var stored int64
for _, att := range parsed.attachments {
relPath, err := s.backend.server.storage.Save(att.fileName, att.data)
if err != nil {
@@ -529,9 +642,9 @@ func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTP
log.Printf("SMTP: failed to create attachment record: %v", err)
continue
}
_ = s.backend.server.stores.Users.UpdateUsedBytes(userID, rec.FileSize)
stored += int64(len(att.data))
}
return msg, nil
return msg, stored, nil
}
// Reset clears the session state for the next message on the same connection.
+135 -1
View File
@@ -2,6 +2,7 @@ package smtp_server
import (
"bytes"
"errors"
"fmt"
"testing"
"time"
@@ -12,6 +13,7 @@ import (
"mail_go/internal/store"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -86,7 +88,7 @@ func TestSaveMessagePersistsAttachments(t *testing.T) {
t.Fatalf("create user: %v", err)
}
if _, err := sess.saveMessage(user.ID, "INBOX", parsed, data, false); err != nil {
if _, _, err := sess.saveMessage(user.ID, "INBOX", parsed, data, false); err != nil {
t.Fatalf("saveMessage: %v", err)
}
@@ -212,3 +214,135 @@ func TestSessionLoggingRecordsDelivery(t *testing.T) {
t.Fatalf("expected success, got %+v", logs[0])
}
}
// newQuotaTestSession 构造带真实存储的入站会话(配额逻辑不触碰连接,
// 直接构造会话即可单测)。
func newQuotaTestSession(t *testing.T, quotaBytes, usedBytes int64) (*smtpSession, *store.Stores, *storage.AttachmentStorage, *db.User) {
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)
attStorage := storage.NewAttachmentStorage(t.TempDir())
srv := &SMTPServer{stores: stores, storage: attStorage}
sess := &smtpSession{
backend: &smtpBackend{server: srv, mode: smtpModeInbound},
clientIP: "203.0.113.10",
startedAt: time.Now(),
port: 25,
rcpts: make([]string, 0),
}
domain := &db.Domain{Name: "example.com"}
if err := stores.Domains.Create(domain); err != nil {
t.Fatalf("create domain: %v", err)
}
user := &db.User{Username: "alice", PasswordHash: "x", DomainID: domain.ID, IsActive: true, QuotaBytes: quotaBytes, UsedBytes: usedBytes}
if err := stores.Users.Create(user); err != nil {
t.Fatalf("create user: %v", err)
}
if err := sess.Rcpt("alice@example.com", nil); err != nil {
t.Fatalf("Rcpt: %v", err)
}
return sess, stores, attStorage, user
}
// TestInboundQuotaRejectsOverQuotaRecipient 验证收件人配额不足时整封以
// 452 临时失败拒收,不落库、不改配额(已预扣份额全部回退)。
func TestInboundQuotaRejectsOverQuotaRecipient(t *testing.T) {
sess, stores, _, user := newQuotaTestSession(t, 5, 5)
parsed, err := parseSMTPMessage(testMultipartMessage())
if err != nil {
t.Fatalf("parseSMTPMessage: %v", err)
}
_, _, qerr := sess.reserveLocalQuota(attachmentSize(parsed))
if qerr == nil {
t.Fatal("expected 452 for over-quota recipient")
}
var smtpErr *smtp.SMTPError
if !errors.As(qerr, &smtpErr) || smtpErr.Code != 452 {
t.Fatalf("expected 452 SMTPError, got %v", qerr)
}
if count, _ := stores.Mails.CountAll(); count != 0 {
t.Fatalf("expected 0 messages after rejection, got %d", count)
}
u, err := stores.Users.GetByID(user.ID)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if u.UsedBytes != 5 {
t.Fatalf("used_bytes = %d, want 5 (unchanged)", u.UsedBytes)
}
}
// TestInboundQuotaDeliversWithinQuota 验证配额充足时正常投递:预扣量与
// 实际落库量一致,对账不产生退还,附件文件与记录就位。
func TestInboundQuotaDeliversWithinQuota(t *testing.T) {
sess, stores, attStorage, user := newQuotaTestSession(t, 1<<20, 0)
parsed, err := parseSMTPMessage(testMultipartMessage())
if err != nil {
t.Fatalf("parseSMTPMessage: %v", err)
}
attTotal := attachmentSize(parsed) // "hello world" = 11 字节
rcptUser, charged, qerr := sess.reserveLocalQuota(attTotal)
if qerr != nil {
t.Fatalf("reserveLocalQuota: %v", qerr)
}
if rcptUser["alice@example.com"] != user.ID || charged[user.ID] != attTotal {
t.Fatalf("unexpected reservation: rcptUser=%v charged=%v", rcptUser, charged)
}
msg, stored, err := sess.saveMessage(user.ID, "INBOX", parsed, testMultipartMessage(), false)
if err != nil {
t.Fatalf("saveMessage: %v", err)
}
if stored != attTotal {
t.Fatalf("stored = %d, want %d", stored, attTotal)
}
sess.reconcileQuota(charged, map[uint]int64{user.ID: stored})
atts, err := stores.Attachments.ListByMessage(msg.ID)
if err != nil || len(atts) != 1 {
t.Fatalf("expected 1 attachment, got %d (err=%v)", len(atts), err)
}
if content, err := attStorage.Read(atts[0].FilePath); err != nil || !bytes.Equal(content, []byte("hello world")) {
t.Fatalf("attachment file missing or wrong content (err=%v)", err)
}
u, err := stores.Users.GetByID(user.ID)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if u.UsedBytes != attTotal {
t.Fatalf("used_bytes = %d, want %d", u.UsedBytes, attTotal)
}
}
// TestInboundQuotaReconcilesRefund 验证附件未落库(投递失败等)时,对账
// 把预扣量完全回退,配额不泄漏。
func TestInboundQuotaReconcilesRefund(t *testing.T) {
sess, stores, _, user := newQuotaTestSession(t, 1<<20, 0)
parsed, err := parseSMTPMessage(testMultipartMessage())
if err != nil {
t.Fatalf("parseSMTPMessage: %v", err)
}
_, charged, qerr := sess.reserveLocalQuota(attachmentSize(parsed))
if qerr != nil {
t.Fatalf("reserveLocalQuota: %v", qerr)
}
// 模拟投递失败:stored 为空 → 对账全额回退
sess.reconcileQuota(charged, map[uint]int64{})
u, err := 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)
}
}
+36
View File
@@ -0,0 +1,36 @@
package store
import (
"log"
"mail_go/internal/db"
"mail_go/internal/storage"
)
// PurgeMessages 永久删除一批邮件:逐封删除附件文件并回退其占用的配额、
// 删除附件记录,最后批量删除邮件记录。Web 删除/清空、IMAP EXPUNGE、
// POP3 删除共用本入口,保证磁盘文件、配额与数据库三者一致。
// att 为 nil 时跳过文件清理(仍回退配额并删除记录,测试与降级场景使用)。
func (s *Stores) PurgeMessages(att *storage.AttachmentStorage, userID uint, msgs []db.Message) error {
ids := make([]uint, 0, len(msgs))
for i := range msgs {
attachments, err := s.Attachments.ListByMessage(msgs[i].ID)
if err != nil {
log.Printf("store: 查询附件失败 msg=%d: %v", msgs[i].ID, err)
}
for _, a := range attachments {
if att != nil {
_ = att.Delete(a.FilePath)
}
_ = s.Users.UpdateUsedBytes(userID, -a.FileSize)
}
if err := s.Attachments.DeleteByMessage(msgs[i].ID); err != nil {
log.Printf("store: 删除附件记录失败 msg=%d: %v", msgs[i].ID, err)
}
ids = append(ids, msgs[i].ID)
}
if len(ids) == 0 {
return nil
}
return s.Mails.DeleteMany(ids)
}
+3 -14
View File
@@ -99,21 +99,10 @@ func (h *MailHandler) seqOfFolder(userID uint, folder string, msgID uint) uint32
return 0
}
// purgeMessages 永久删除邮件(含附件文件与配额回退)
// purgeMessages 永久删除邮件(含附件文件与配额回退),委托给与 IMAP/POP3
// 删除路径同源的 store.PurgeMessages。
func (h *MailHandler) purgeMessages(userID uint, msgs []db.Message) {
ids := make([]uint, 0, len(msgs))
for i := range msgs {
attachments, _ := h.stores.Attachments.ListByMessage(msgs[i].ID)
for _, att := range attachments {
_ = h.storage.Delete(att.FilePath)
_ = h.stores.Users.UpdateUsedBytes(userID, -att.FileSize)
}
if err := h.stores.Attachments.DeleteByMessage(msgs[i].ID); err != nil {
log.Printf("web: 删除附件记录失败 msg=%d: %v", msgs[i].ID, err)
}
ids = append(ids, msgs[i].ID)
}
if err := h.stores.Mails.DeleteMany(ids); err != nil {
if err := h.stores.PurgeMessages(h.storage, userID, msgs); err != nil {
log.Printf("web: 删除邮件失败: %v", err)
}
}
+2 -2
View File
@@ -242,7 +242,7 @@ func main() {
connHub := connhub.New()
// 7. Start IMAP server(先于 SMTP 创建,SMTP 投递成功时通知其推送)
imapSrv := imap_server.NewIMAPServer(cfg.IMAP, stores, imapTLS, cfg.Ban, connHub)
imapSrv := imap_server.NewIMAPServer(cfg.IMAP, stores, imapTLS, cfg.Ban, connHub, attStorage)
go func() {
if err := imapSrv.Start(); err != nil {
log.Printf("IMAP 服务启动失败: %v", err)
@@ -279,7 +279,7 @@ func main() {
}
// 9. Start POP3 server
pop3Srv := pop3_server.NewPOP3Server(cfg.POP3, stores, pop3TLS, cfg.Ban, connHub, imapSrv)
pop3Srv := pop3_server.NewPOP3Server(cfg.POP3, stores, pop3TLS, cfg.Ban, connHub, imapSrv, attStorage)
go func() {
if err := pop3Srv.Start(); err != nil {
log.Printf("POP3 服务启动失败: %v", err)
+114 -2
View File
@@ -1,8 +1,10 @@
# 安全漏洞修复 TODO
依据 2026-08-19 安全审计结果(代码静态审计 + mail.lmve.net 线上验证)整理。
依据 2026-08-19 首轮安全审计(代码静态审计 + mail.lmve.net 线上验证)、2026-08-20 第二轮审计、2026-08-28 第三轮审计(代码静态审计)整理。
按优先级排列:P0 立即修复,P1 尽快修复,P2 排期修复,P3 加固项。
按优先级排列:P0 立即修复,P1 尽快修复,P2 排期修复,P3 加固项。条目编号为发现顺序(跨轮次唯一,非严重度排序);第三轮发现(#19-#26)已按等级归并至下列 P1-P3 区块,标题注明轮次与日期。
> 第三轮(2026-08-28)结论:未发现 SQL 注入、路径穿越、开放中继、越权访问、XSS 等高危漏洞,前两轮修复(#1-#18)均验证到位;新发现问题集中在**资源滥用/配额记账缺陷**与**防御深度缺失**两类。执行顺序注意:**#20 必须先于(或随)#19 落地**——配额退还先于配额强制,否则用户会因幽灵占用被误拒收。
## P0 严重:可被完全接管
@@ -59,6 +61,31 @@
- [x] 单测:`to`/`cc`/`subject` 携带 CRLF 注入载荷时 RawData 无独立注入头;文件名含 CRLF/引号时头结构完好;非 ASCII 主题正确编码(`mail_injection_test.go`)。
- [ ] 含特殊字符附件名的邮件实测收发正常。
### 19. SMTP 入站投递不强制配额 —— 磁盘耗尽 DoS(第三轮,2026-08-28
- [x] 位置:`internal/smtp_server/server.go:493``saveMessage`,记账在 532 行)
- 现状:入站邮件附件直接落盘,`UpdateUsedBytes` 只记账不检查配额。对比 Web 上传路径有 `TryReserveQuota` 原子预扣(`user_store.go:191`),入站路径配额形同虚设。任何外部发件人可投递 64MB(`MaxMessage`)× 无限封邮件(附件落盘 + `RawData` 整封入库双份存储)直到磁盘写满,导致全部用户服务中断;多用户租户场景配额承诺无法兑现。
- 修复方案:
- [x] `saveMessage` 前调用 `TryReserveQuota` 预扣附件总大小(无附件时跳过)。→ 实现 `reserveLocalQuota`:按用户聚合预扣(重复 RCPT TO 投递多份),任一收件人不足则回退全部已预扣量,整封原子拒收
- [x] 超限返回 `smtp.SMTPError{Code: 452, ...}`insufficient storage,临时失败语义,发件方按退避重试)。**注意**:go-smtp 对 `Data()` 返回的普通 error 一律映射为 554 永久失败(`conn.go` `dataErrorToStatus`),语义错误会导致对方服务器直接退信。
- [x] 入库/落盘失败时补偿回退预扣字节。→ `saveMessage` 返回实际落库字节数,`reconcileQuota` 对账退还差额(覆盖收件人被删/落库失败/部分附件失败);Sent 副本为尽力而为:预扣失败仅跳过副本并记日志,不回滚整封
- 验证:
- [x] 单测:配额已满时入站投递被拒(452)、`used_bytes` 不变;配额充足时正常投递记账。(`TestInboundQuotaRejectsOverQuotaRecipient` / `TestInboundQuotaDeliversWithinQuota` / `TestInboundQuotaReconcilesRefund`
- [x] 回归:本地投递 + 外发队列 + Sent 副本路径不受影响。(`go test ./...` 全量通过)
- 已完成(2026-08-28)。
### 20. 删除邮件不删附件文件、不退配额 —— 幽灵配额(第三轮新发现,2026-08-28)
- [x] 位置:`internal/imap_server/session.go:956``Expunge`)、`internal/pop3_server/server.go:551``expungeDeleted`
- 现状:两处只调 `Mails.Delete`/`DeleteMany`,不删附件文件、不回退 `UsedBytes`——配额只增不减。Web 侧 `purgeMessages``web/handlers/mail.go:103`)是完整实现(`storage.Delete` + `DeleteByMessage` + `UpdateUsedBytes` 回退),协议侧缺失。**与 #19 配套必须修复**,否则强制配额后用户会因幽灵占用被拒收且无法自助恢复。
- 修复方案:
- [x] 抽取共享清理入口(store 层):删附件记录前先 `Attachments.ListByMessage` 取文件路径,`storage.Delete` + `UpdateUsedBytes(userID, -FileSize)` 回退,再删记录与邮件。→ 新增 `store.PurgeMessages`Web/IMAP/POP3 同源)
- [x] IMAP `Expunge`(含 UID EXPUNGE 分支)与 POP3 `expungeDeleted` 接入。
- [x] IMAP 服务器需注入 `AttachmentStorage``NewIMAPServer` 当前无此依赖);注意 `pop3_server_test.go:29` 直接构造 `POP3Server{stores:...}``imap_server` 两个测试文件直接构造服务器的用例需同步。→ `NewIMAPServer`/`NewPOP3Server` 增参,main.go 与三个测试文件已同步
- 验证:
- [x] 单测:带附件邮件经 IMAP EXPUNGE / POP3 DELE+QUIT 删除后附件文件消失、`used_bytes` 回退。(`TestExpungeDeletedRemovesAttachmentsAndRefundsQuota` 覆盖 POP3 路径与共享入口;IMAP EXPUNGE 为同一入口的薄接线)
- 已完成(2026-08-28)。注:Web 侧 `purgeMessages` 改为委托同一入口,消除三处重复实现。
## P2 中危
### 5. 会话 Cookie 缺 Secure 标志
@@ -136,6 +163,26 @@
- [x] 现有 OAuth2 测试仍通过(错误页文案不含内部细节)。
- [ ] 线上(启用 LDAP/OAuth 后)验证失败页面不含内部地址/DN/原始错误串。
### 21. Web 发信配额预扣泄漏(第三轮,2026-08-28)
- [ ] 位置:`internal/web/handlers/mail.go:278`(预扣)→ 367 / 390 / 416(泄漏点)
- 现状:`TryReserveQuota` 预扣附件配额后,三处提前 return 不回退:外部收件人入队失败(367)、本地投递失败(390)、Sent 副本保存失败(416)。文件读取失败(310)与附件保存失败(426)有回退,上述路径漏了。用户反复提交含无效外部地址的带附件邮件,可把自己配额扣光且无对应文件占用(配额"凭空消失",需管理员改库恢复)。
- 修复方案:
- [ ] defer + 成功标志统一补偿:未走到"附件记录全部落库"终点即回退剩余预扣量;或各失败分支显式回退。
- [ ] 注意 367 处入队失败时可能已成功入队部分外部收件人,只回退附件未消耗部分。
- 验证:
- [ ] 单测:外部入队失败 / 本地投递失败路径退出后 `used_bytes` 恢复原值。
### 22. OAuth2 登录不校验邮箱 verified 状态(第三轮,2026-08-28
- [ ] 位置:`internal/auth/oauth2.go:111-127`
- 现状:GitHub `/user/emails` 响应含 `verified` 字段但未检查,取 primary 首个即用;Google 未检查 `email_verified`。当前 GitHub 该 API 实际只返回已验证邮箱,现实可利用性低,但属依赖 IdP 实现细节;自建 provider(代码 33-36 行支持任意 host)场景下未验证邮箱可登录他人账号。
- 修复方案:
- [ ] GitHub:过滤 `verified == true`(primary 优先),无已验证邮箱返回错误。
- [ ] Google:解析 `email_verified`false 或缺失时拒绝。
- 验证:
- [ ] 单测:构造含未验证邮箱的响应被拒。
## P3 低危 / 加固
### 12. Referer 开放重定向
@@ -171,6 +218,52 @@
- 验证:
- [x] 单测:8 天前的会话被重定向登录页;1 小时前的会话正常访问(用配置密钥签名构造会话,`TestSessionAbsoluteExpiryForcesRelogin`/`TestSessionWithinExpiryWorks`)。
### 23. 管理员建用户/域名无格式校验(第三轮,2026-08-28)
- [ ] 位置:`internal/web/handlers/admin.go``CreateUser`:612 / `UpdateUser`:737 / `CreateDomain`:222
- 现状:username 可含 `@`、空格、控制字符;域名无任何校验。用户名 `a@evil` 生成邮箱 `a@evil@example.com``GetByEmail``SplitN("@", 2)``user_store.go:75`)解析错位,认证与投递路由混乱;畸形数据也会进入 IMAP 邮箱视图。
- 修复方案:
- [ ] 用户名白名单 `^[a-zA-Z0-9._-]+$`、长度 ≤64;域名小写字母数字/连字符/至少一个点、长度 ≤253。
- [ ] `UpdateUser` 改名/换域名同样校验;错误回显走 i18n。
- 验证:
- [ ] 单测:`a@evil`、含空格/控制字符用户名被拒;畸形域名被拒;合法输入通过。
### 24. 密码无最小长度要求(第三轮,2026-08-28)
- [ ] 位置:`internal/web/handlers/mail.go:849`(自助修改)、`internal/web/handlers/admin.go:638/771`(创建/重置)
- 现状:新密码仅检查非空,1 位密码也接受,与系统整体安全水位不匹配。
- 修复方案:
- [ ] 统一最小长度 8NIST SP 800-63B:长度优先于复杂度),三处共用校验函数 + i18n 文案。
- 验证:
- [ ] 单测:7 位拒绝、8 位通过。
### 25. POP3/IMAP 允许明文认证(第三轮,2026-08-28)
- [ ] 位置:`internal/pop3_server/server.go:390``handlePASS`)、`internal/imap_server/session.go:212``Login`
- 现状:非 TLS 连接上 USER/PASS、LOGIN 明文传输无限制,被动嗅探可截获密码。自签证书总是自动生成,STLS/STARTTLS 能力具备(POP3 CAPA 已宣告 STLS)。
- 修复方案:
- [ ] 回环来源放行(本地调试);非回环要求连接已 TLS(POP3 `tlsActive` / IMAP `NetConn()``*tls.Conn`)才接受认证,否则提示先执行 STLS/STARTTLS。
- [ ] 加配置项控制(默认强制),不破坏极端明文内网部署。
- 验证:
- [ ] 单测:非 TLS 非回环认证被拒并提示;TLS 后成功;回环明文可用。
### 26. 加固建议(第三轮,2026-08-28,可选排期)
- [ ] CSP `script-src 'unsafe-inline'``middleware/security.go:19`):长期改 nonce 方案,恢复 XSS 纵深防御。
- [ ] 邮件远程图片(CSP `img-src https:`):默认屏蔽、点击加载,防追踪像素泄露收件人 IP/UA。
- [ ] 入站邮件 SPF/DKIM/DMARC 验证:当前伪造发件人的钓鱼邮件原样入库展示。
- [ ] Unix socket 0666`web/server.go:493`):本地多用户主机依赖目录权限兜底,可收紧 0660。
- [ ] RawData 双份存储(附件落盘 + 整封 RawData 入库):存储翻倍,放大 #19 的 DoS 效果。
### 27. IncrementFail 并发首建窗口丢失更新(第三轮修复过程中发现,存量问题)
- [ ] 位置:`internal/store/ban_store.go``IncrementFail`
- 现状:UPDATE 未命中(RowsAffected=0)后 INSERT 用 `OnConflict DoNothing` 兜底——两个 goroutine 的 UPDATE 可都在记录存在前提交,随后 INSERT 竞争,败者被 DoNothing 吞掉、自增丢失,最终 `fail_count` 少 1。生产影响极小(仅同 IP 首次失败瞬时并发时计数少 1,后续自增自愈),但 `TestIncrementFailConcurrent`(16×5 并发,断言精确 80)会因此偶发失败:`-race` 多包并行时观察到一次;干净树验证非第三轮改动引入(改动仅新增 purge.go,未被该测试触及)。
- 修复方案:
- [ ] 失败 INSERT 改为冲突时自增:`OnConflict{Columns: ip_address, DoUpdates: fail_count = fail_count + 1}`SQLite ≥3.24 / MySQL 均支持,约 3 行改动),消除首建窗口的丢失更新。
- 验证:
- [ ] `go test -race -count=N ./internal/store/` 多轮稳定通过。
## 已确认安全、无需改动
- bcrypt 密码哈希;GORM 全参数化查询(无 SQL 注入)。
@@ -179,6 +272,14 @@
- 邮件 HTML 经 sandbox iframe(无 `allow-scripts`)渲染,`srcdoc` 属性转义经实测有效,无存储型 XSS。
- Web 登录错误提示不区分用户是否存在(无枚举)。
第三轮(2026-08-28)复查确认:
- 存储层 GORM 全参数化(无 SQL 注入);附件路径 UUID 白名单 + 前缀校验(无路径穿越)。
- SMTP 非开放中继、认证用户强制 From=登录身份;Web/管理端路由权限校验全链路到位。
- 邮件 HTML 经 sandbox iframe 渲染 + `jsonify` 转义(无存储型 XSS);会话 SameSite=Strict + 7 天绝对过期 + 滑动续期。
- 邮件头 CRLF 注入过滤;OAuth2 state 常量时间比较 + 一次性;LDAP `EscapeFilter`Referer 同站回跳。
- 配置密钥治理(旧硬编码拒绝启动、配置 0600、env 覆盖不落盘);仅信任回环代理。
## 修复顺序建议
1. ~~#1P0~~ 已完成 2026-08-19
@@ -186,6 +287,7 @@
3. ~~#5-#11P2~~ 已完成 2026-08-19
4. ~~#12-#16P3~~ 已完成 2026-08-19
5. ~~#17P4)、#18P5,方案 A~~ 已完成 2026-08-20
6. 第三轮(#19-#26,2026-08-28)待实施,已按安全等级归并至上文 P1-P3 区块;实施顺序见文末「第三轮修复顺序建议」。
## P4 低危:第二轮审计发现(2026-08-208ea4a62..37b4816
@@ -225,3 +327,13 @@
- Caddy 加固(可选,应用层已加安全头)、8080 端口保持仅本机可达。
- GitHub 仓库中 3 个 50MB+ 的 exe 文件(mailgo.exe / mail_go.exe / mailgo_qa.exe)建议改用 Git LFS 或从历史中删除。
- 线上验证:部署新版后检查登录/收件箱/管理页、协议认证封禁、邮件远程图片加载(CSP 影响)。
## 第三轮修复顺序建议(#19-#27,实施顺序 ≠ 安全等级)
1. ~~**#20**(先修删除退配额,数据一致性基础)~~ 已完成 2026-08-28
2. ~~**#19**(入站配额强制,依赖 #20)~~ 已完成 2026-08-28
3. **#21**Web 预扣泄漏)
4. #24#23(一行校验类)
5. #22#25
6. #26 按需排期
7. #27(存量丢失更新,约 3 行改动,可顺手修)