fix(security): 修复 P2 中危项(cookie/协议限速/路径遍历/默认口令/中继TLS/安全头/信息泄露)
- 会话 cookie 增加 Secure 标志;新增 [web].cookie_secure 配置 (默认 true,仅本地 HTTP 调试关闭;缺失字段按安全默认处理) - SMTP/IMAP/POP3 认证接入封禁体系(store.RecordAuthFailure 与 Web 共用 ban_entries):失败计数达 ban.max_fail_attempts 即封禁 IP, 已封禁 IP 拒绝认证,堵住协议层暴力破解 - 附件存储路径遍历防护重写:FullPath 白名单校验(UUID 文件名格式) + baseDir 前缀兜底,非法路径返回错误;Save 扩展名白名单化 - 初始管理员不再使用 admin/admin:密码取 MAILGO_ADMIN_PASSWORD 或 随机生成并打印一次;新增 MustChangePassword 首登强制改密 (管理员重置密码同样触发) - 外发中继默认验证 TLS 证书(保护 AUTH 凭据,防 MITM),直投 MX 保持机会式 TLS;新增 outbound.relay_tls_insecure 开关(默认 false) - 新增安全响应头中间件:HSTS、X-Frame-Options DENY、nosniff、 Referrer-Policy、基础 CSP(frame-ancestors 'none' 防点击劫持, connect-src/form-action 'self' 防数据外泄) - LDAP/OAuth 登录错误统一为通用文案,原始错误只写日志, 不再回显邮箱/内部细节(防用户枚举与信息泄露) - 新增 25 个回归测试:cookie 标志、封禁阈值、路径遍历用例、 中继 TLS 验证(自签证书 STARTTLS 集成)、安全头、OAuth 文案 部署注意:升级后所有会话失效需重新登录;若直接以 HTTP 提供 服务需显式配置 cookie_secure = false。
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"net"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mail_go/internal/db"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newTestStores(t *testing.T) *Stores {
|
||||
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.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return NewStores(gdb)
|
||||
}
|
||||
|
||||
// TestRecordAuthFailureBansAfterThreshold 验证连续认证失败达到阈值后封禁。
|
||||
func TestRecordAuthFailureBansAfterThreshold(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
const ip = "203.0.113.10"
|
||||
const maxFail = 3
|
||||
|
||||
// 前两次失败不封禁
|
||||
for i := 1; i < maxFail; i++ {
|
||||
banned, count := s.RecordAuthFailure(ip, maxFail, 30)
|
||||
if banned {
|
||||
t.Fatalf("attempt %d should not be banned yet", i)
|
||||
}
|
||||
if count != i {
|
||||
t.Fatalf("attempt %d: fail count = %d, want %d", i, count, i)
|
||||
}
|
||||
}
|
||||
|
||||
// 第三次失败触发封禁
|
||||
banned, count := s.RecordAuthFailure(ip, maxFail, 30)
|
||||
if !banned {
|
||||
t.Fatal("attempt reaching threshold should ban the IP")
|
||||
}
|
||||
if count != maxFail {
|
||||
t.Fatalf("fail count = %d, want %d", count, maxFail)
|
||||
}
|
||||
|
||||
// IP 现在处于封禁状态
|
||||
banned, entry := s.Bans.IsBanned(ip)
|
||||
if !banned {
|
||||
t.Fatal("IP should be banned")
|
||||
}
|
||||
if entry.ExpiresAt.Before(time.Now().Add(29 * time.Minute)) {
|
||||
t.Fatalf("ban expiry too short: %v", entry.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordAuthFailureEmptyIPSafe 空 IP 不应产生副作用。
|
||||
func TestRecordAuthFailureEmptyIPSafe(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
banned, count := s.RecordAuthFailure("", 3, 30)
|
||||
if banned || count != 0 {
|
||||
t.Fatalf("empty IP must be a no-op: banned=%v count=%d", banned, count)
|
||||
}
|
||||
if _, err := s.Bans.GetByIP(""); err == nil {
|
||||
t.Fatal("empty IP should not be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordAuthFailureWebAndProtocolShared 协议层与 Web 层共用封禁记录。
|
||||
func TestRecordAuthFailureWebAndProtocolShared(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
const ip = "198.51.100.20"
|
||||
|
||||
// Web 层已封禁(直接建记录模拟),协议层认证必须被拒绝
|
||||
s.Bans.Create(&db.BanEntry{
|
||||
IPAddress: ip,
|
||||
Reason: "web login failures",
|
||||
FailCount: 5,
|
||||
ExpiresAt: time.Now().Add(30 * time.Minute),
|
||||
})
|
||||
if banned, _ := s.Bans.IsBanned(ip); !banned {
|
||||
t.Fatal("IP should be banned for both web and protocol auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPFromAddr(t *testing.T) {
|
||||
cases := []struct {
|
||||
addr net.Addr
|
||||
want string
|
||||
}{
|
||||
{nil, ""},
|
||||
{addrMock("203.0.113.5:12345"), "203.0.113.5"},
|
||||
{addrMock("[2001:db8::1]:993"), "2001:db8::1"},
|
||||
{addrMock("bad-format"), "bad-format"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := ClientIPFromAddr(tc.addr); got != tc.want {
|
||||
t.Errorf("ClientIPFromAddr(%v) = %q, want %q", tc.addr, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addrMock 实现 net.Addr 的最小桩。
|
||||
type addrMock string
|
||||
|
||||
func (a addrMock) Network() string { return "tcp" }
|
||||
func (a addrMock) String() string { return string(a) }
|
||||
Reference in New Issue
Block a user