Compare commits
5
Commits
c881146a40
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2a6203714 | ||
|
|
b1c86d436d | ||
|
|
d2b3caa81f | ||
|
|
17d8dc1567 | ||
|
|
cca7e4f092 |
No files matched your search
@@ -75,6 +75,10 @@ type IMAPConfig struct {
|
||||
TLSAddr string `toml:"tls_addr"`
|
||||
TLSCert string `toml:"tls_cert"`
|
||||
TLSKey string `toml:"tls_key"`
|
||||
// AllowInsecureAuth 允许非 TLS 明文认证(仅限内网兼容场景显式开启)。
|
||||
// 默认 false:配置了 TLS 时,非回环来源必须先 STARTTLS 再 LOGIN
|
||||
//(与 SMTP 的 AllowInsecureAuth 语义一致)。
|
||||
AllowInsecureAuth bool `toml:"allow_insecure_auth"`
|
||||
}
|
||||
|
||||
// POP3Config holds POP3 server settings.
|
||||
@@ -83,6 +87,9 @@ type POP3Config struct {
|
||||
TLSAddr string `toml:"tls_addr"`
|
||||
TLSCert string `toml:"tls_cert"`
|
||||
TLSKey string `toml:"tls_key"`
|
||||
// AllowInsecureAuth 允许非 TLS 明文认证(仅限内网兼容场景显式开启)。
|
||||
// 默认 false:配置了 TLS 时,非回环来源必须先 STLS 再认证。
|
||||
AllowInsecureAuth bool `toml:"allow_insecure_auth"`
|
||||
}
|
||||
|
||||
// AuthConfig holds external authentication settings (OAuth2, LDAP).
|
||||
|
||||
+52
-26
@@ -96,40 +96,66 @@ func (p *OAuth2Provider) fetchUserEmail(token *oauth2.Token) (string, error) {
|
||||
}
|
||||
|
||||
if p.cfg.OAuth2Provider == "google" {
|
||||
var userInfo struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &userInfo); err != nil {
|
||||
return "", fmt.Errorf("解析Google用户信息失败: %w", err)
|
||||
}
|
||||
if userInfo.Email == "" {
|
||||
return "", fmt.Errorf("Google账户未关联邮箱")
|
||||
}
|
||||
return userInfo.Email, nil
|
||||
return parseGoogleEmail(body)
|
||||
}
|
||||
|
||||
if p.cfg.OAuth2Provider == "github" {
|
||||
var emails []struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &emails); err != nil {
|
||||
return "", fmt.Errorf("解析GitHub用户信息失败: %w", err)
|
||||
}
|
||||
for _, e := range emails {
|
||||
if e.Primary {
|
||||
return e.Email, nil
|
||||
}
|
||||
}
|
||||
if len(emails) > 0 {
|
||||
return emails[0].Email, nil
|
||||
}
|
||||
return "", fmt.Errorf("GitHub账户未关联邮箱")
|
||||
return parseGitHubEmail(body)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("OAuth2 邮箱获取尚未实现")
|
||||
}
|
||||
|
||||
// parseGoogleEmail 解析 Google userinfo 响应,仅接受已验证邮箱。
|
||||
// verified_email 缺失按未验证处理(fail closed):未验证邮箱可能不属于
|
||||
// 该账号所有者,放行会允许冒充既有用户登录(账号接管)。
|
||||
func parseGoogleEmail(body []byte) (string, error) {
|
||||
var userInfo struct {
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"verified_email"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &userInfo); err != nil {
|
||||
return "", fmt.Errorf("解析Google用户信息失败: %w", err)
|
||||
}
|
||||
if userInfo.Email == "" {
|
||||
return "", fmt.Errorf("Google账户未关联邮箱")
|
||||
}
|
||||
if !userInfo.EmailVerified {
|
||||
return "", fmt.Errorf("Google 账号邮箱未验证")
|
||||
}
|
||||
return userInfo.Email, nil
|
||||
}
|
||||
|
||||
// parseGitHubEmail 解析 /user/emails 响应,仅接受已验证邮箱:主邮箱优先,
|
||||
// 其次任一已验证邮箱。GitHub 该接口当前实际只返回已验证邮箱,显式校验
|
||||
// 使登录安全不依赖该行为。
|
||||
func parseGitHubEmail(body []byte) (string, error) {
|
||||
var emails []struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &emails); err != nil {
|
||||
return "", fmt.Errorf("解析GitHub用户信息失败: %w", err)
|
||||
}
|
||||
var fallback string
|
||||
for _, e := range emails {
|
||||
if e.Email == "" || !e.Verified {
|
||||
continue
|
||||
}
|
||||
if e.Primary {
|
||||
return e.Email, nil
|
||||
}
|
||||
if fallback == "" {
|
||||
fallback = e.Email
|
||||
}
|
||||
}
|
||||
if fallback != "" {
|
||||
return fallback, nil
|
||||
}
|
||||
return "", fmt.Errorf("GitHub账户没有已验证邮箱")
|
||||
}
|
||||
|
||||
// Authenticate 实现 Provider 接口(OAuth2 不使用此方法,通过回调流程认证)
|
||||
func (p *OAuth2Provider) Authenticate(credentials map[string]string) (string, error) {
|
||||
return "", fmt.Errorf("OAuth2 不支持直接认证,请使用回调流程")
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package auth
|
||||
|
||||
// OAuth2 邮箱解析回归测试(#22):仅接受已验证邮箱——未验证邮箱可能
|
||||
// 不属于该账号所有者,放行会允许冒充既有用户登录(账号接管)。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseGitHubEmailPrefersVerifiedPrimary(t *testing.T) {
|
||||
body := []byte(`[{"email":"p@example.com","primary":true,"verified":true}]`)
|
||||
email, err := parseGitHubEmail(body)
|
||||
if err != nil {
|
||||
t.Fatalf("parseGitHubEmail: %v", err)
|
||||
}
|
||||
if email != "p@example.com" {
|
||||
t.Fatalf("email = %q, want p@example.com", email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGitHubEmailSkipsUnverifiedPrimary(t *testing.T) {
|
||||
// 主邮箱未验证:不能用它登录,回退到任一已验证邮箱
|
||||
body := []byte(`[
|
||||
{"email":"unverified@example.com","primary":true,"verified":false},
|
||||
{"email":"verified@example.com","primary":false,"verified":true}
|
||||
]`)
|
||||
email, err := parseGitHubEmail(body)
|
||||
if err != nil {
|
||||
t.Fatalf("parseGitHubEmail: %v", err)
|
||||
}
|
||||
if email != "verified@example.com" {
|
||||
t.Fatalf("email = %q, want verified@example.com", email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGitHubEmailRejectsAllUnverified(t *testing.T) {
|
||||
body := []byte(`[
|
||||
{"email":"a@example.com","primary":true,"verified":false},
|
||||
{"email":"b@example.com","primary":false,"verified":false}
|
||||
]`)
|
||||
_, err := parseGitHubEmail(body)
|
||||
if err == nil || !strings.Contains(err.Error(), "已验证") {
|
||||
t.Fatalf("expected unverified-email rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGitHubEmailMalformedBody(t *testing.T) {
|
||||
if _, err := parseGitHubEmail([]byte("not json")); err == nil {
|
||||
t.Fatal("expected error for malformed body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGoogleEmailAcceptsVerified(t *testing.T) {
|
||||
email, err := parseGoogleEmail([]byte(`{"email":"a@example.com","verified_email":true}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parseGoogleEmail: %v", err)
|
||||
}
|
||||
if email != "a@example.com" {
|
||||
t.Fatalf("email = %q, want a@example.com", email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGoogleEmailRejectsUnverified(t *testing.T) {
|
||||
if _, err := parseGoogleEmail([]byte(`{"email":"a@example.com","verified_email":false}`)); err == nil {
|
||||
t.Fatal("expected rejection for verified_email=false")
|
||||
}
|
||||
// verified_email 缺失按未验证处理(fail closed)
|
||||
if _, err := parseGoogleEmail([]byte(`{"email":"a@example.com"}`)); err == nil {
|
||||
t.Fatal("expected rejection when verified_email is missing")
|
||||
}
|
||||
}
|
||||
@@ -366,6 +366,9 @@ var en = map[string]string{
|
||||
"取消失败: %v": "Cancel failed: %v",
|
||||
"无效的队列ID": "Invalid queue ID",
|
||||
"禁止访问:需要管理员权限": "Access denied: administrator privileges required",
|
||||
"密码长度至少为 8 个字符": "Password must be at least 8 characters",
|
||||
"用户名格式无效(仅限字母、数字与 . _ -)": "Invalid username (letters, digits, dot, underscore and hyphen only)",
|
||||
"域名格式无效": "Invalid domain name format",
|
||||
}
|
||||
|
||||
// ja 日文目录:key 为界面中的原始中文字符串;缺译回退英文目录。
|
||||
@@ -733,4 +736,7 @@ var ja = map[string]string{
|
||||
"取消失败: %v": "キャンセルに失敗しました: %v",
|
||||
"无效的队列ID": "無効なキュー ID",
|
||||
"禁止访问:需要管理员权限": "アクセスが拒否されました:管理者権限が必要です",
|
||||
"密码长度至少为 8 个字符": "パスワードは8文字以上で入力してください",
|
||||
"用户名格式无效(仅限字母、数字与 . _ -)": "ユーザー名の形式が無効です(英数字と . _ - のみ使用できます)",
|
||||
"域名格式无效": "ドメイン名の形式が無効です",
|
||||
}
|
||||
@@ -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)
|
||||
@@ -159,27 +159,58 @@ func TestSeqStoreServerIssued(t *testing.T) {
|
||||
assertReadState(t, stores, ids[2], true)
|
||||
}
|
||||
|
||||
// TestSeqStoreClientSelfNumbered 复现风险场景:客户端不信任服务器序号,
|
||||
// 按自己的视图(日期倒序,最新在前)自行编号后发 seq 式 STORE。
|
||||
// 服务器规范排序必须与常见客户端视图一致(date DESC, id DESC),
|
||||
// 否则会把另一封邮件标为已读、目标邮件永远未读。
|
||||
func TestSeqStoreClientSelfNumbered(t *testing.T) {
|
||||
// TestSeqOrderArrival 验证序号按到达顺序(id ASC,最早 = seq 1)分配:
|
||||
// 与主流服务器行为一致——新邮件永远追加到末尾(seq = 新 EXISTS 数),
|
||||
// 既不位移既有邮件序号,也能被 seq 增量同步(seq 4)正确获取;
|
||||
// INTERNALDATE 返回到达时间(CreatedAt)而非 Date 头。
|
||||
func TestSeqOrderArrival(t *testing.T) {
|
||||
stores, addr := startIntegrationServer(t)
|
||||
ids := seedMailbox(t, stores, 1, 3) // 3 封,日期递增,最新的是 ids[2]
|
||||
ids := seedMailbox(t, stores, 1, 3)
|
||||
|
||||
c := loginAndSelect(t, addr)
|
||||
|
||||
// 客户端按日期倒序视图:最新一封 = seq 1
|
||||
cmd := c.Store(imap.SeqSetNum(1), &imap.StoreFlags{
|
||||
Op: imap.StoreFlagsAdd,
|
||||
Flags: []imap.Flag{imap.FlagSeen},
|
||||
}, nil)
|
||||
if _, err := cmd.Collect(); err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
msgs, err := c.Fetch(imap.SeqSetNum(1, 2, 3), &imap.FetchOptions{UID: true}).Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
seqOf := map[imap.UID]uint32{}
|
||||
for _, m := range msgs {
|
||||
seqOf[m.UID] = m.SeqNum
|
||||
}
|
||||
if seqOf[imap.UID(ids[0])] != 1 || seqOf[imap.UID(ids[1])] != 2 || seqOf[imap.UID(ids[2])] != 3 {
|
||||
t.Fatalf("seq mapping = %v, want ids[0]=1 ids[1]=2 ids[2]=3", seqOf)
|
||||
}
|
||||
|
||||
// 客户端意图是标记最新一封(ids[2])为已读
|
||||
assertReadState(t, stores, ids[2], true)
|
||||
// 新邮件到达(Date 头较旧):仍追加到末尾,不移位既有邮件
|
||||
late := &db.Message{
|
||||
UserID: 1,
|
||||
Folder: "INBOX",
|
||||
FromAddr: "x@y",
|
||||
ToAddr: "alice@example.com",
|
||||
Subject: "late",
|
||||
Date: time.Now().Add(-24 * time.Hour),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := stores.Mails.Create(late); err != nil {
|
||||
t.Fatalf("create late message: %v", err)
|
||||
}
|
||||
|
||||
msgs, err = c.Fetch(imap.SeqSetNum(4), &imap.FetchOptions{UID: true, InternalDate: true}).Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("fetch seq 4: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 || msgs[0].UID != imap.UID(late.ID) {
|
||||
t.Fatalf("seq 4 = %v, want 新邮件 uid=%d", msgs, late.ID)
|
||||
}
|
||||
|
||||
// INTERNALDATE = 到达时间(CreatedAt),而不是 Date 头(协议格式仅到秒)
|
||||
stored, err := stores.Mails.GetByID(late.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get late msg: %v", err)
|
||||
}
|
||||
if !msgs[0].InternalDate.Equal(stored.CreatedAt.Truncate(time.Second)) {
|
||||
t.Fatalf("internaldate = %v, want CreatedAt %v", msgs[0].InternalDate, stored.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchBodyMalformedMIME 回归:消息包含无法解析的 MIME(base64 编码的
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{}),
|
||||
|
||||
@@ -213,6 +213,14 @@ func (s *imapSession) Login(username, password string) error {
|
||||
clientIP := store.ClientIPFromAddr(s.conn.NetConn().RemoteAddr())
|
||||
now := time.Now()
|
||||
|
||||
// 明文认证限制(与 SMTP/POP3 策略一致):配置了 TLS 且来源非本机
|
||||
// 回环时,必须先 STARTTLS 再 LOGIN,防止密码被网络窃听;
|
||||
// allow_insecure_auth 配置可显式放行旧客户端。
|
||||
if _, tlsOn := s.conn.NetConn().(*tls.Conn); !tlsOn && !s.srv.cfg.AllowInsecureAuth && s.srv.tlsLoader != nil && !store.IsLoopbackIP(clientIP) {
|
||||
s.recordLogin(clientIP, username, false, "明文认证被拒绝", "LOGIN 失败(需先 STARTTLS)", now)
|
||||
return &imap.Error{Type: imap.StatusResponseTypeNo, Text: "TLS required: start TLS before authentication"}
|
||||
}
|
||||
|
||||
if banned, _ := s.srv.stores.Bans.IsBanned(clientIP); banned {
|
||||
s.recordLogin(clientIP, username, false, "IP已被封禁", "认证被拒绝(IP 已封禁)", now)
|
||||
return imapserver.ErrAuthFailed
|
||||
@@ -497,7 +505,13 @@ func (s *imapSession) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, optio
|
||||
fw.WriteRFC822Size(int64(len(raw)))
|
||||
}
|
||||
if options.InternalDate {
|
||||
fw.WriteInternalDate(msg.Date)
|
||||
// INTERNALDATE 是服务器接收时间(RFC 3501 §2.3.4),不是 Date
|
||||
// 头;使用 CreatedAt(到达时间),旧数据为零时降级为 Date。
|
||||
arrival := msg.CreatedAt
|
||||
if arrival.IsZero() {
|
||||
arrival = msg.Date
|
||||
}
|
||||
fw.WriteInternalDate(arrival)
|
||||
}
|
||||
if options.Envelope {
|
||||
var env *imap.Envelope
|
||||
@@ -963,6 +977,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 {
|
||||
@@ -972,17 +987,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
@@ -139,7 +142,12 @@ func (s *POP3Server) handleConn(conn net.Conn, port int) {
|
||||
|
||||
// 连接追踪:注册到当前连接中心,连接结束时注销;
|
||||
// 强制断开:关闭底层连接(STLS 后 conn 变量已指向 tlsConn,同样生效)。
|
||||
activeConn := s.hub.Register("pop3", clientIP, port, false)
|
||||
// 隐式 TLS 端口(POP3S)的连接本身就是 tls.Conn,此处一并识别。
|
||||
tlsActive := false
|
||||
if _, ok := conn.(*tls.Conn); ok {
|
||||
tlsActive = true
|
||||
}
|
||||
activeConn := s.hub.Register("pop3", clientIP, port, tlsActive)
|
||||
if activeConn != nil {
|
||||
activeConn.SetDisconnect(func() { _ = conn.Close() })
|
||||
}
|
||||
@@ -156,7 +164,6 @@ func (s *POP3Server) handleConn(conn net.Conn, port int) {
|
||||
reader := bufio.NewReader(conn)
|
||||
var messages []pop3Message
|
||||
var deleted map[int]bool
|
||||
tlsActive := false
|
||||
|
||||
defer func() {
|
||||
if activeConn != nil {
|
||||
@@ -197,6 +204,14 @@ func (s *POP3Server) handleConn(conn net.Conn, port int) {
|
||||
authUsername = arg
|
||||
authUser, messages, deleted = s.handleUSER(conn, arg, authUser)
|
||||
case "PASS":
|
||||
// 明文认证限制(与 SMTP 的 AllowInsecureAuth 语义一致):配置了
|
||||
// TLS 且来源非本机回环时,必须先 STLS 再认证,防止密码被网络
|
||||
// 窃听;allow_insecure_auth 配置可显式放行旧客户端。
|
||||
if !tlsActive && !s.cfg.AllowInsecureAuth && s.tlsLoader != nil && !store.IsLoopbackIP(clientIP) {
|
||||
authFailReason = "明文认证被拒绝(需先 STLS)"
|
||||
sendResponse(conn, "-ERR TLS required: use STLS before authentication")
|
||||
continue
|
||||
}
|
||||
authUser, messages, deleted = s.handlePASS(conn, arg, authUser)
|
||||
if authUser == nil || authUser.ID == 0 {
|
||||
if authFailReason == "" {
|
||||
@@ -556,8 +571,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++
|
||||
|
||||
@@ -2,14 +2,25 @@ 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"
|
||||
@@ -247,3 +258,177 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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"])
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,14 @@ func ClientIPFromAddr(addr net.Addr) string {
|
||||
return host
|
||||
}
|
||||
|
||||
// IsLoopbackIP 判断 IP 是否为本机回环地址(127.0.0.0/8、::1)。
|
||||
// 明文认证仅允许来自回环的连接(与 Dovecot disable_plaintext_auth 的
|
||||
// 语义一致),非回环来源要求先完成 TLS;非 IP 字符串一律视为非回环。
|
||||
func IsLoopbackIP(ip string) bool {
|
||||
parsed := net.ParseIP(ip)
|
||||
return parsed != nil && parsed.IsLoopback()
|
||||
}
|
||||
|
||||
// RecordAuthFailure 记录一次登录/认证失败(Web 表单、LDAP 与 SMTP/IMAP/POP3
|
||||
// 协议层统一入口):
|
||||
// - 失败计数累加(每 IP 一条记录,upsert);
|
||||
|
||||
@@ -163,9 +163,15 @@ func (s *banStoreGorm) IncrementFail(ip string) (int, error) {
|
||||
return 0, res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// 无记录:插入首条;ip_address 唯一索引下并发插入用
|
||||
// OnConflict DoNothing 兜底,失败方继续走下面的回读。
|
||||
err := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&db.BanEntry{
|
||||
// 无记录:插入首条。并发下若记录已被其他请求抢先创建,冲突时
|
||||
// 改为自增而非 DoNothing——否则两个 UPDATE 都在记录存在前提交时,
|
||||
// 败者的本次自增会丢失(fail_count 少 1)。
|
||||
err := s.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "ip_address"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"fail_count": gorm.Expr("fail_count + 1"),
|
||||
}),
|
||||
}).Create(&db.BanEntry{
|
||||
IPAddress: ip,
|
||||
FailCount: 1,
|
||||
BanCount: 0,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsLoopbackIP 验证回环地址判定:明文认证仅放行回环来源(#25),
|
||||
// 非 IP 字符串(如 net.Pipe 的 "pipe")一律视为非回环。
|
||||
func TestIsLoopbackIP(t *testing.T) {
|
||||
loopback := []string{"127.0.0.1", "127.9.9.9", "::1"}
|
||||
for _, ip := range loopback {
|
||||
if !IsLoopbackIP(ip) {
|
||||
t.Fatalf("IsLoopbackIP(%q) = false, want true", ip)
|
||||
}
|
||||
}
|
||||
external := []string{"203.0.113.5", "", "pipe", "not-an-ip"}
|
||||
for _, ip := range external {
|
||||
if IsLoopbackIP(ip) {
|
||||
t.Fatalf("IsLoopbackIP(%q) = true, want false", ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ type MailStore interface {
|
||||
// SetDeletedStates 批量设置多封邮件的 \Deleted 标记(单条 UPDATE ... IN)。
|
||||
SetDeletedStates(ids []uint, deleted bool) error
|
||||
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件
|
||||
// (按 date DESC, id DESC 排序,与全量列表一致,序号映射全链路相同)。
|
||||
// (按 id ASC 排序,与全量列表一致,序号映射全链路相同)。
|
||||
ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error)
|
||||
// DeleteMany 批量硬删除多封邮件(单条 DELETE ... IN)。
|
||||
DeleteMany(ids []uint) error
|
||||
@@ -93,7 +93,7 @@ func (s *mailStoreGorm) ListByUserAndFolder(userID uint, folder string, page, si
|
||||
}
|
||||
|
||||
offset := (page - 1) * size
|
||||
if err := query.Order("date DESC").Offset(offset).Limit(size).Find(&messages).Error; err != nil {
|
||||
if err := query.Order("date DESC, id DESC").Offset(offset).Limit(size).Find(&messages).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return messages, total, nil
|
||||
@@ -145,11 +145,12 @@ func (s *mailStoreGorm) SetDeletedStates(ids []uint, deleted bool) error {
|
||||
return s.db.Model(&db.Message{}).Where("id IN ?", ids).Update("is_deleted", deleted).Error
|
||||
}
|
||||
|
||||
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件。
|
||||
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件
|
||||
// (按 id ASC 排序,与全量列表一致,序号映射全链路相同)。
|
||||
func (s *mailStoreGorm) ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error) {
|
||||
var messages []db.Message
|
||||
if err := s.db.Where("user_id = ? AND folder = ? AND is_deleted = ?", userID, folder, true).
|
||||
Order("date DESC, id DESC").Find(&messages).Error; err != nil {
|
||||
Order("id ASC").Find(&messages).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return messages, nil
|
||||
@@ -180,14 +181,16 @@ func (s *mailStoreGorm) CountUnread(userID uint, folder string) (int64, error) {
|
||||
}
|
||||
|
||||
// ListAllByUserAndFolder retrieves all messages for a user in a folder without pagination.
|
||||
// 按 date DESC, id DESC 排序(最新在前):与主流邮件客户端(Thunderbird、
|
||||
// 手机客户端等)默认视图一致,客户端自行按日期编号的 seq 式 STORE 不会
|
||||
// 错位标错邮件。所有 IMAP 序号相关路径(Status/ListMessages/推送/seqOf)
|
||||
// 共用本排序,保证序号全链路一致。
|
||||
// 按 id ASC(到达顺序,最早在前)排序:新邮件永远获得最大序号(seq =
|
||||
// EXISTS 数),与主流服务器(Dovecot/Courier)行为一致,依赖「新邮件 =
|
||||
// seq N+1」做增量同步的客户端不会漏收或标错邮件;新邮件到达不会使既有
|
||||
// 邮件序号位移(只有 EXPUNGE 才会,属正常行为)。所有 IMAP 序号相关路径
|
||||
// (Status/Fetch/Search/Store/Copy/Move/Expunge/推送/seqOf)共用本排序,
|
||||
// 保证序号全链路一致。INTERNALDATE 使用 CreatedAt(到达时间),与排序一致。
|
||||
func (s *mailStoreGorm) ListAllByUserAndFolder(userID uint, folder string) ([]db.Message, error) {
|
||||
var messages []db.Message
|
||||
if err := s.db.Where("user_id = ? AND folder = ?", userID, folder).
|
||||
Order("date DESC, id DESC").Find(&messages).Error; err != nil {
|
||||
Order("id ASC").Find(&messages).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return messages, nil
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -65,6 +66,45 @@ func (h *AdminHandler) dayStartIn() time.Time {
|
||||
// manualBanDuration 管理员手动封禁时长(180 天,与自动封禁档位上限制一致)。
|
||||
const manualBanDuration = 180 * 24 * time.Hour
|
||||
|
||||
// minPasswordLength 新密码最小长度(NIST SP 800-63B:长度优先于复杂度)。
|
||||
const minPasswordLength = 8
|
||||
|
||||
// usernameRe 用户名字符集白名单:字母、数字、点、下划线、连字符,长度
|
||||
// 1-64。禁止 @ 与空白等字符——GetByEmail 按首个 @ 切分用户名与域名,
|
||||
// 含 @ 的用户名会生成 "a@b@example.com" 这类解析歧义地址。
|
||||
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`)
|
||||
|
||||
// domainLabelRe 域名标签:字母/数字开头结尾,中间允许连字符。
|
||||
var domainLabelRe = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$`)
|
||||
|
||||
// validDomainName 校验域名格式:标签以点分隔、总长 ≤253(允许 localhost
|
||||
// 等单标签内部域名)。
|
||||
func validDomainName(name string) bool {
|
||||
if name == "" || len(name) > 253 {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(name, ".") {
|
||||
if len(label) > 63 || !domainLabelRe.MatchString(label) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// renderUserFormError 渲染用户表单错误页(新建/编辑共用)。
|
||||
func (h *AdminHandler) renderUserFormError(c *gin.Context, isEdit bool, user *db.User, message string) {
|
||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(http.StatusBadRequest, "admin_user_form", withLang(c, gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "users",
|
||||
"error": message,
|
||||
"isEdit": isEdit,
|
||||
"domains": domains,
|
||||
"user": user,
|
||||
}))
|
||||
}
|
||||
|
||||
// DisconnectConnection 强制断开指定连接并封禁其 IP(管理后台「断开并封禁」)。
|
||||
// 封禁后该 IP 的所有在线连接一并断开。
|
||||
func (h *AdminHandler) DisconnectConnection(c *gin.Context) {
|
||||
@@ -244,6 +284,24 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !validDomainName(name) {
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(http.StatusBadRequest, "admin_domain_form", withLang(c, gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "domains",
|
||||
"error": i18n.T(langOf(c), "域名格式无效"),
|
||||
"isEdit": false,
|
||||
"domain": &db.Domain{
|
||||
Name: name,
|
||||
SmtpPort: smtpPort,
|
||||
ImapPort: imapPort,
|
||||
Pop3Port: pop3Port,
|
||||
TlsEnabled: tlsEnabled,
|
||||
},
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
domain := &db.Domain{
|
||||
Name: name,
|
||||
SmtpPort: smtpPort,
|
||||
@@ -634,6 +692,15 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !usernameRe.MatchString(username) {
|
||||
h.renderUserFormError(c, false, &db.User{Username: username, DomainID: domainID, IsAdmin: isAdmin}, i18n.T(langOf(c), "用户名格式无效(仅限字母、数字与 . _ -)"))
|
||||
return
|
||||
}
|
||||
if len(password) < minPasswordLength {
|
||||
h.renderUserFormError(c, false, &db.User{Username: username, DomainID: domainID, IsAdmin: isAdmin}, i18n.T(langOf(c), "密码长度至少为 8 个字符"))
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@@ -755,6 +822,10 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
|
||||
password := c.PostForm("password")
|
||||
|
||||
if username != "" {
|
||||
if !usernameRe.MatchString(username) {
|
||||
h.renderUserFormError(c, true, user, i18n.T(langOf(c), "用户名格式无效(仅限字母、数字与 . _ -)"))
|
||||
return
|
||||
}
|
||||
user.Username = username
|
||||
}
|
||||
user.DomainID = domainID
|
||||
@@ -768,6 +839,10 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
|
||||
|
||||
// Update password only if a new one is provided
|
||||
if password != "" {
|
||||
if len(password) < minPasswordLength {
|
||||
h.renderUserFormError(c, true, user, i18n.T(langOf(c), "密码长度至少为 8 个字符"))
|
||||
return
|
||||
}
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package handlers
|
||||
|
||||
// 管理端输入校验回归测试(#23/#24):用户名/域名格式白名单、密码最小
|
||||
// 长度。含纯函数校验与 handler 层拒绝路径。
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/storage"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestUsernameReWhitelist(t *testing.T) {
|
||||
valid := []string{"alice", "a.b_c-d", "A1", "x", "user.01"}
|
||||
invalid := []string{"", "a@b", "a b", "a/b", "a\\b", "中文", "a:b"}
|
||||
for _, name := range valid {
|
||||
if !usernameRe.MatchString(name) {
|
||||
t.Fatalf("usernameRe(%q) = false, want true", name)
|
||||
}
|
||||
}
|
||||
for _, name := range invalid {
|
||||
if usernameRe.MatchString(name) {
|
||||
t.Fatalf("usernameRe(%q) = true, want false", name)
|
||||
}
|
||||
}
|
||||
if usernameRe.MatchString(strings.Repeat("a", 65)) {
|
||||
t.Fatal("usernameRe should reject 65-char username")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidDomainName(t *testing.T) {
|
||||
valid := []string{"example.com", "localhost", "a-b.co", "A1.Example.COM", "mail.example.co.uk"}
|
||||
invalid := []string{
|
||||
"", "a@b", "a b", "-a.com", "a-.com", "a..com", ".a.com", "a.com.",
|
||||
"例え.jp", strings.Repeat("a", 64) + ".com", strings.Repeat("a", 254),
|
||||
}
|
||||
for _, name := range valid {
|
||||
if !validDomainName(name) {
|
||||
t.Fatalf("validDomainName(%q) = false, want true", name)
|
||||
}
|
||||
}
|
||||
for _, name := range invalid {
|
||||
if validDomainName(name) {
|
||||
t.Fatalf("validDomainName(%q) = true, want false", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newAdminTestHandler(t *testing.T) (*AdminHandler, *store.Stores) {
|
||||
t.Helper()
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.Mailbox{}, &db.MailboxState{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
if err := stores.Users.Create(&db.User{ID: 1, Username: "admin", Domain: db.Domain{Name: "example.com"}, DomainID: 1, IsAdmin: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attStorage := storage.NewAttachmentStorage(t.TempDir())
|
||||
return NewAdminHandler(stores, attStorage, filepath.Join(t.TempDir(), "tls"), "", nil, 30, nil, nil), stores
|
||||
}
|
||||
|
||||
func newAdminTestRouter(h *AdminHandler) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
tmpl := template.Must(template.New("").Funcs(testTemplateFuncs()).ParseGlob(filepath.Join("..", "templates", "*.html")))
|
||||
template.Must(tmpl.ParseGlob(filepath.Join("..", "templates", "admin", "*.html")))
|
||||
r.SetHTMLTemplate(tmpl)
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("userID", uint(1))
|
||||
c.Set("currentUser", &db.User{ID: 1, Username: "admin", Domain: db.Domain{Name: "example.com"}, IsAdmin: true})
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/admin/users", h.CreateUser)
|
||||
r.POST("/admin/users/:id", h.UpdateUser)
|
||||
r.POST("/admin/domains", h.CreateDomain)
|
||||
return r
|
||||
}
|
||||
|
||||
func postForm(r *gin.Engine, path, form string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestCreateUserRejectsInvalidUsername(t *testing.T) {
|
||||
h, stores := newAdminTestHandler(t)
|
||||
r := newAdminTestRouter(h)
|
||||
|
||||
w := postForm(r, "/admin/users", "username=a@b&password=secret123&domain_id=1")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
if _, total, _ := stores.Users.ListAll(1, 100); total != 1 {
|
||||
t.Fatalf("user count = %d, want 1 (not created)", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserRejectsShortPassword(t *testing.T) {
|
||||
h, stores := newAdminTestHandler(t)
|
||||
r := newAdminTestRouter(h)
|
||||
|
||||
w := postForm(r, "/admin/users", "username=bob&password=short&domain_id=1")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
if _, total, _ := stores.Users.ListAll(1, 100); total != 1 {
|
||||
t.Fatalf("user count = %d, want 1 (not created)", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserAcceptsValidInput(t *testing.T) {
|
||||
h, stores := newAdminTestHandler(t)
|
||||
r := newAdminTestRouter(h)
|
||||
|
||||
w := postForm(r, "/admin/users", "username=bob&password=secret123&domain_id=1")
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302", w.Code)
|
||||
}
|
||||
if _, total, _ := stores.Users.ListAll(1, 100); total != 2 {
|
||||
t.Fatalf("user count = %d, want 2", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserRejectsInvalidUsername(t *testing.T) {
|
||||
h, stores := newAdminTestHandler(t)
|
||||
r := newAdminTestRouter(h)
|
||||
|
||||
w := postForm(r, "/admin/users/1", "username=a@b")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
u, err := stores.Users.GetByID(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.Username != "admin" {
|
||||
t.Fatalf("username = %q, want unchanged admin", u.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDomainRejectsInvalidName(t *testing.T) {
|
||||
h, stores := newAdminTestHandler(t)
|
||||
r := newAdminTestRouter(h)
|
||||
|
||||
w := postForm(r, "/admin/domains", "name=a@b")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
if _, total, _ := stores.Domains.List(1, 100); total != 1 {
|
||||
t.Fatalf("domain count = %d, want 1 (not created)", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDomainAcceptsValidName(t *testing.T) {
|
||||
h, stores := newAdminTestHandler(t)
|
||||
r := newAdminTestRouter(h)
|
||||
|
||||
w := postForm(r, "/admin/domains", "name=corp.example.net")
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302", w.Code)
|
||||
}
|
||||
if _, total, _ := stores.Domains.List(1, 100); total != 2 {
|
||||
t.Fatalf("domain count = %d, want 2", total)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -266,11 +255,21 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
// Handle attachments and check quota
|
||||
form, multipartErr := c.MultipartForm()
|
||||
attachments := make([]pendingAttachment, 0)
|
||||
// 统一配额补偿:quotaReserved 为本次预扣总量,persistedBytes 为实际
|
||||
// 落库的附件量;函数任何路径退出(含外发入队失败/本地投递失败/Sent
|
||||
// 副本保存失败的提前 return)都退还预扣量中未落库的部分,杜绝配额
|
||||
// 凭空泄漏。
|
||||
var quotaReserved, persistedBytes int64
|
||||
defer func() {
|
||||
if remaining := quotaReserved - persistedBytes; remaining > 0 {
|
||||
_ = h.stores.Users.UpdateUsedBytes(userID, -remaining)
|
||||
}
|
||||
}()
|
||||
if multipartErr == nil {
|
||||
files := form.File["attachments"]
|
||||
if len(files) > 0 {
|
||||
// 原子预扣附件配额(单条 SQL:used_bytes + n <= quota_bytes 才生效),
|
||||
// 防止并发提交绕过配额检查(TOCTOU)。后续保存失败会补偿回退。
|
||||
// 防止并发提交绕过配额检查(TOCTOU)。补偿由上方 defer 统一处理。
|
||||
var totalNewSize int64
|
||||
for _, file := range files {
|
||||
totalNewSize += file.Size
|
||||
@@ -300,20 +299,19 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
}))
|
||||
return
|
||||
}
|
||||
quotaReserved = totalNewSize
|
||||
|
||||
// Read all attachment files into memory once (used for both the
|
||||
// MIME message body and the stored attachment records).
|
||||
// 读取失败的文件回退已预扣的配额。
|
||||
// 读取失败的文件不会落库,其份额由 defer 统一回退。
|
||||
for _, file := range files {
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
_ = h.stores.Users.UpdateUsedBytes(userID, -file.Size)
|
||||
continue
|
||||
}
|
||||
buf, readErr := io.ReadAll(f)
|
||||
f.Close()
|
||||
if readErr != nil {
|
||||
_ = h.stores.Users.UpdateUsedBytes(userID, -file.Size)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -418,12 +416,11 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Save attachment records linked to the Sent copy (bytes were already
|
||||
// read during message construction). 配额已在前面原子预扣,
|
||||
// 保存/落库失败的附件需要补偿回退。
|
||||
// read during message construction). 配额已在前面原子预扣,落库成功
|
||||
// 的份额记入 persistedBytes,失败份额由 defer 统一回退。
|
||||
for _, att := range attachments {
|
||||
relPath, err := h.storage.Save(att.filename, att.data)
|
||||
if err != nil {
|
||||
_ = h.stores.Users.UpdateUsedBytes(userID, -int64(len(att.data)))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -435,9 +432,9 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
FileSize: int64(len(att.data)),
|
||||
}
|
||||
if err := h.stores.Attachments.Create(attRecord); err != nil {
|
||||
_ = h.stores.Users.UpdateUsedBytes(userID, -attRecord.FileSize)
|
||||
continue
|
||||
}
|
||||
persistedBytes += attRecord.FileSize
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/sent")
|
||||
@@ -851,6 +848,11 @@ func (h *MailHandler) UpdateSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(newPassword) < minPasswordLength {
|
||||
c.HTML(http.StatusBadRequest, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "密码长度至少为 8 个字符"), ""))
|
||||
return
|
||||
}
|
||||
|
||||
if newPassword != confirmPassword {
|
||||
c.HTML(http.StatusBadRequest, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "两次输入的密码不一致"), ""))
|
||||
return
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package handlers
|
||||
|
||||
// DoSend 配额记账回归测试(#21):外发入队失败等提前 return 的路径必须
|
||||
// 全额回退预扣量(修复前配额凭空泄漏);成功投递时预扣量与实际落库量
|
||||
// 一致,统一补偿不误退。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/imap_server"
|
||||
"mail_go/internal/storage"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newSendTestHandler(t *testing.T) (*MailHandler, *store.Stores, string) {
|
||||
t.Helper()
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.Mailbox{}, &db.MailboxState{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
// 用户 1 发件(配额 1MB),用户 2 本地收件
|
||||
if err := stores.Users.Create(&db.User{ID: 1, Username: "alice", Domain: db.Domain{Name: "example.com"}, DomainID: 1, QuotaBytes: 1 << 20}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := stores.Users.Create(&db.User{ID: 2, Username: "bob", DomainID: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attDir := t.TempDir()
|
||||
return NewMailHandler(stores, storage.NewAttachmentStorage(attDir), nil, imap_server.NewMailboxService(stores), nil), stores, attDir
|
||||
}
|
||||
|
||||
func newSendTestRouter(h *MailHandler) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
tmpl := template.Must(template.New("").Funcs(testTemplateFuncs()).ParseGlob(filepath.Join("..", "templates", "*.html")))
|
||||
r.SetHTMLTemplate(tmpl)
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("userID", uint(1))
|
||||
c.Set("currentUser", &db.User{ID: 1, Username: "alice", Domain: db.Domain{Name: "example.com"}})
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/compose", h.DoSend)
|
||||
r.POST("/settings", h.UpdateSettings)
|
||||
return r
|
||||
}
|
||||
|
||||
func newComposeRequest(t *testing.T, to string, attachments map[string]string) *http.Request {
|
||||
t.Helper()
|
||||
body := &bytes.Buffer{}
|
||||
mw := multipart.NewWriter(body)
|
||||
if to != "" {
|
||||
if err := mw.WriteField("to", to); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for name, content := range attachments {
|
||||
fw, err := mw.CreateFormFile("attachments", name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fw.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/compose", body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
return req
|
||||
}
|
||||
|
||||
// TestDoSendRefundsQuotaWhenOutboundDisabled 验证外发未启用(入队失败路径)
|
||||
// 提前退出时,预扣的附件配额全额回退(修复前凭空泄漏)。
|
||||
func TestDoSendRefundsQuotaWhenOutboundDisabled(t *testing.T) {
|
||||
h, stores, _ := newSendTestHandler(t)
|
||||
r := newSendTestRouter(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, newComposeRequest(t, "stranger@gmail.com", map[string]string{"a.txt": "hello world"}))
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
u, err := stores.Users.GetByID(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.UsedBytes != 0 {
|
||||
t.Fatalf("used_bytes = %d, want 0 after refund", u.UsedBytes)
|
||||
}
|
||||
if n, _ := stores.Mails.CountAll(); n != 0 {
|
||||
t.Fatalf("messages = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoSendKeepsQuotaOnSuccess 验证成功投递时预扣量与实际落库量一致,
|
||||
// 统一补偿不误退(附件挂 Sent 副本)。
|
||||
func TestDoSendKeepsQuotaOnSuccess(t *testing.T) {
|
||||
h, stores, attDir := newSendTestHandler(t)
|
||||
r := newSendTestRouter(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, newComposeRequest(t, "bob@example.com", map[string]string{"a.txt": "hello world"}))
|
||||
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302", w.Code)
|
||||
}
|
||||
u, err := stores.Users.GetByID(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.UsedBytes != 11 {
|
||||
t.Fatalf("used_bytes = %d, want 11", u.UsedBytes)
|
||||
}
|
||||
|
||||
sent, total, err := stores.Mails.ListByUserAndFolder(1, "Sent", 1, 50)
|
||||
if err != nil || total != 1 || len(sent) != 1 {
|
||||
t.Fatalf("sent copies = %d (err=%v), want 1", len(sent), err)
|
||||
}
|
||||
atts, err := stores.Attachments.ListByMessage(sent[0].ID)
|
||||
if err != nil || len(atts) != 1 {
|
||||
t.Fatalf("attachments = %d (err=%v), want 1", len(atts), err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(attDir, atts[0].FilePath))
|
||||
if err != nil || string(content) != "hello world" {
|
||||
t.Fatalf("attachment file missing or wrong content (err=%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateSettingsRejectsShortPassword 验证修改密码的最小长度校验(#24):
|
||||
// 过短密码被拒且旧密码保持有效。
|
||||
func TestUpdateSettingsRejectsShortPassword(t *testing.T) {
|
||||
h, stores, _ := newSendTestHandler(t)
|
||||
r := newSendTestRouter(h)
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := stores.Users.UpdatePassword(1, string(hashed)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := strings.NewReader("old_password=secret123&new_password=short&confirm_password=short")
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/settings", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
// 旧密码必须仍然有效(密码未被修改)
|
||||
u, err := stores.Users.GetByID(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte("secret123")); err != nil {
|
||||
t.Fatal("old password should still work after rejected change")
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ func testTemplateFuncs() template.FuncMap {
|
||||
"div": func(a, b int) int { return a / b },
|
||||
"mod": func(a, b int) int { return a % b },
|
||||
"ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) },
|
||||
"durationSeconds": func(d time.Duration) int64 {
|
||||
return int64(d / time.Second)
|
||||
},
|
||||
"seq": func(n int) []int {
|
||||
r := make([]int, n)
|
||||
for i := range r {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+120
-2
@@ -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,28 @@
|
||||
- [x] 现有 OAuth2 测试仍通过(错误页文案不含内部细节)。
|
||||
- [ ] 线上(启用 LDAP/OAuth 后)验证失败页面不含内部地址/DN/原始错误串。
|
||||
|
||||
### 21. Web 发信配额预扣泄漏(第三轮,2026-08-28)
|
||||
|
||||
- [x] 位置:`internal/web/handlers/mail.go:278`(预扣)→ 367 / 390 / 416(泄漏点)
|
||||
- 现状:`TryReserveQuota` 预扣附件配额后,三处提前 return 不回退:外部收件人入队失败(367)、本地投递失败(390)、Sent 副本保存失败(416)。文件读取失败(310)与附件保存失败(426)有回退,上述路径漏了。用户反复提交含无效外部地址的带附件邮件,可把自己配额扣光且无对应文件占用(配额"凭空消失",需管理员改库恢复)。
|
||||
- 修复方案:
|
||||
- [x] defer + 成功标志统一补偿:未走到"附件记录全部落库"终点即回退剩余预扣量;或各失败分支显式回退。→ 实现 `defer + persistedBytes` 统一补偿:预扣总量记 `quotaReserved`、实际落库量记 `persistedBytes`,任何退出路径退还差额;同时移除读取/保存失败的分散回退
|
||||
- [x] 注意 367 处入队失败时可能已成功入队部分外部收件人,只回退附件未消耗部分。→ 附件仅随 Sent 副本落库,提前退出时未落库即全额退还,与外发队列的原始 MIME 内联附件无关
|
||||
- 验证:
|
||||
- [x] 单测:外部入队失败 / 本地投递失败路径退出后 `used_bytes` 恢复原值。(`TestDoSendRefundsQuotaWhenOutboundDisabled`;另含成功路径不误退 `TestDoSendKeepsQuotaOnSuccess`)
|
||||
- 已完成(2026-08-28)。
|
||||
|
||||
### 22. OAuth2 登录不校验邮箱 verified 状态(第三轮,2026-08-28)
|
||||
|
||||
- [x] 位置:`internal/auth/oauth2.go:111-127`
|
||||
- 现状:GitHub `/user/emails` 响应含 `verified` 字段但未检查,取 primary 首个即用;Google 未检查 `email_verified`。当前 GitHub 该 API 实际只返回已验证邮箱,现实可利用性低,但属依赖 IdP 实现细节;自建 provider(代码 33-36 行支持任意 host)场景下未验证邮箱可登录他人账号。
|
||||
- 修复方案:
|
||||
- [x] GitHub:过滤 `verified == true`(primary 优先),无已验证邮箱返回错误。→ 解析抽为 `parseGitHubEmail` 纯函数
|
||||
- [x] Google:解析 `email_verified`,false 或缺失时拒绝。→ `parseGoogleEmail`,缺失按未验证 fail closed
|
||||
- 验证:
|
||||
- [x] 单测:构造含未验证邮箱的响应被拒。(`oauth2_email_test.go` 6 项:主邮箱已验证优先、跳过未验证主邮箱回退、全部未验证拒绝、畸形响应、Google verified_email 缺失/为 false 拒绝)
|
||||
- 已完成(2026-08-28)。
|
||||
|
||||
## P3 低危 / 加固
|
||||
|
||||
### 12. Referer 开放重定向
|
||||
@@ -171,6 +220,56 @@
|
||||
- 验证:
|
||||
- [x] 单测:8 天前的会话被重定向登录页;1 小时前的会话正常访问(用配置密钥签名构造会话,`TestSessionAbsoluteExpiryForcesRelogin`/`TestSessionWithinExpiryWorks`)。
|
||||
|
||||
### 23. 管理员建用户/域名无格式校验(第三轮,2026-08-28)
|
||||
|
||||
- [x] 位置:`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 邮箱视图。
|
||||
- 修复方案:
|
||||
- [x] 用户名白名单 `^[a-zA-Z0-9._-]{1,64}$`;域名按标签校验(字母/数字/连字符、标签 ≤63、总长 ≤253)。→ 实现时未强制"至少一个点":允许 localhost 等单标签内部域名,标签格式校验已足够
|
||||
- [x] `UpdateUser` 改名/换域名同样校验;错误回显走 i18n(新增 `renderUserFormError` 共用渲染)。
|
||||
- 验证:
|
||||
- [x] 单测:`a@evil`、含空格/控制字符用户名被拒;畸形域名(首尾连字符、连续点、超长标签、非 ASCII、超总长)被拒;合法输入通过。(`admin_validation_test.go` 8 项:纯函数矩阵 + handler 拒绝/放行路径)
|
||||
- 已完成(2026-08-28)。
|
||||
|
||||
### 24. 密码无最小长度要求(第三轮,2026-08-28)
|
||||
|
||||
- [x] 位置:`internal/web/handlers/mail.go:849`(自助修改)、`internal/web/handlers/admin.go:638/771`(创建/重置)
|
||||
- 现状:新密码仅检查非空,1 位密码也接受,与系统整体安全水位不匹配。
|
||||
- 修复方案:
|
||||
- [x] 统一最小长度 8(`minPasswordLength`,NIST SP 800-63B:长度优先于复杂度),三处共用校验 + i18n 三语文案。
|
||||
- 验证:
|
||||
- [x] 单测:过短密码拒绝且旧密码保持有效。(`TestUpdateSettingsRejectsShortPassword`;管理员创建路径由 `TestCreateUserRejectsShortPassword` 覆盖)
|
||||
- 已完成(2026-08-28)。
|
||||
|
||||
### 25. POP3/IMAP 允许明文认证(第三轮,2026-08-28)
|
||||
|
||||
- [x] 位置:`internal/pop3_server/server.go:390`(`handlePASS`)、`internal/imap_server/session.go:212`(`Login`)
|
||||
- 现状:非 TLS 连接上 USER/PASS、LOGIN 明文传输无限制,被动嗅探可截获密码。自签证书总是自动生成,STLS/STARTTLS 能力具备(POP3 CAPA 已宣告 STLS)。
|
||||
- 修复方案:
|
||||
- [x] 回环来源放行(本地调试);非回环要求连接已 TLS(POP3 `tlsActive` / IMAP `NetConn()` 为 `*tls.Conn`)才接受认证,否则提示先执行 STLS/STARTTLS。→ 新增 `store.IsLoopbackIP`;顺带修复 POP3 隐式 TLS 端口(POP3S)`tlsActive` 恒为 false 的存量缺陷(原仅 STLS 命令置位),连接中心注册的 TLS 标记同步修正
|
||||
- [x] 加配置项控制:`[imap]/[pop3] allow_insecure_auth`(默认 false 强制 TLS 认证,极端明文内网部署可显式放行)。
|
||||
- 验证:
|
||||
- [x] 单测:非 TLS 非回环认证被拒并提示;`allow_insecure_auth=true` 时正常进入认证;回环明文可用(IMAP 侧为对称 6 行接线,由既有 loopback 集成测试覆盖)。(`TestPlainAuthRejectedWithoutTLSOnNonLoopback` / `TestPlainAuthAllowedWithInsecureAuthOption` / `TestIsLoopbackIP`)
|
||||
- 已完成(2026-08-28)。**部署注意**:默认行为收紧——升级后非 TLS 明文认证(非回环)将被拒绝,内网明文老客户端需改用 STARTTLS/STLS 或显式配置 `allow_insecure_auth = true`。
|
||||
|
||||
### 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 并发首建窗口丢失更新(第三轮修复过程中发现,存量问题)
|
||||
|
||||
- [x] 位置:`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,未被该测试触及)。
|
||||
- 修复方案:
|
||||
- [x] 失败 INSERT 改为冲突时自增:`OnConflict{Columns: ip_address, DoUpdates: fail_count = fail_count + 1}`(SQLite ≥3.24 / MySQL 均支持),消除首建窗口的丢失更新。
|
||||
- 验证:
|
||||
- [x] `TestIncrementFailConcurrent` 在 `-race -count=5` 下稳定通过;store 包 `-race -count=1` 全量通过。
|
||||
- [ ] 存量备注(非本轮问题):`mailbox_store_test.go`/`user_store_authlogin_test.go` 等 4 个测试使用 `file::memory:?cache=shared` 共享内存库,`go test -count>1` 重复运行时数据残留撞唯一约束(UNIQUE domains.name)——测试非幂等,单轮跑法(-count=1,CI 默认)不受影响,后续可改为每轮唯一库名清理。
|
||||
|
||||
## 已确认安全、无需改动
|
||||
|
||||
- bcrypt 密码哈希;GORM 全参数化查询(无 SQL 注入)。
|
||||
@@ -179,6 +278,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. ~~#1(P0)~~ 已完成 2026-08-19
|
||||
@@ -186,6 +293,7 @@
|
||||
3. ~~#5-#11(P2)~~ 已完成 2026-08-19
|
||||
4. ~~#12-#16(P3)~~ 已完成 2026-08-19
|
||||
5. ~~#17(P4)、#18(P5,方案 A)~~ 已完成 2026-08-20
|
||||
6. 第三轮(#19-#26,2026-08-28)待实施,已按安全等级归并至上文 P1-P3 区块;实施顺序见文末「第三轮修复顺序建议」。
|
||||
|
||||
## P4 低危:第二轮审计发现(2026-08-20,8ea4a62..37b4816)
|
||||
|
||||
@@ -225,3 +333,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 预扣泄漏)~~ 已完成 2026-08-28
|
||||
4. ~~#24、#23(一行校验类)~~ 已完成 2026-08-28
|
||||
5. ~~#22(OAuth2 verified)~~ 已完成 2026-08-28;~~#25(明文认证限制)~~ 已完成 2026-08-28
|
||||
6. #26 加固清单按需排期(五项独立,逐项决策)
|
||||
7. ~~#27(存量丢失更新)~~ 已完成 2026-08-28
|
||||
Reference in New Issue
Block a user