Files
xlgo-core/validation/validator.go
T
杭州明婳科技 6595e94677 fix: 多模型评审终审收口 — P0安全 + 主线A并发治理 + 文档对齐
经 deepseek/GLM/Claude 三轮对抗性评审 + 交叉核验 + 终审(见
last_report.md、cross_review_assessment.md),逐条回读源码核实并修复。
go build / vet / test -race ./... 全绿。

安全(P0):
- JWT alg confusion:WithValidMethods 固定 HMAC + keyfunc 方法断言
- JWT 空密钥 / 不支持算法 fail-closed(ErrEmptySecret / ErrUnsupportedAlgorithm)
- 上传大小实测封顶(enforceUploadSize),不信任客户端 file.Size
- HTTP headers/cookies map 竞态(写锁+快照)+ SSRF 防护(opt-in dialer.Control)

并发/资源(主线A 闭合):
- 包级可变全局一律 atomic.Pointer:DefaultRedis / DefaultStorage /
  DefaultManager / Validator / DefaultCache 等,消除裸指针数据竞争
- trace.Close 去 sync.Once(防 exporter 泄漏);app OnReady 失败走 Shutdown
- ws.Hub.Stop stopOnce 防 double-close;cron 接入 App 生命周期(WithCron)
- ratelimit failClosed atomic.Bool;Recover Written() 守卫
- cron checkAndRun 锁内收集锁外 spawn(wg.Add 在锁内防 Stop 竞态)

终审剩余项收口(H-A~M-H + L 系列):
- 副本连接池 MaxOpenConns/2 截断修复(replicaMaxOpenConns)
- JWT 黑名单 1s 超时 + 显式错误;ratelimit GetRedis 取一次复用
- GetPage 深分页上限;HashFile 流式;ReadFile 去 TOCTOU
- config Clone() 深拷贝;cache 锁操作返 ErrRedisNotReady
- compress 解压残留清理;正则预编译;EqualsIgnoreCase→EqualFold
- 删 redisLimiters 死代码;CLI 输入校验 + 回滚

文档:
- README/GUIDE/CHANGELOG 对齐当前 API(含破坏性变更迁移说明)
- docs/README 索引修正;config 注释修正(RS256 已不支持)

破坏性变更详见 CHANGELOG.md [Unreleased]。项目处于研发初期、无下游用户。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 23:16:03 +08:00

