diff --git a/config/config.go b/config/config.go index 428cefd..d767a35 100644 --- a/config/config.go +++ b/config/config.go @@ -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). diff --git a/internal/i18n/catalog.go b/internal/i18n/catalog.go index 1d307ce..2399d8b 100644 --- a/internal/i18n/catalog.go +++ b/internal/i18n/catalog.go @@ -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文字以上で入力してください", + "用户名格式无效(仅限字母、数字与 . _ -)": "ユーザー名の形式が無効です(英数字と . _ - のみ使用できます)", + "域名格式无效": "ドメイン名の形式が無効です", } diff --git a/internal/imap_server/session.go b/internal/imap_server/session.go index 58cd548..544b13c 100644 --- a/internal/imap_server/session.go +++ b/internal/imap_server/session.go @@ -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 diff --git a/internal/pop3_server/server.go b/internal/pop3_server/server.go index a1d0ada..c665fd5 100644 --- a/internal/pop3_server/server.go +++ b/internal/pop3_server/server.go @@ -142,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() }) } @@ -159,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 { @@ -200,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 == "" { diff --git a/internal/pop3_server/server_test.go b/internal/pop3_server/server_test.go index 7a6f3dc..484d614 100644 --- a/internal/pop3_server/server_test.go +++ b/internal/pop3_server/server_test.go @@ -2,6 +2,13 @@ package pop3_server import ( "bufio" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "log" + "math/big" "net" "os" "path/filepath" @@ -13,6 +20,7 @@ import ( "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" @@ -306,3 +314,121 @@ func TestExpungeDeletedRemovesAttachmentsAndRefundsQuota(t *testing.T) { 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"]) + } +} diff --git a/internal/store/auth_guard.go b/internal/store/auth_guard.go index ad4d1e6..39a15a7 100644 --- a/internal/store/auth_guard.go +++ b/internal/store/auth_guard.go @@ -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); diff --git a/internal/store/ban_store.go b/internal/store/ban_store.go index dcf1e48..ee0800c 100644 --- a/internal/store/ban_store.go +++ b/internal/store/ban_store.go @@ -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, diff --git a/internal/store/ip_test.go b/internal/store/ip_test.go new file mode 100644 index 0000000..a848bd0 --- /dev/null +++ b/internal/store/ip_test.go @@ -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) + } + } +} diff --git a/internal/web/handlers/admin.go b/internal/web/handlers/admin.go index 679069f..8cf0c14 100644 --- a/internal/web/handlers/admin.go +++ b/internal/web/handlers/admin.go @@ -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) diff --git a/internal/web/handlers/admin_validation_test.go b/internal/web/handlers/admin_validation_test.go new file mode 100644 index 0000000..e31a144 --- /dev/null +++ b/internal/web/handlers/admin_validation_test.go @@ -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) + } +} diff --git a/internal/web/handlers/mail.go b/internal/web/handlers/mail.go index b1ea666..d572d1d 100644 --- a/internal/web/handlers/mail.go +++ b/internal/web/handlers/mail.go @@ -848,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 diff --git a/internal/web/handlers/mail_send_test.go b/internal/web/handlers/mail_send_test.go index 23ffaf7..da2b1cb 100644 --- a/internal/web/handlers/mail_send_test.go +++ b/internal/web/handlers/mail_send_test.go @@ -12,6 +12,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "mail_go/internal/db" @@ -20,6 +21,7 @@ import ( "mail_go/internal/store" "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -56,6 +58,7 @@ func newSendTestRouter(h *MailHandler) *gin.Engine { c.Next() }) r.POST("/compose", h.DoSend) + r.POST("/settings", h.UpdateSettings) return r } @@ -142,3 +145,36 @@ func TestDoSendKeepsQuotaOnSuccess(t *testing.T) { 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") + } +} diff --git a/internal/web/handlers/oauth2_state_test.go b/internal/web/handlers/oauth2_state_test.go index 4e27beb..d5e4497 100644 --- a/internal/web/handlers/oauth2_state_test.go +++ b/internal/web/handlers/oauth2_state_test.go @@ -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 { diff --git a/security_todo.md b/security_todo.md index aa1577b..aa3a075 100644 --- a/security_todo.md +++ b/security_todo.md @@ -222,32 +222,35 @@ ### 23. 管理员建用户/域名无格式校验(第三轮,2026-08-28) -- [ ] 位置:`internal/web/handlers/admin.go`(`CreateUser`:612 / `UpdateUser`:737 / `CreateDomain`:222) +- [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 邮箱视图。 - 修复方案: - - [ ] 用户名白名单 `^[a-zA-Z0-9._-]+$`、长度 ≤64;域名小写字母数字/连字符/至少一个点、长度 ≤253。 - - [ ] `UpdateUser` 改名/换域名同样校验;错误回显走 i18n。 + - [x] 用户名白名单 `^[a-zA-Z0-9._-]{1,64}$`;域名按标签校验(字母/数字/连字符、标签 ≤63、总长 ≤253)。→ 实现时未强制"至少一个点":允许 localhost 等单标签内部域名,标签格式校验已足够 + - [x] `UpdateUser` 改名/换域名同样校验;错误回显走 i18n(新增 `renderUserFormError` 共用渲染)。 - 验证: - - [ ] 单测:`a@evil`、含空格/控制字符用户名被拒;畸形域名被拒;合法输入通过。 + - [x] 单测:`a@evil`、含空格/控制字符用户名被拒;畸形域名(首尾连字符、连续点、超长标签、非 ASCII、超总长)被拒;合法输入通过。(`admin_validation_test.go` 8 项:纯函数矩阵 + handler 拒绝/放行路径) +- 已完成(2026-08-28)。 ### 24. 密码无最小长度要求(第三轮,2026-08-28) -- [ ] 位置:`internal/web/handlers/mail.go:849`(自助修改)、`internal/web/handlers/admin.go:638/771`(创建/重置) +- [x] 位置:`internal/web/handlers/mail.go:849`(自助修改)、`internal/web/handlers/admin.go:638/771`(创建/重置) - 现状:新密码仅检查非空,1 位密码也接受,与系统整体安全水位不匹配。 - 修复方案: - - [ ] 统一最小长度 8(NIST SP 800-63B:长度优先于复杂度),三处共用校验函数 + i18n 文案。 + - [x] 统一最小长度 8(`minPasswordLength`,NIST SP 800-63B:长度优先于复杂度),三处共用校验 + i18n 三语文案。 - 验证: - - [ ] 单测:7 位拒绝、8 位通过。 + - [x] 单测:过短密码拒绝且旧密码保持有效。(`TestUpdateSettingsRejectsShortPassword`;管理员创建路径由 `TestCreateUserRejectsShortPassword` 覆盖) +- 已完成(2026-08-28)。 ### 25. POP3/IMAP 允许明文认证(第三轮,2026-08-28) -- [ ] 位置:`internal/pop3_server/server.go:390`(`handlePASS`)、`internal/imap_server/session.go:212`(`Login`) +- [x] 位置:`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。 - - [ ] 加配置项控制(默认强制),不破坏极端明文内网部署。 + - [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 认证,极端明文内网部署可显式放行)。 - 验证: - - [ ] 单测:非 TLS 非回环认证被拒并提示;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,可选排期) @@ -259,12 +262,13 @@ ### 27. IncrementFail 并发首建窗口丢失更新(第三轮修复过程中发现,存量问题) -- [ ] 位置:`internal/store/ban_store.go`(`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,未被该测试触及)。 - 修复方案: - - [ ] 失败 INSERT 改为冲突时自增:`OnConflict{Columns: ip_address, DoUpdates: fail_count = fail_count + 1}`(SQLite ≥3.24 / MySQL 均支持,约 3 行改动),消除首建窗口的丢失更新。 + - [x] 失败 INSERT 改为冲突时自增:`OnConflict{Columns: ip_address, DoUpdates: fail_count = fail_count + 1}`(SQLite ≥3.24 / MySQL 均支持),消除首建窗口的丢失更新。 - 验证: - - [ ] `go test -race -count=N ./internal/store/` 多轮稳定通过。 + - [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 默认)不受影响,后续可改为每轮唯一库名清理。 ## 已确认安全、无需改动 @@ -335,7 +339,7 @@ 1. ~~**#20**(先修删除退配额,数据一致性基础)~~ 已完成 2026-08-28 2. ~~**#19**(入站配额强制,依赖 #20)~~ 已完成 2026-08-28 3. ~~**#21**(Web 预扣泄漏)~~ 已完成 2026-08-28 -4. #24、#23(一行校验类) -5. ~~#22(OAuth2 verified)~~ 已完成 2026-08-28;#25 待修 -6. #26 按需排期 -7. #27(存量丢失更新,约 3 行改动,可顺手修) +4. ~~#24、#23(一行校验类)~~ 已完成 2026-08-28 +5. ~~#22(OAuth2 verified)~~ 已完成 2026-08-28;~~#25(明文认证限制)~~ 已完成 2026-08-28 +6. #26 加固清单按需排期(五项独立,逐项决策) +7. ~~#27(存量丢失更新)~~ 已完成 2026-08-28