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:
14 files changed
+520
-23
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).
|
||||
|
||||
@@ -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文字以上で入力してください",
|
||||
"用户名格式无效(仅限字母、数字与 . _ -)": "ユーザー名の形式が無効です(英数字と . _ - のみ使用できます)",
|
||||
"域名格式无效": "ドメイン名の形式が無効です",
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user