Files
kevin f2a6203714 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
  默认单轮跑法无关)
2026-08-28 15:09:55 +08:00

90 lines
3.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(用户名存在、疑似真实用户输错)前 freeTriggers3)次
// 只计数不封禁(防误封);
// !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
}