安全审计 #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 默认单轮跑法无关)
90 lines
3.0 KiB
Go
90 lines
3.0 KiB
Go
package store
|
||
|
||
import (
|
||
"fmt"
|
||
"net"
|
||
"time"
|
||
)
|
||
|
||
// ClientIPFromAddr 从 net.Addr 提取客户端 IP 字符串(去掉端口)。
|
||
// 解析失败返回空字符串,调用方应据此跳过封禁逻辑(不误封)。
|
||
func ClientIPFromAddr(addr net.Addr) string {
|
||
if addr == nil {
|
||
return ""
|
||
}
|
||
host, _, err := net.SplitHostPort(addr.String())
|
||
if err != nil {
|
||
return 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);
|
||
// - 达到 maxFail 阈值时触发次数 BanCount+1:
|
||
// knownUser(用户名存在、疑似真实用户输错)前 freeTriggers(3)次
|
||
// 只计数不封禁(防误封);
|
||
// !knownUser(用户名不存在,枚举型爆破)跳过宽限,首次触发即按
|
||
// 第 1 档封禁;
|
||
// 封禁时长按档位递增(stageDuration),上限半年;
|
||
// - reason 为失败场景描述(如“登录失败次数过多”),封禁原因会带上档位。
|
||
//
|
||
// 返回 (是否触发封禁, 当前失败计数)。成功登录后调用 ResetFail 清零。
|
||
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
|
||
}
|
||
failCount, _ = s.Bans.IncrementFail(ip)
|
||
if failCount < maxFail {
|
||
return false, failCount
|
||
}
|
||
|
||
entry, err := s.Bans.GetByIP(ip)
|
||
if err != nil || entry == nil {
|
||
return false, failCount
|
||
}
|
||
// 已处于封禁中(例如并发请求竞态)不重复触发、不重设档位
|
||
if entry.ExpiresAt.After(time.Now()) {
|
||
return true, failCount
|
||
}
|
||
|
||
banCount := entry.BanCount + 1
|
||
entry.BanCount = banCount
|
||
entry.FailCount = failCount
|
||
|
||
// 未知用户名(枚举型爆破)跳过宽限档:等效于已用完 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 := 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
|
||
}
|
||
return true, failCount
|
||
}
|