fix(security): 修复 P4 封禁记录数据错位 + P5 枚举爆破宽限(方案 A)
P4 #17 手动封禁 Create 非 upsert 导致数据错位: - BanStore 新增 BanIP(ip, reason, duration):事务内清理该 IP 全部 既有记录(兼容历史脏数据)后插入单条封禁记录,计数清零; DisconnectConnection 改用(原裸 Create 为全仓库唯一调用点) - BanEntry.IPAddress 升级 uniqueIndex;InitDB 在 AutoMigrate 前 dedupeBanEntries 清理历史重复行(MySQL 1093 兼容写法) - IncrementFail 原子化:SQL 侧 fail_count+1,miss 时 OnConflict DoNothing 插入兜底并发竞态,回读计数 P5 #18 方案 A(按失败性质区分宽限): - RecordAuthFailure 新增 knownUser 参数:用户名不存在(枚举型 爆破)跳过 3 次宽限、首次触发即封第 1 档;用户名存在(真实 用户输错)保留宽限防误封 - 新增 UserStore.LoginExists(邮箱/裸用户名);五个失败调用点 接线(Web 登录查存在性;SMTP/IMAP/POP3 用登录名;LDAP 侧 存在性不可判定,保守按已知用户处理) - 封禁原因注明「未知用户名,跳过宽限」便于后台审计 新增 8 个测试(-race 通过):BanIP 单行 upsert、唯一索引约束、 16 协程并发计数精确、dedupe 清理/表不存在静默、未知用户即时 封禁、已知用户宽限回归、LoginExists 矩阵。
This commit is contained in:
@@ -2,6 +2,7 @@ package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -55,6 +56,11 @@ func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.D
|
||||
return nil, fmt.Errorf("连接数据库失败: %w", err)
|
||||
}
|
||||
|
||||
// AutoMigrate 前清理 ban_entries 的历史重复行(保留每 IP 最大 id):
|
||||
// ip_address 将升级为唯一索引,重复行会使索引创建失败。
|
||||
// 首次安装表不存在时忽略错误(AutoMigrate 会建新表)。
|
||||
dedupeBanEntries(db)
|
||||
|
||||
// Auto-migrate all models
|
||||
if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}, &ProtocolLog{}, &MailboxState{}, &Mailbox{}); err != nil {
|
||||
return nil, fmt.Errorf("数据库迁移失败: %w", err)
|
||||
@@ -62,3 +68,22 @@ func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.D
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// dedupeBanEntries 删除 ban_entries 中同一 ip_address 的重复行(保留
|
||||
// 每组 id 最大的一条),为 ip_address 唯一索引的 AutoMigrate 扫清障碍。
|
||||
// 表不存在(首次安装)时静默跳过;清理失败仅告警,不阻断启动
|
||||
// (索引创建失败会在 AutoMigrate 中显式报错)。
|
||||
func dedupeBanEntries(db *gorm.DB) {
|
||||
// SQLite 与 MySQL 均支持;MySQL 不允许 DELETE 子查询直接引用同表
|
||||
// (1093),因此用派生表包一层。
|
||||
sql := "DELETE FROM ban_entries WHERE id NOT IN (" +
|
||||
"SELECT mid FROM (SELECT MAX(id) AS mid FROM ban_entries GROUP BY ip_address) AS t)"
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
// 表不存在(首次安装)为预期情况
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "no such table") || strings.Contains(msg, "doesn't exist") {
|
||||
return
|
||||
}
|
||||
log.Printf("清理 ban_entries 重复行失败(唯一索引可能无法创建): %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package db
|
||||
|
||||
// P4 #17 回归:dedupeBanEntries 在 AutoMigrate 前清理历史重复行,
|
||||
// 为 ip_address 唯一索引扫清障碍;表不存在时静默跳过。
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// createLegacyBanTable 按旧版结构建表(ip_address 无唯一索引)。
|
||||
func createLegacyBanTable(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.Exec(`CREATE TABLE ban_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip_address TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
fail_count INTEGER DEFAULT 0,
|
||||
ban_count INTEGER DEFAULT 0,
|
||||
expires_at DATETIME,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create legacy table: %v", err)
|
||||
}
|
||||
return gdb
|
||||
}
|
||||
|
||||
func TestDedupeBanEntriesRemovesDuplicates(t *testing.T) {
|
||||
gdb := createLegacyBanTable(t)
|
||||
|
||||
// 同一 IP 三条旧记录(id 1,2,3),另一 IP 一条
|
||||
for _, rec := range []struct {
|
||||
ip string
|
||||
fc int
|
||||
}{
|
||||
{"1.2.3.4", 1},
|
||||
{"1.2.3.4", 2},
|
||||
{"1.2.3.4", 3},
|
||||
{"5.6.7.8", 4},
|
||||
} {
|
||||
if err := gdb.Exec(
|
||||
"INSERT INTO ban_entries (ip_address, fail_count) VALUES (?, ?)", rec.ip, rec.fc,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
dedupeBanEntries(gdb)
|
||||
|
||||
// 每 IP 仅剩 id 最大的一条
|
||||
var rows []struct {
|
||||
IP string `gorm:"column:ip_address"`
|
||||
FC int `gorm:"column:fail_count"`
|
||||
}
|
||||
if err := gdb.Raw("SELECT ip_address, fail_count FROM ban_entries ORDER BY ip_address").Scan(&rows).Error; err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("rows after dedupe = %d, want 2", len(rows))
|
||||
}
|
||||
if rows[0].IP != "1.2.3.4" || rows[0].FC != 3 {
|
||||
t.Fatalf("1.2.3.4 row = %+v, want the max-id row (fail_count=3)", rows[0])
|
||||
}
|
||||
if rows[1].IP != "5.6.7.8" || rows[1].FC != 4 {
|
||||
t.Fatalf("5.6.7.8 row = %+v, want fail_count=4", rows[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeBanEntriesMissingTableSilent(t *testing.T) {
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
// 表不存在时不应 panic/报错(首次安装场景)
|
||||
dedupeBanEntries(gdb)
|
||||
}
|
||||
@@ -116,7 +116,9 @@ func (OutboundMessage) TableName() string {
|
||||
// BanEntry represents an IP address that has been banned due to excessive login failures.
|
||||
type BanEntry struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
IPAddress string `gorm:"size:45;index;not null" json:"ip_address"`
|
||||
// IPAddress 唯一索引:每 IP 恰好一条记录(观察计数与封禁状态共用),
|
||||
// 防止并发写入产生重复行导致计数与档位读写错位。
|
||||
IPAddress string `gorm:"size:45;uniqueIndex;not null" json:"ip_address"`
|
||||
Reason string `gorm:"size:255" json:"reason"`
|
||||
FailCount int `gorm:"default:0" json:"fail_count"`
|
||||
// BanCount 是该 IP 累计达到失败阈值的次数(含未封禁的前几次)。
|
||||
|
||||
@@ -220,7 +220,8 @@ func (s *imapSession) Login(username, password string) error {
|
||||
|
||||
user, err := s.srv.stores.Users.AuthenticateLogin(username, password)
|
||||
if err != nil {
|
||||
s.srv.stores.RecordAuthFailure(clientIP, s.srv.banCfg.MaxFailAttempts, s.srv.banCfg.BanDurationMin, "邮件协议认证失败次数过多")
|
||||
// 用户名不存在(枚举型爆破)跳过宽限首次触发即封
|
||||
s.srv.stores.RecordAuthFailure(clientIP, s.srv.banCfg.MaxFailAttempts, s.srv.banCfg.BanDurationMin, "邮件协议认证失败次数过多", s.srv.stores.Users.LoginExists(username))
|
||||
s.recordLogin(clientIP, username, false, "用户名或密码错误", "LOGIN 失败", now)
|
||||
return imapserver.ErrAuthFailed
|
||||
}
|
||||
|
||||
@@ -397,8 +397,9 @@ func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) (
|
||||
|
||||
authUser, err := s.stores.Users.AuthenticateLogin(user.Username, password)
|
||||
if err != nil {
|
||||
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries)
|
||||
s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin, "邮件协议认证失败次数过多")
|
||||
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries)。
|
||||
// 用户名不存在(枚举型爆破)跳过宽限首次触发即封。
|
||||
s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin, "邮件协议认证失败次数过多", s.stores.Users.LoginExists(user.Username))
|
||||
sendResponse(conn, "-ERR authentication failed")
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
@@ -221,12 +221,14 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
|
||||
|
||||
user, err := s.backend.server.stores.Users.AuthenticateLogin(username, password)
|
||||
if err != nil {
|
||||
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries)
|
||||
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries)。
|
||||
// 用户名不存在(枚举型爆破)跳过宽限首次触发即封。
|
||||
s.backend.server.stores.RecordAuthFailure(
|
||||
s.clientIP,
|
||||
s.backend.server.banCfg.MaxFailAttempts,
|
||||
s.backend.server.banCfg.BanDurationMin,
|
||||
"邮件协议认证失败次数过多",
|
||||
s.backend.server.stores.Users.LoginExists(username),
|
||||
)
|
||||
s.recordFail("用户名或密码错误")
|
||||
return smtp.ErrAuthFailed
|
||||
|
||||
@@ -23,12 +23,15 @@ func ClientIPFromAddr(addr net.Addr) string {
|
||||
// 协议层统一入口):
|
||||
// - 失败计数累加(每 IP 一条记录,upsert);
|
||||
// - 达到 maxFail 阈值时触发次数 BanCount+1:
|
||||
// 前 freeTriggers(3)次只计数不封禁;
|
||||
// 从第 4 次起封禁,时长按档位递增(stageDuration),上限半年;
|
||||
// knownUser(用户名存在、疑似真实用户输错)前 freeTriggers(3)次
|
||||
// 只计数不封禁(防误封);
|
||||
// !knownUser(用户名不存在,枚举型爆破)跳过宽限,首次触发即按
|
||||
// 第 1 档封禁;
|
||||
// 封禁时长按档位递增(stageDuration),上限半年;
|
||||
// - reason 为失败场景描述(如“登录失败次数过多”),封禁原因会带上档位。
|
||||
//
|
||||
// 返回 (是否触发封禁, 当前失败计数)。成功登录后调用 ResetFail 清零。
|
||||
func (s *Stores) RecordAuthFailure(ip string, maxFail int, firstBanMin int, reason string) (banned bool, failCount int) {
|
||||
func (s *Stores) RecordAuthFailure(ip string, maxFail int, firstBanMin int, reason string, knownUser bool) (banned bool, failCount int) {
|
||||
if ip == "" || maxFail <= 0 {
|
||||
return false, 0
|
||||
}
|
||||
@@ -50,16 +53,26 @@ func (s *Stores) RecordAuthFailure(ip string, maxFail int, firstBanMin int, reas
|
||||
entry.BanCount = banCount
|
||||
entry.FailCount = failCount
|
||||
|
||||
// 前 3 次只计数,不封禁(保留零到期时间与空原因)
|
||||
if banCount <= freeTriggers {
|
||||
// 未知用户名(枚举型爆破)跳过宽限档:等效于已用完 3 次宽限
|
||||
effectiveCount := banCount
|
||||
if !knownUser {
|
||||
effectiveCount += freeTriggers
|
||||
}
|
||||
|
||||
// 宽限期内只计数,不封禁(保留零到期时间与空原因)
|
||||
if effectiveCount <= freeTriggers {
|
||||
if err := s.Bans.Update(entry); err != nil {
|
||||
return false, failCount
|
||||
}
|
||||
return false, failCount
|
||||
}
|
||||
|
||||
banNum := banCount - freeTriggers
|
||||
banNum := effectiveCount - freeTriggers
|
||||
if knownUser {
|
||||
entry.Reason = fmt.Sprintf("第%d次封禁:%s(第%d次触发,失败%d次)", banNum, reason, banCount, failCount)
|
||||
} else {
|
||||
entry.Reason = fmt.Sprintf("第%d次封禁:%s(未知用户名,跳过宽限;第%d次触发,失败%d次)", banNum, reason, banCount, failCount)
|
||||
}
|
||||
entry.ExpiresAt = time.Now().Add(stageDuration(banNum, firstBanMin))
|
||||
if err := s.Bans.Update(entry); err != nil {
|
||||
return false, failCount
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -33,7 +34,7 @@ func TestRecordAuthFailureFreeTriggers(t *testing.T) {
|
||||
const maxFail = 2
|
||||
|
||||
failOnce := func() bool {
|
||||
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多")
|
||||
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", true)
|
||||
return banned
|
||||
}
|
||||
|
||||
@@ -94,7 +95,7 @@ func TestStagedBanEscalation(t *testing.T) {
|
||||
const maxFail = 2
|
||||
|
||||
failOnce := func() bool {
|
||||
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多")
|
||||
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", true)
|
||||
return banned
|
||||
}
|
||||
|
||||
@@ -228,7 +229,7 @@ func TestBanListOnlyBannedOrExpired(t *testing.T) {
|
||||
// TestRecordAuthFailureEmptyIPSafe 空 IP 不应产生副作用。
|
||||
func TestRecordAuthFailureEmptyIPSafe(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
banned, count := s.RecordAuthFailure("", 3, 30, "登录失败次数过多")
|
||||
banned, count := s.RecordAuthFailure("", 3, 30, "登录失败次数过多", true)
|
||||
if banned || count != 0 {
|
||||
t.Fatalf("empty IP must be a no-op: banned=%v count=%d", banned, count)
|
||||
}
|
||||
@@ -350,3 +351,200 @@ func TestTryReserveQuotaNonPositiveDelta(t *testing.T) {
|
||||
t.Fatalf("used_bytes = %d, want 0", got.UsedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// P5 #18 方案 A:未知用户名(枚举型爆破)跳过宽限,首次触发即封。
|
||||
func TestRecordAuthFailureUnknownUserSkipsGrace(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
const ip = "203.0.113.77"
|
||||
const maxFail = 3
|
||||
|
||||
// 未知用户名:第 1 次触发(累计失败 3 次)即封第 1 档(30 分钟)
|
||||
for i := 1; i <= maxFail; i++ {
|
||||
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", false)
|
||||
if i < maxFail && banned {
|
||||
t.Fatalf("attempt %d should not ban before threshold", i)
|
||||
}
|
||||
if i == maxFail && !banned {
|
||||
t.Fatal("unknown user: first trigger must ban immediately")
|
||||
}
|
||||
}
|
||||
banned, entry := s.Bans.IsBanned(ip)
|
||||
if !banned {
|
||||
t.Fatal("IP should be banned")
|
||||
}
|
||||
// 第 1 档 = 30 分钟
|
||||
if entry.ExpiresAt.Before(time.Now().Add(29 * time.Minute)) {
|
||||
t.Fatalf("first-stage ban duration wrong: expires %v", entry.ExpiresAt)
|
||||
}
|
||||
if !strings.Contains(entry.Reason, "未知用户名") {
|
||||
t.Fatalf("reason should note unknown-user skip: %q", entry.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// P5 #18 方案 A:已知用户名(真实用户输错)保留前 3 次宽限(回归)。
|
||||
// 触发语义与 TestRecordAuthFailureFreeTriggers 一致:达到阈值后每次失败
|
||||
// 都会触发一次,前 3 次触发(第 2-4 次失败)不封禁,第 4 次触发
|
||||
// (第 5 次失败)封第 1 档。
|
||||
func TestRecordAuthFailureKnownUserKeepsGrace(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
const ip = "198.51.100.88"
|
||||
const maxFail = 2
|
||||
|
||||
// 第 1 次失败:计数,未达阈值
|
||||
if banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", true); banned {
|
||||
t.Fatal("failure 1 must not ban")
|
||||
}
|
||||
// 第 2-4 次失败 = 触发 1-3,宽限期内不封禁
|
||||
for i := 2; i <= 4; i++ {
|
||||
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", true)
|
||||
if banned {
|
||||
t.Fatalf("failure %d (trigger within grace) should not ban", i)
|
||||
}
|
||||
}
|
||||
if banned, _ := s.Bans.IsBanned(ip); banned {
|
||||
t.Fatal("known user must not be banned within 3 free triggers")
|
||||
}
|
||||
entry, _ := s.Bans.GetByIP(ip)
|
||||
if entry.BanCount != 3 {
|
||||
t.Fatalf("ban_count = %d, want 3", entry.BanCount)
|
||||
}
|
||||
|
||||
// 第 5 次失败 = 触发 4 -> 第 1 档封禁
|
||||
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", true)
|
||||
if !banned {
|
||||
t.Fatal("4th trigger should ban (stage 1)")
|
||||
}
|
||||
entry, _ = s.Bans.GetByIP(ip)
|
||||
if entry.BanCount != 4 {
|
||||
t.Fatalf("ban count = %d, want 4", entry.BanCount)
|
||||
}
|
||||
if !strings.Contains(entry.Reason, "第1次封禁") {
|
||||
t.Fatalf("reason = %q, want 第1次封禁", entry.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// LoginExists:完整邮箱与裸用户名两种形态。
|
||||
func TestLoginExists(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
domain := &db.Domain{Name: "example.com"}
|
||||
if err := s.Domains.Create(domain); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Users.Create(&db.User{Username: "alice", PasswordHash: "x", DomainID: domain.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
login string
|
||||
want bool
|
||||
}{
|
||||
{"alice@example.com", true},
|
||||
{"alice", true},
|
||||
{"bob@example.com", false},
|
||||
{"bob", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := s.Users.LoginExists(tc.login); got != tc.want {
|
||||
t.Errorf("LoginExists(%q) = %v, want %v", tc.login, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// P4 #17:BanIP 为 upsert 语义,已有观察记录的 IP 手动封禁后仅一条记录。
|
||||
func TestBanIPUpsertSingleRow(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
const ip = "203.0.113.99"
|
||||
|
||||
// 先产生观察计数记录(未封禁)
|
||||
for i := 0; i < 2; i++ {
|
||||
_, _ = s.RecordAuthFailure(ip, 10, 30, "登录失败次数过多", true)
|
||||
}
|
||||
if banned, _ := s.Bans.IsBanned(ip); banned {
|
||||
t.Fatal("should be observation-only at this point")
|
||||
}
|
||||
|
||||
// 手动封禁 180 天
|
||||
if err := s.Bans.BanIP(ip, "管理员手动封禁", 180*24*time.Hour); err != nil {
|
||||
t.Fatalf("BanIP: %v", err)
|
||||
}
|
||||
|
||||
// 仅一条记录,且处于封禁状态、计数清零
|
||||
entry, err := s.Bans.GetByIP(ip)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByIP: %v", err)
|
||||
}
|
||||
if entry.Reason != "管理员手动封禁" {
|
||||
t.Fatalf("reason = %q", entry.Reason)
|
||||
}
|
||||
if entry.FailCount != 0 || entry.BanCount != 0 {
|
||||
t.Fatalf("manual ban should reset counters, got fail=%d ban=%d", entry.FailCount, entry.BanCount)
|
||||
}
|
||||
if !entry.ExpiresAt.After(time.Now().Add(179 * 24 * time.Hour)) {
|
||||
t.Fatalf("manual ban duration wrong: %v", entry.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// P4 #17:ip_address 唯一索引生效——同一 IP 不允许第二条记录
|
||||
// (历史重复行由 InitDB 的 dedupeBanEntries 在 AutoMigrate 前清理,
|
||||
// BanIP 的事务内“先删后插”兼容既有脏数据)。
|
||||
func TestBanEntryUniqueIndex(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
const ip = "198.51.100.3"
|
||||
|
||||
if err := s.Bans.Create(&db.BanEntry{IPAddress: ip, Reason: "first", ExpiresAt: time.Time{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 第二条同 IP 记录必须被唯一约束拒绝
|
||||
if err := s.Bans.Create(&db.BanEntry{IPAddress: ip, Reason: "second", ExpiresAt: time.Time{}}); err == nil {
|
||||
t.Fatal("duplicate ban entry for same IP should be rejected by unique index")
|
||||
}
|
||||
|
||||
// 唯一记录上的 BanIP/IncrementFail 读写一致(无错位)
|
||||
if err := s.Bans.BanIP(ip, "管理员手动封禁", time.Hour); err != nil {
|
||||
t.Fatalf("BanIP: %v", err)
|
||||
}
|
||||
entry, err := s.Bans.GetByIP(ip)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entry.Reason != "管理员手动封禁" || entry.FailCount != 0 {
|
||||
t.Fatalf("unexpected entry: %+v", entry)
|
||||
}
|
||||
cnt, err := s.Bans.IncrementFail(ip)
|
||||
if err != nil || cnt != 1 {
|
||||
t.Fatalf("IncrementFail after BanIP = %d, %v; want 1, nil", cnt, err)
|
||||
}
|
||||
}
|
||||
|
||||
// P4 #17:IncrementFail 并发安全(-race 下不产生重复行、计数准确)。
|
||||
func TestIncrementFailConcurrent(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
const ip = "203.0.113.100"
|
||||
const goroutines = 16
|
||||
const perG = 5
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < perG; i++ {
|
||||
if _, err := s.Bans.IncrementFail(ip); err != nil {
|
||||
t.Errorf("IncrementFail: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
entry, err := s.Bans.GetByIP(ip)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByIP: %v", err)
|
||||
}
|
||||
want := goroutines * perG
|
||||
if entry.FailCount != want {
|
||||
t.Fatalf("fail count = %d, want %d (lost updates or duplicate rows)", entry.FailCount, want)
|
||||
}
|
||||
}
|
||||
+67
-18
@@ -1,11 +1,14 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mail_go/internal/db"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// 阶段封禁档位(分钟/天),从第 4 次触发阈值开始封禁:
|
||||
@@ -41,6 +44,10 @@ func stageDuration(banCount int, firstBanMin int) time.Duration {
|
||||
// BanStore defines the interface for IP ban operations.
|
||||
type BanStore interface {
|
||||
Create(entry *db.BanEntry) error
|
||||
// BanIP 手动/直接封禁某 IP 指定时长:该 IP 只保留一条记录
|
||||
// (既有观察记录一并清理,计数清零),避免产生重复行导致
|
||||
// IncrementFail 与 GetByIP 读写错位。
|
||||
BanIP(ip, reason string, duration time.Duration) error
|
||||
GetByIP(ip string) (*db.BanEntry, error)
|
||||
Update(entry *db.BanEntry) error
|
||||
Delete(id uint) error
|
||||
@@ -67,6 +74,30 @@ func (s *banStoreGorm) Create(entry *db.BanEntry) error {
|
||||
return s.db.Create(entry).Error
|
||||
}
|
||||
|
||||
// BanIP 手动/直接封禁:事务内删除该 IP 的全部既有记录(含历史 bug
|
||||
// 产生的重复行与观察计数记录)后插入一条封禁记录,计数清零。
|
||||
// 与"管理员解封清零"语义一致:手动封禁视为对档位的重新评估。
|
||||
func (s *banStoreGorm) BanIP(ip, reason string, duration time.Duration) error {
|
||||
if ip == "" {
|
||||
return fmt.Errorf("empty ip")
|
||||
}
|
||||
if duration <= 0 {
|
||||
return fmt.Errorf("invalid ban duration: %v", duration)
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("ip_address = ?", ip).Delete(&db.BanEntry{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&db.BanEntry{
|
||||
IPAddress: ip,
|
||||
Reason: reason,
|
||||
FailCount: 0,
|
||||
BanCount: 0,
|
||||
ExpiresAt: time.Now().Add(duration),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// GetByIP retrieves the most recent ban entry for a given IP address.
|
||||
func (s *banStoreGorm) GetByIP(ip string) (*db.BanEntry, error) {
|
||||
var entry db.BanEntry
|
||||
@@ -118,33 +149,51 @@ func (s *banStoreGorm) IsBanned(ip string) (bool, *db.BanEntry) {
|
||||
return true, &entry
|
||||
}
|
||||
|
||||
// IncrementFail increments the fail count for an IP address.
|
||||
// If no record exists, it creates one with fail_count=1, ban_count=0 and a
|
||||
// zero expires_at (not yet banned). Existing BanCount is preserved.
|
||||
// Returns the updated fail count.
|
||||
// IncrementFail increments the fail count for an IP address atomically
|
||||
// (SQL-side increment, avoiding read-modify-write races). If no record
|
||||
// exists it creates one with fail_count=1, ban_count=0 and a zero
|
||||
// expires_at (not yet banned); a concurrent creator wins and the loser's
|
||||
// insert becomes a no-op via the unique index. Existing BanCount is
|
||||
// preserved. Returns the current fail count.
|
||||
func (s *banStoreGorm) IncrementFail(ip string) (int, error) {
|
||||
var entry db.BanEntry
|
||||
err := s.db.Where("ip_address = ?", ip).First(&entry).Error
|
||||
if err != nil {
|
||||
// No record exists, create a new one
|
||||
entry = db.BanEntry{
|
||||
res := s.db.Model(&db.BanEntry{}).
|
||||
Where("ip_address = ?", ip).
|
||||
Update("fail_count", gorm.Expr("fail_count + 1"))
|
||||
if res.Error != nil {
|
||||
return 0, res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// 无记录:插入首条;ip_address 唯一索引下并发插入用
|
||||
// OnConflict DoNothing 兜底,失败方继续走下面的回读。
|
||||
err := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&db.BanEntry{
|
||||
IPAddress: ip,
|
||||
FailCount: 1,
|
||||
BanCount: 0,
|
||||
ExpiresAt: time.Time{}, // Zero time, not yet banned
|
||||
}).Error
|
||||
if err != nil && !isUniqueConflictErr(err) {
|
||||
return 0, err
|
||||
}
|
||||
if createErr := s.db.Create(&entry).Error; createErr != nil {
|
||||
return 0, createErr
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// Record exists, increment fail count
|
||||
newCount := entry.FailCount + 1
|
||||
if updateErr := s.db.Model(&entry).Update("fail_count", newCount).Error; updateErr != nil {
|
||||
return 0, updateErr
|
||||
// 回读计数(并发下取数据库最终值)
|
||||
var count int64
|
||||
if err := s.db.Model(&db.BanEntry{}).Where("ip_address = ?", ip).
|
||||
Select("fail_count").Scan(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return newCount, nil
|
||||
return int(count), nil
|
||||
}
|
||||
|
||||
// isUniqueConflictErr 判断是否为唯一约束冲突(并发插入竞态的预期结果)。
|
||||
func isUniqueConflictErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "UNIQUE constraint") || // SQLite
|
||||
strings.Contains(msg, "Duplicate entry") || // MySQL
|
||||
strings.Contains(msg, "unique constraint") // generic
|
||||
}
|
||||
|
||||
// ResetFail resets the fail count for an IP address by deleting its record.
|
||||
|
||||
@@ -20,6 +20,10 @@ type UserStore interface {
|
||||
// 但支持裸用户名(如 "kevin"),自动解析到其唯一所属域名;多域名下
|
||||
// 用户名存在歧义时要求完整邮箱。兼容手机/客户端只填用户名的配置。
|
||||
AuthenticateLogin(login, password string) (*db.User, error)
|
||||
// LoginExists 判断登录名(完整邮箱或裸用户名)是否对应系统中的用户,
|
||||
// 供封禁逻辑区分“真实用户输错密码”(保留宽限)与“枚举型爆破”
|
||||
// (跳过宽限,见 RecordAuthFailure 的 knownUser 参数)。
|
||||
LoginExists(login string) bool
|
||||
Update(user *db.User) error
|
||||
Delete(id uint) error
|
||||
List(domainID uint, page, size int) ([]db.User, int64, error)
|
||||
@@ -127,6 +131,25 @@ func (s *userStoreGorm) AuthenticateLogin(login, password string) (*db.User, err
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// LoginExists 判断登录名是否对应系统中的用户:完整邮箱按邮箱查,
|
||||
// 裸用户名按用户名全局查(存在即算,歧义不影响存在性判定)。
|
||||
// 仅用于封禁分级(knownUser),不做认证。
|
||||
func (s *userStoreGorm) LoginExists(login string) bool {
|
||||
login = strings.TrimSpace(login)
|
||||
if login == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(login, "@") {
|
||||
_, err := s.GetByEmail(login)
|
||||
return err == nil
|
||||
}
|
||||
var count int64
|
||||
if err := s.db.Model(&db.User{}).Where("username = ?", login).Count(&count).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// Update saves changes to an existing user record.
|
||||
func (s *userStoreGorm) Update(user *db.User) error {
|
||||
return s.db.Save(user).Error
|
||||
|
||||
@@ -79,14 +79,10 @@ func (h *AdminHandler) DisconnectConnection(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 加入黑名单:180 天封禁(管理员可随时解封)
|
||||
if err := h.stores.Bans.Create(&db.BanEntry{
|
||||
IPAddress: conn.IP,
|
||||
Reason: "管理员手动封禁(连接断开)",
|
||||
FailCount: 0,
|
||||
BanCount: 0,
|
||||
ExpiresAt: time.Now().Add(manualBanDuration),
|
||||
}); err != nil {
|
||||
// 加入黑名单:180 天封禁(管理员可随时解封)。
|
||||
// BanIP 为 upsert 语义:清理该 IP 既有观察/重复记录后仅保留一条,
|
||||
// 避免与阶段性封禁的计数/档位记录错位。
|
||||
if err := h.stores.Bans.BanIP(conn.IP, "管理员手动封禁(连接断开)", manualBanDuration); err != nil {
|
||||
c.String(http.StatusInternalServerError, "封禁失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -73,7 +73,10 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
|
||||
|
||||
user, err := h.stores.Users.Authenticate(email, password)
|
||||
if err != nil {
|
||||
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "登录失败次数过多")
|
||||
// 区分失败性质:用户名存在(真实用户输错,保留宽限)vs
|
||||
// 用户名不存在(枚举型爆破,跳过宽限首次触发即封)
|
||||
knownUser := h.stores.Users.LoginExists(email)
|
||||
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "登录失败次数过多", knownUser)
|
||||
if banned {
|
||||
entry, _ := h.stores.Bans.GetByIP(ip)
|
||||
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
|
||||
@@ -146,7 +149,8 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
|
||||
if err != nil {
|
||||
log.Printf("LDAP 认证失败: %v", err)
|
||||
|
||||
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "LDAP 登录失败次数过多")
|
||||
// LDAP 侧用户存在性无法判定,保守按已知用户处理(保留宽限防误封)
|
||||
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "LDAP 登录失败次数过多", true)
|
||||
if banned {
|
||||
entry, _ := h.stores.Bans.GetByIP(ip)
|
||||
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
|
||||
|
||||
+39
-3
@@ -185,7 +185,43 @@
|
||||
2. ~~#2、#3、#4(P1)~~ 已完成 2026-08-19
|
||||
3. ~~#5-#11(P2)~~ 已完成 2026-08-19
|
||||
4. ~~#12-#16(P3)~~ 已完成 2026-08-19
|
||||
5. ~~#17(P4)、#18(P5,方案 A)~~ 已完成 2026-08-20
|
||||
|
||||
**全部安全审计项已修复完成。** 剩余建议(非代码项):
|
||||
- 部署侧:Caddy 加固(可选,应用层已加安全头)、8080 端口保持仅本机可达、GitHub 仓库中 3 个 50MB+ 的 exe 文件建议改用 LFS 或删除
|
||||
- 线上验证:部署新版后检查登录/收件箱/管理页、协议认证封禁、邮件远程图片加载(CSP 影响)
|
||||
## P4 低危:第二轮审计发现(2026-08-20,8ea4a62..37b4816)
|
||||
|
||||
### 17. 手动封禁 Create 非 upsert,与阶段性封禁体系数据错位
|
||||
|
||||
- [x] 位置:`internal/web/handlers/admin.go`(`DisconnectConnection`)、`internal/store/ban_store.go`、`internal/db/models.go`、`internal/db/db.go`
|
||||
- 现状:`f2493da` 阶段性封禁已改为"每 IP 一条记录 upsert"(`RecordAuthFailure` 内部 GetByIP + Update),但管理员"断开并封禁"仍直接 `Create`。`ip_address` 无唯一索引,当目标 IP 已有失败计数记录时会插入**第二条**记录,造成:
|
||||
- `IncrementFail` 用 `First`(默认主键升序)更新**旧行**,`GetByIP` 用 `Order("id DESC")` 返回**新行** -> 自动封禁的档位判定(BanCount)与失败计数(FailCount)读写错行;
|
||||
- `UnbanIP` 按 ID 删除一行后另一行仍在,可能出现"解封后仍被旧记录挡住/计数异常"。
|
||||
- 修复方案:
|
||||
- [x] BanStore 新增 `BanIP(ip, reason, duration)`:事务内删除该 IP 全部既有记录(兼容历史脏数据)后插入单条封禁记录,计数清零(与"管理员解封清零"语义一致);`DisconnectConnection` 改用该方法。
|
||||
- [x] `BanEntry.IPAddress` 升级为 `uniqueIndex`;`InitDB` 在 AutoMigrate 前调用 `dedupeBanEntries` 清理历史重复行(保留每 IP 最大 id,SQLite/MySQL 兼容的派生表写法),表不存在时静默。
|
||||
- [x] `IncrementFail` 原子化:SQL 侧 `fail_count + 1`,miss 时 `OnConflict DoNothing` 插入兜底并发竞态,回读计数。
|
||||
- 验证:
|
||||
- [x] 单测:已有观察记录的 IP 手动封禁后仅一条、计数清零、封禁生效(`TestBanIPUpsertSingleRow`)。
|
||||
- [x] 单测:同 IP 第二条记录被唯一约束拒绝(`TestBanEntryUniqueIndex`)。
|
||||
- [x] 并发单测(`-race`):16 协程并发 IncrementFail 计数精确无重复行(`TestIncrementFailConcurrent`)。
|
||||
- [x] db 包单测:旧表重复行清理保留最大 id、表不存在静默(`dedupe_test.go`)。
|
||||
|
||||
## P5 备注:产品权衡项(需决策后实施)
|
||||
|
||||
### 18. 阶段性封禁"前 3 次触发不封禁"降低爆破门槛
|
||||
|
||||
- [x] 位置:`internal/store/auth_guard.go`(`RecordAuthFailure`)、`internal/store/user_store.go`(`LoginExists`)、Web/LDAP/SMTP/IMAP/POP3 五处调用点
|
||||
- 现状:为防误封手机客户端(配置向导探测、裸用户名重试等),达到失败阈值记为一次触发,前 3 次**只计数不封禁**。副作用:攻击者每次触发前可"免费"尝试 `max_fail_attempts`(默认 5)次,即约 **15 次失败尝试零封禁**;第 4 次起才进入 30min -> 3h -> 3 个月 -> 半年的递增档位。长期防护足够,但自动化爆破的起步门槛降低。
|
||||
- 已实施(方案 A,2026-08-20):
|
||||
- [x] `RecordAuthFailure` 新增 `knownUser bool` 参数:用户名存在(真实用户输错)保留前 3 次宽限;用户名不存在(枚举型爆破)跳过宽限、首次触发即按第 1 档封禁,封禁原因注明"未知用户名,跳过宽限"。
|
||||
- [x] 新增 `UserStore.LoginExists(login)`(完整邮箱或裸用户名),五个失败调用点按场景传入:Web 登录查邮箱存在性;SMTP/IMAP/POP3 用登录名查;LDAP 侧存在性无法判定,保守按已知用户处理(防误封)。
|
||||
- 验证:
|
||||
- [x] A:未知用户名第 1 次触发即封(30 分钟,reason 含"未知用户名")(`TestRecordAuthFailureUnknownUserSkipsGrace`)。
|
||||
- [x] 已知用户名前 3 次触发不封、第 4 次封第 1 档(回归)(`TestRecordAuthFailureKnownUserKeepsGrace`)。
|
||||
- [x] `LoginExists` 邮箱/裸用户名/不存在/空输入矩阵(`TestLoginExists`)。
|
||||
- 决策记录:**方案 A**(按失败性质区分宽限:真实用户防误封,枚举爆破即时封禁)——在不改变正常用户体验的前提下,让针对不存在账号的字典爆破首次达到阈值即被封,兼顾误封防护与爆破门槛。
|
||||
|
||||
## 部署侧建议(非代码项)
|
||||
|
||||
- Caddy 加固(可选,应用层已加安全头)、8080 端口保持仅本机可达。
|
||||
- GitHub 仓库中 3 个 50MB+ 的 exe 文件(mailgo.exe / mail_go.exe / mailgo_qa.exe)建议改用 Git LFS 或从历史中删除。
|
||||
- 线上验证:部署新版后检查登录/收件箱/管理页、协议认证封禁、邮件远程图片加载(CSP 影响)。
|
||||
Reference in New Issue
Block a user