fix(security): 管理端输入校验/密码最小长度/明文认证限制,修复封禁计数丢失更新

安全审计 #23/#24/#25/#27(security_todo.md P3):

- #23 用户名/域名格式校验:用户名白名单 ^[a-zA-Z0-9._-]{1,64}$
  (杜绝含 @ 用户名导致的 GetByEmail 解析歧义),域名按标签校验
  (字母/数字/连字符、标签 ≤63、总长 ≤253,允许单标签内部域名);
  CreateUser/UpdateUser/CreateDomain 接入,新增 renderUserFormError
  共用错误渲染
- #24 密码最小长度 8 位:统一 minPasswordLength,接入自助修改、
  管理员创建、管理员重置三处,新增中/英/日 i18n 文案
- #25 POP3/IMAP 明文认证限制:配置了 TLS 且来源非回环时必须先
  STLS/STARTTLS 再认证;新增 store.IsLoopbackIP 与
  [imap]/[pop3] allow_insecure_auth 配置项(默认 false 强制)。
  顺带修复存量缺陷:POP3S 隐式 TLS 端口 tlsActive 恒为 false
  (原仅 STLS 命令置位),连接中心注册的 TLS 标记同步修正。
  部署注意:默认行为收紧,内网明文老客户端需改用 TLS 或显式
  配置 allow_insecure_auth = true
- #27 IncrementFail 并发首建窗口丢失更新:冲突 INSERT 由
  DoNothing 改为 fail_count 自增,TestIncrementFailConcurrent
  在 -race -count=5 下从偶发失败变为稳定通过
- 新增 17 项单测(校验矩阵/handler 拒绝放行路径/settings 密码/
  POP3 明文拒绝与放行/回环判定)
- security_todo.md 勾选 #23/#24/#25/#27;登记存量备注:4 个
  cache=shared 测试在 -count>1 下非幂等(UNIQUE 冲突,与 CI
  默认单轮跑法无关)
This commit is contained in:
2026-08-28 15:09:55 +08:00
parent b1c86d436d
commit f2a6203714
14 files changed
+520 -23

No files matched your search

+7
View File
@@ -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).
+6
View File
@@ -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文字以上で入力してください",
"用户名格式无效(仅限字母、数字与 . _ -)": "ユーザー名の形式が無効です(英数字と . _ - のみ使用できます)",
"域名格式无效": "ドメイン名の形式が無効です",
}
+8
View File
@@ -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
+14 -2
View File
@@ -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 == "" {
+126
View File
@@ -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"])
}
}
+8
View File
@@ -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);
+9 -3
View File
@@ -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,
+20
View File
@@ -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)
}
}
}
+75
View File
@@ -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)
}
}
+5
View File
@@ -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
+36
View File
@@ -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")
}
}
@@ -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 {
+22 -18
View File
@@ -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 位密码也接受,与系统整体安全水位不匹配。
- 修复方案:
- [ ] 统一最小长度 8NIST 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. ~~#22OAuth2 verified~~ 已完成 2026-08-28#25 待修
6. #26 按需排期
7. #27(存量丢失更新,约 3 行改动,可顺手修)
4. ~~#24、#23(一行校验类)~~ 已完成 2026-08-28
5. ~~#22OAuth2 verified~~ 已完成 2026-08-28~~#25(明文认证限制)~~ 已完成 2026-08-28
6. #26 加固清单按需排期(五项独立,逐项决策)
7. ~~#27(存量丢失更新~~ 已完成 2026-08-28