453 lines
12 KiB
Go
Raw 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 validation
import (
"fmt"
"reflect"
"strings"
"sync/atomic"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
// Validator 全局验证器实例。
//
// H-13 修复:改用 atomic.Pointer 保护读写,消除原裸指针(InitValidator 无锁写、
// ValidateStruct 无锁读)在运行期热重载/并发校验时的数据竞争。类型由 *validator.Validate
// 变更为 atomic.Pointer[validator.Validate]breaking:下游若直接读 validation.Validator.Struct
// 需改用 ValidateStruct,或 validation.Validator.Load().Struct)。
var Validator atomic.Pointer[validator.Validate]
// ValidationError 验证错误
type ValidationError struct {
Field string `json:"field"` // 字段名(使用 label 或 json tag
Label string `json:"label"` // 字段中文名(用于显示)
Message string `json:"message"` // 错误消息
}
// ValidationErrors 验证错误列表
type ValidationErrors []ValidationError
// Error 实现 error 接口
func (ve ValidationErrors) Error() string {
var msgs []string
for _, e := range ve {
if e.Label != "" {
msgs = append(msgs, e.Label+": "+e.Message)
} else {
msgs = append(msgs, e.Field+": "+e.Message)
}
}
return strings.Join(msgs, "; ")
}
// ToMap 转换为 map
func (ve ValidationErrors) ToMap() map[string]string {
m := make(map[string]string)
for _, e := range ve {
m[e.Field] = e.Message
}
return m
}
// ToLabelMap 转换为带标签的 map
func (ve ValidationErrors) ToLabelMap() map[string]string {
m := make(map[string]string)
for _, e := range ve {
if e.Label != "" {
m[e.Label] = e.Message
} else {
m[e.Field] = e.Message
}
}
return m
}
// First 获取第一个错误
func (ve ValidationErrors) First() *ValidationError {
if len(ve) == 0 {
return nil
}
return &ve[0]
}
// FirstMessage 获取第一个错误消息
func (ve ValidationErrors) FirstMessage() string {
if len(ve) == 0 {
return ""
}
return ve[0].Message
}
// InitValidator 初始化验证器
func InitValidator() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
// 注册自定义标签名函数(优先使用 label,其次 json)
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
// 优先使用 label tag 作为字段显示名
label := fld.Tag.Get("label")
if label != "" {
return label
}
// 其次使用 json tag
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
// 注册自定义验证规则(先注册再 Store,避免并发 ValidateStruct 拿到
// "已 Store 但自定义规则未注册完"的 validator
registerCustomValidations(v)
// 全部配置完成后再发布,确保 Load 得到的 validator 永远是完整的
Validator.Store(v)
}
}
// registerCustomValidations 注册自定义验证规则。
//
// P1 #18:每个 RegisterValidation 的错误都必须检查——注册失败会导致该 tag 静默不存在、
// 所有输入被视为通过(password/idcard 等 fail-open 安全漏洞)。用 must 包装,在 init
// 期以明确信息 panic(配置类错误应启动即暴露,而非运行期静默放行)。
func registerCustomValidations(v *validator.Validate) {
must := func(tag string, fn validator.Func) {
if err := v.RegisterValidation(tag, fn); err != nil {
panic(fmt.Sprintf("validation: 注册校验规则 %q 失败: %v", tag, err))
}
}
// 密码强度验证
must("password", func(fl validator.FieldLevel) bool {
password := fl.Field().String()
valid, _ := ValidatePassword(password)
return valid
})
// 手机号验证(中国大陆)
must("phone", func(fl validator.FieldLevel) bool {
phone := fl.Field().String()
if len(phone) != 11 || !strings.HasPrefix(phone, "1") {
return false
}
// P1 #17:要求全数字——原实现仅查长度+前缀,"1abcdefghij" 这类会误通过。
for i := 0; i < len(phone); i++ {
if phone[i] < '0' || phone[i] > '9' {
return false
}
}
return true
})
// 用户名验证(字母开头,允许字母数字下划线)
must("username", func(fl validator.FieldLevel) bool {
username := fl.Field().String()
if len(username) < 3 || len(username) > 20 {
return false
}
// 首字符按 rune 取,避免非 ASCII 首字节误判(M5:原 rune(username[0]) 取字节)。
runes := []rune(username)
if len(runes) == 0 || !isLetter(runes[0]) {
return false
}
for _, r := range runes {
if !isLetter(r) && !isDigit(r) && r != '_' {
return false
}
}
return true
})
// 手机号严格验证(验证运营商号段)
must("phone_strict", func(fl validator.FieldLevel) bool {
phone := fl.Field().String()
if len(phone) != 11 {
return false
}
if !strings.HasPrefix(phone, "1") {
return false
}
// 检查号段
prefix := phone[:3]
validPrefixes := []string{
"130", "131", "132", "133", "134", "135", "136", "137", "138", "139",
"145", "146", "147", "148", "149",
"150", "151", "152", "153", "155", "156", "157", "158", "159",
"166", "167",
"170", "171", "172", "173", "174", "175", "176", "177", "178",
"180", "181", "182", "183", "184", "185", "186", "187", "188", "189",
"191", "198", "199",
}
for _, p := range validPrefixes {
if prefix == p {
return true
}
}
return false
})
// 身份证号验证(18 位带校验位;15 位仅格式,向后兼容旧号段)。
must("idcard", func(fl validator.FieldLevel) bool {
id := fl.Field().String()
if len(id) != 18 && len(id) != 15 {
return false
}
// 基本格式:前 17 位(18 位号)或全部(15 位号)须为数字,18 位号末位可为 X。
for i, c := range id {
if i == len(id)-1 && len(id) == 18 {
if !isDigit(c) && c != 'X' && c != 'x' {
return false
}
} else {
if !isDigit(c) {
return false
}
}
}
// 18 位号校验校验位(M5:原仅查长度+格式,无校验位可被任意构造通过)。
if len(id) == 18 {
if !validateIDCardChecksum(id) {
return false
}
}
return true
})
}
func isLetter(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
}
func isDigit(r rune) bool {
return r >= '0' && r <= '9'
}
// validateIDCardChecksum 校验 18 位身份证校验位(GB 11643-1999M5)。
// 前 17 位按权重 [7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2] 加权求和 mod 11
// 对照码表 [1,0,X,9,8,7,6,5,4,3,2] 得期望末位。
func validateIDCardChecksum(id string) bool {
weights := [17]int{7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2}
checkCodes := [11]byte{'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'}
sum := 0
for i := 0; i < 17; i++ {
c := id[i]
if c < '0' || c > '9' {
return false
}
sum += int(c-'0') * weights[i]
}
expected := checkCodes[sum%11]
last := id[17]
// 末位 X 大小写不敏感
if last == 'x' {
last = 'X'
}
return last == expected
}
// ValidateStruct 验证结构体
func ValidateStruct(s any) ValidationErrors {
v := Validator.Load()
if v == nil {
InitValidator()
v = Validator.Load()
}
err := v.Struct(s)
if err == nil {
return nil
}
return parseValidationErrors(err, s)
}
// parseValidationErrors 解析验证错误(支持自定义错误消息)
func parseValidationErrors(err error, s any) ValidationErrors {
var errors ValidationErrors
if validationErrors, ok := err.(validator.ValidationErrors); ok {
for _, e := range validationErrors {
fieldName := e.Field()
label := fieldName // Field() 返回的是 label 或 json tag
// 尝试获取原始字段名和自定义错误消息
if s != nil {
t := reflect.TypeOf(s)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() == reflect.Struct {
// 获取原始字段名
originalField := getOriginalFieldName(t, e.StructField())
if originalField != "" {
fieldName = originalField
}
// 获取自定义错误消息
field, found := t.FieldByName(e.StructField())
if found {
customMsg := getCustomErrorMessage(field, e.Tag())
if customMsg != "" {
errors = append(errors, ValidationError{
Field: fieldName,
Label: label,
Message: customMsg,
})
continue
}
}
}
}
errors = append(errors, ValidationError{
Field: fieldName,
Label: label,
Message: getErrorMessage(e),
})
}
}
return errors
}
// getOriginalFieldName 获取原始字段名(从 json tag)
func getOriginalFieldName(t reflect.Type, structField string) string {
field, found := t.FieldByName(structField)
if !found {
return ""
}
jsonTag := field.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
return structField
}
name := strings.SplitN(jsonTag, ",", 2)[0]
if name == "" {
return structField
}
return name
}
// getCustomErrorMessage 获取自定义错误消息
// 支持格式:
// - error:"自定义错误消息"
// - msg_required:"必填项" (针对特定验证规则)
// - msg_min:"最少5个字符"
func getCustomErrorMessage(field reflect.StructField, tag string) string {
// 优先查找特定规则的错误消息
specificTag := fmt.Sprintf("msg_%s", tag)
msg := field.Tag.Get(specificTag)
if msg != "" {
return msg
}
// 其次查找通用错误消息
msg = field.Tag.Get("error")
if msg != "" {
return msg
}
// 最后查找 msg tag
msg = field.Tag.Get("msg")
if msg != "" {
return msg
}
return ""
}
// getErrorMessage 获取默认验证错误消息
func getErrorMessage(e validator.FieldError) string {
switch e.Tag() {
case "required":
return "此字段为必填项"
case "email":
return "邮箱格式不正确"
case "min":
return fmt.Sprintf("长度不能少于 %s 个字符", e.Param())
case "max":
return fmt.Sprintf("长度不能超过 %s 个字符", e.Param())
case "len":
return fmt.Sprintf("长度必须为 %s 个字符", e.Param())
case "gte":
return fmt.Sprintf("必须大于或等于 %s", e.Param())
case "lte":
return fmt.Sprintf("必须小于或等于 %s", e.Param())
case "gt":
return fmt.Sprintf("必须大于 %s", e.Param())
case "lt":
return fmt.Sprintf("必须小于 %s", e.Param())
case "eq":
return fmt.Sprintf("必须等于 %s", e.Param())
case "ne":
return fmt.Sprintf("不能等于 %s", e.Param())
case "oneof":
return fmt.Sprintf("必须是以下值之一: %s", e.Param())
case "url":
return "URL 格式不正确"
case "uri":
return "URI 格式不正确"
case "uuid":
return "UUID 格式不正确"
case "alphanum":
return "只能包含字母和数字"
case "alpha":
return "只能包含字母"
case "numeric":
return "必须是数字"
case "password":
return "密码强度不足,需包含大小写字母和数字,至少8位"
case "phone":
return "手机号格式不正确"
case "phone_strict":
return "手机号无效,请输入正确的手机号"
case "username":
return "用户名必须以字母开头,只能包含字母、数字和下划线,长度3-20"
case "idcard":
return "身份证号格式不正确"
default:
return fmt.Sprintf("验证失败: %s", e.Tag())
}
}
// BindAndValidate 绑定并验证请求
func BindAndValidate(c *gin.Context, req any) ValidationErrors {
if err := c.ShouldBind(req); err != nil {
return parseValidationErrors(err, req)
}
return ValidateStruct(req)
}
// ShouldBindAndValidate 绑定并验证请求,返回是否成功
func ShouldBindAndValidate(c *gin.Context, req any) (ValidationErrors, bool) {
errors := BindAndValidate(c, req)
return errors, len(errors) == 0
}
// BindJSON 绑定 JSON 并验证
func BindJSON(c *gin.Context, req any) ValidationErrors {
if err := c.ShouldBindJSON(req); err != nil {
return parseValidationErrors(err, req)
}
return ValidateStruct(req)
}
// BindQuery 绑定 Query 并验证
func BindQuery(c *gin.Context, req any) ValidationErrors {
if err := c.ShouldBindQuery(req); err != nil {
return parseValidationErrors(err, req)
}
return ValidateStruct(req)
}
// BindForm 绑定 Form 并验证
func BindForm(c *gin.Context, req any) ValidationErrors {
if err := c.ShouldBind(req); err != nil {
return parseValidationErrors(err, req)
}
return ValidateStruct(req)
}