审计范围:
/internal下全部非测试、非_gen.go生产代码(logic、loaders、model 的 custom 层、middleware、handler、server、svc、consts、response、util)+ 入口perm.go+ 接口定义perm.api。 审计时间:2026-04-19 审计重点:
- 并发场景下"最后一个 ADMIN"保护的真实可打破性(跨行 TOCTOU、事务内外数据脱钩)
- 状态变更接口的"无变化也强制递增 tokenVersion"导致的不必要踢出
- 级联删除的 TOCTOU(父部门删除 vs 子部门/用户插入)
- 高频写接口的限流盲区(changePassword 等)
- 负缓存缺失 / 缓存索引集合与数据 key 的非原子 SADD/SetEx 导致的漏清理
- 依赖
strings.Contains(err, "1062")的脆弱错误分类- 僵尸 scaffolded 中间件
相对上一轮:本轮新发现一批未被上一轮覆盖的 P0/P1 问题,都涉及生产环境真实可触发的业务逻辑/数据完整性破坏路径。
UpdateMember 的"最后一个 ADMIN"保护只看 memberType 变化,不看 status 变化 → 可把最后一个 ADMIN 直接禁用(产品瞬间"无人管理")internal/logic/member/updateMemberLogic.go:51,54-59,66-73member.MemberType = req.MemberType if req.Status != 0 {
if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
return response.ErrBadRequest("状态值无效...")
}
member.Status = req.Status
} ... if needAdminCheck {
adminCount, _ := ...CountActiveAdminsTx(...)
if adminCount <= 1 {
return response.ErrBadRequest("不能降级该产品的最后一个管理员")
}
}
`needAdminCheck` 只在 **memberType 从 ADMIN 改成非 ADMIN** 时为 `true`。如果请求保持 `memberType=ADMIN` 但 **`status` 改为 `StatusDisabled`**,`needAdminCheck=false`,直接跳过计数检查。`CountActiveAdminsTx` 只计 `status=1` 的 ADMIN,所以"禁用最后一个 ADMIN"是在绕过该保护之下合法完成的。
攻击/误操作路径(任何持有该产品 ADMIN 令牌的账号都可做):
```http
POST /api/member/update
{ "id": <最后一个 ADMIN 的 member 记录 id>,
"memberType": "ADMIN",
"status": 2 }
执行结果:DB 里这条 ADMIN 的 status=2,CountActiveAdmins==0。此后:
RequireProductAdminFor 的 Admin 校验(除了超管);该产品上的 ADMIN 自助救援路径不存在,需要联系平台管理员。
影响:
修复方案:
把 needAdminCheck 扩展为"任何会让这条 ADMIN 记录变为非活跃的写入":
// internal/logic/member/updateMemberLogic.go
nextType := req.MemberType
nextStatus := member.Status
if req.Status != 0 {
nextStatus = req.Status
}
willBecomeInactiveAdmin :=
member.MemberType == consts.MemberTypeAdmin &&
member.Status == consts.StatusEnabled &&
(nextType != consts.MemberTypeAdmin || nextStatus != consts.StatusEnabled)
并把后续赋值/校验改成基于 nextType/nextStatus。同时建议把 needAdminCheck/"最后一个 ADMIN" 同时在 RemoveMember 和 UpdateMember 里抽成一个 helper guardLastAdminTx(session, productCode, excludingId),统一用 行锁 + FOR UPDATE 遍历 方式做。参考 H-3 的修复方案。
RemoveMember 在"是不是 ADMIN"判断上使用了事务外读到的 member.MemberType,而把事务内 FindOneForUpdateTx 的返回值扔掉 → 并发下最后一个 ADMIN 会被删掉internal/logic/member/removeMemberLogic.go:32,42-53描述:
member, err := l.svcCtx.SysProductMemberModel.FindOne(l.ctx, req.Id) // 事务外
...
if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
if _, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id); err != nil { // <-- 返回值被丢弃
return response.ErrNotFound("成员不存在")
}
if member.MemberType == consts.MemberTypeAdmin { // <-- 用的是事务外的 member
adminCount, err := ...CountActiveAdminsTx(...)
...
}
...
})
FindOneForUpdateTx 仅用来加行锁,但返回的最新值直接丢掉,后续判断"这条是不是 ADMIN"仍然走事务外的 member。
攻击/误操作路径:
productA 有 ADMIN = [A1, A2];M 是 MEMBER。T1:调用 RemoveMember(member.Id = M):member.MemberType = MEMBER。T2 几乎同时:UpdateMember 把 M 提升成 ADMIN(事务提交)。T1:进入事务,FindOneForUpdateTx(M) 返回 ADMIN(被丢弃)。T1:if member.MemberType == ADMIN 用的是旧 MEMBER → 跳过 count 检查。T1:删除 M。M 提到 ADMIN 补位(运营流程),T1 就在恢复期内删掉了新晋 ADMIN;极端下可导致 0 active admin。即使不考虑跨线程竞态,这也违背了"同一事务内一次读到一次写之间必须用事务内的视图来决策"的原则。
影响:与 H-1 同类,破坏 "至少一个 ADMIN" 不变式。
修复方案:
if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
locked, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, req.Id)
if err != nil {
return response.ErrNotFound("成员不存在")
}
if locked.MemberType == consts.MemberTypeAdmin && locked.Status == consts.StatusEnabled {
// 只有当前是"活跃 ADMIN"才需要计数
adminCount, err := ...CountActiveAdminsTx(...)
if err != nil {
return err
}
if adminCount <= 1 {
return response.ErrBadRequest("不能移除该产品的最后一个管理员")
}
}
...
})
外层的 member 只用来取 UserId/ProductCode 做 CheckManageAccess 和事后清缓存。
SELECT COUNT(*) 的快照读 → 并发移除/降级两个不同 ADMIN 会同时通过检查(跨行 TOCTOU,业务不变式被打破)位置:
internal/model/productmember/sysProductMemberModel.go:62-69 (CountActiveAdminsTx)internal/logic/member/updateMemberLogic.go:66-73internal/logic/member/removeMemberLogic.go:45-52描述:
func (m *customSysProductMemberModel) CountActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string) (int64, error) {
var count int64
query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE `productCode` = ? AND `memberType` = ? AND `status` = ?", m.table)
if err := session.QueryRowCtx(ctx, &count, query, productCode, consts.MemberTypeAdmin, consts.StatusEnabled); err != nil {
return 0, err
}
return count, nil
}
session.QueryRowCtx 不附带 FOR UPDATE / LOCK IN SHARE MODE)。在 MySQL InnoDB 默认 REPEATABLE READ 下,这是 MVCC 快照读,不会阻塞也不会被其他事务的 INSERT/UPDATE 阻塞。m1;事务 T2 几乎同时锁定并计划降级/移除 ADMIN m2(m1 ≠ m2,所以行锁不互斥)。CountActiveAdmins,都读到 snapshot=2,都通过 <=1 检查,都 commit → 活跃 ADMIN 数变成 0。这是一个真实存在的跨行 TOCTOU,经典情景:
批处理脚本 + 人工操作的并发。
影响:与 H-1/H-2 累积。"至少一个 ADMIN"是系统的关键不变式,它被三条独立路径共同破坏:
H-1:通过 disable 绕过;
H-2:通过事务内外视图不一致绕过;
H-3:通过快照读+跨行并发绕过。
修复方案(推荐方案 B):
方案 A(局部:对所有活跃 ADMIN 加共享锁):
func (m *customSysProductMemberModel) LockActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string) (int64, error) {
var ids []int64
// 锁定该产品下所有当前活跃的 ADMIN 行;任何其它事务想把这些行变更,都必须等我
q := fmt.Sprintf(
"SELECT `id` FROM %s WHERE `productCode`=? AND `memberType`=? AND `status`=? FOR UPDATE",
m.table)
if err := session.QueryRowsCtx(ctx, &ids, q, productCode, consts.MemberTypeAdmin, consts.StatusEnabled); err != nil {
return 0, err
}
return int64(len(ids)), nil
}
在所有会让一条 ADMIN 变为非活跃的路径(UpdateMember 的 disable/降级 分支、RemoveMember)上:先调用 LockActiveAdminsTx,再判断 count <= 1。这样两个并发事务都会尝试锁定 相同的一组 ADMIN 行,其中一个会被阻塞;赢的那个先计数 → 允许或拒绝 → 提交后释放锁;输的那个获得锁后再看到准确的 count-1。
方案 B(更稳:对产品行做粗粒度互斥):
在所有"可能影响 ADMIN 数量"的事务入口先 SELECT ... FOR UPDATE 产品行(或建一张 sys_product_mutex 表),让对同一产品的 ADMIN 集合变更天然串行化。业务上这些操作并发率极低(手动运营动作),粗粒度锁不会成为性能瓶颈。
DeleteDept 子部门/用户计数用非锁定快照读 → CreateDept / CreateUser / UpdateUser 能在 Delete 事务中"偷偷插入"→ 产生孤儿引用internal/logic/dept/deleteDeptLogic.go:36-63描述:
return l.svcCtx.SysDeptModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
// 仅锁定要删的那一行
lockQuery := fmt.Sprintf("SELECT `id` FROM %s WHERE `id` = ? FOR UPDATE", ...)
...
var childCount int64
countChildQuery := "SELECT COUNT(*) FROM sys_dept WHERE parentId = ?"
session.QueryRowCtx(..., countChildQuery, req.Id) // 快照读,无锁
var userCount int64
countUserQuery := "SELECT COUNT(*) FROM sys_user WHERE deptId = ?"
session.QueryRowCtx(..., countUserQuery, req.Id) // 快照读,无锁
return l.svcCtx.SysDeptModel.DeleteWithTx(...)
})
FOR UPDATE,但这只是对 sys_dept.id=X 这一行的 X 锁。sys_dept.parentId=X。父部门行 X 锁不会传递给"引用这个父"的子行,也不会在 parentId 索引上建立 gap lock(因为 COUNT(*) 不是锁定读)。sys_user.deptId=X 分属另一张表,跟 sys_dept 的行锁没有任何关系。CreateDept(internal/logic/dept/createDeptLogic.go:46-52)在事务外 FindOne 读父部门,不加任何锁,随后 InsertWithTx 写子行。同样 CreateUser、UpdateUser(改 deptId)都不会锁父部门。结果:
CreateDept(parentId=5):不需要任何锁,Insert 成功,commit。sys_dept 子行挂在一个已不存在的 parentId=5 → DeptTreeLogic 的 "parent 不存在则视为 root" 兜底 (deptTreeLogic.go:60-62) 只是把异常用户吞掉,组织架构彻底错乱。对 sys_user 同理:可能生产出 deptId=5 但 dept 已删的孤儿用户,loadDept→FindOne 会失败静默,DeptPath="",进而触发 CheckManageAccess → checkDeptHierarchy 的"您的部门信息异常"分支,用户相当于完全失去权限。
影响:
组织树出现悬空父指针,DeptTree 展示异常;
用户的 deptId 成孤儿,loadDept 静默失败,用户被持续推到"无权操作"分支;
如果外部系统以 deptPath 鉴权,会出现"应该有权限但系统说没"或反之的权限紊乱。
修复方案:
在 DeleteDept 事务内,对 parentId 索引 和 deptId 索引 做"存在性锁定读":
// 子部门:如果有任意一行,就失败;并在 parentId 索引上加 gap/next-key 锁,阻塞并发 INSERT
var tmp int64
q := "SELECT 1 FROM sys_dept WHERE parentId=? LIMIT 1 FOR UPDATE"
err := session.QueryRowCtx(ctx, &tmp, q, req.Id)
if err == nil {
return response.ErrBadRequest("该部门下存在子部门,无法删除")
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
// 用户引用:同样用锁定读
q = "SELECT 1 FROM sys_user WHERE deptId=? LIMIT 1 FOR UPDATE"
err = session.QueryRowCtx(ctx, &tmp, q, req.Id)
...
CreateDept / CreateUser / UpdateUser 在写 parentId / deptId 之前,也要 SELECT 1 FROM sys_dept WHERE id=? FOR SHARE(S-lock 父行),这样 DeleteDept 的 X-lock 会阻塞这些插入,TOCTOU 被彻底消除。
对长期工程:建议直接加 MySQL FK RESTRICT;但即使不加 FK,应用层也必须把"存在性检查 + 删除"放在同一事务的锁定读语义下。
ChangePasswordLogic 无任何限流,JWT 被盗/会话仍在场景下可暴力爆破当前密码internal/logic/auth/changePasswordLogic.go:32-62描述:
func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) error {
if msg := util.ValidatePassword(req.NewPassword); msg != "" { ... }
userId := middleware.GetUserId(l.ctx)
user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, userId)
...
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
return response.ErrBadRequest("原密码错误")
}
...
}
/api/auth/changePassword 只有 JwtAuth 中间件,无 RateLimitMiddleware。LogoutLogic 和 RefreshTokenLogic 都挂了 svcCtx.TokenOpLimiter,但 ChangePasswordLogic 没有;也没有 UsernameLoginLimit 类的每用户限流。bcrypt.CompareHashAndPassword 大约 ~100ms,已登录用户可以串行每秒 ~10 次对自己当前密码做爆破。changePassword 枚举旧密码。虽然 bcrypt 慢,但没有限流意味着可以一直尝试——被害人直到 access token 过期(默认 AccessExpire,通常 2h~1d)都无法自救。ErrBadRequest("原密码错误"),既不记录日志也不递增 tokenVersion,失败完全"无痕"。对比:LogoutLogic 做了 10/60s 限流,RefreshTokenLogic 也做了;ChangePasswordLogic 显然遗漏。
影响:
提供了"持有短期 access token 后可以 brute-force 当前密码,命中后改密持久化"的攻击路径;
一旦命中,新密码会触发 tokenVersion+1(UpdatePassword 里),踢掉真正的用户,攻击者用新密码重新登录即可接管(管理后台也同理,因为它用同一套 bcrypt)。
修复方案:
// internal/logic/auth/changePasswordLogic.go
if l.svcCtx.TokenOpLimiter != nil {
code, _ := l.svcCtx.TokenOpLimiter.Take(fmt.Sprintf("chpwd:%d", userId))
if code == limit.OverQuota {
return response.ErrTooManyRequests("操作过于频繁,请稍后再试")
}
}
建议在 ValidatePassword 之后、FindOne 之前立即做(失败也计数,避免 "先写合法新密码才触发限流" 的绕过)。同时把失败做一条 logx.WithContext(l.ctx).Infof("change-password old-password mismatch userId=%d", userId),给 SOC 提供可观测性。
UpdateUserStatus 对"状态无变化"的请求仍然强制递增 tokenVersion,把被操作用户不必要地踢下线internal/logic/user/updateUserStatusLogic.go:31-52、internal/model/user/sysUserModel.go:137-150UpdateUserStatus 没有对比 user.Status 与 req.Status,不管是否真的变更,都调用 SysUserModel.UpdateStatus;模型层 SQL 为 UPDATE … SET status=?, tokenVersion=tokenVersion+1 … 无条件递增。结果:管理员点一次"启用"按钮(用户原本就是启用),所有该用户在场的会话全部下线。
UpdateUserLogic(updateUserLogic.go:122-135):显式 if user.Status != req.Status { statusChanged = true },只在真正变化时才递增。两处逻辑不一致。user.Status != req.Status 时调 UpdateStatus;或者在 SQL 上加 WHERE id=? AND status<>? 并仅当 RowsAffected>0 时认为"确实变更"。generateRandomHex 长度截断导致 appSecret / adminPassword 熵减半(上一轮 M-3,仍未修复)internal/logic/product/createProductLogic.go:158-164描述:
func generateRandomHex(length int) (string, error) {
b := make([]byte, length)
rand.Read(b)
return hex.EncodeToString(b)[:length], nil // 😱 截断
}
hex.EncodeToString(N bytes) => 2N chars;[:length] 拿到 length 个 hex 字符 = length/2 字节随机性。generateRandomHex(64) 生成 appSecret → 32 字节熵,不是 64;generateRandomHex(32) 生成 appKey → 16 字节 / 128 bit,勉强;generateRandomHex(8) 生成首任管理员初始密码 → 4 字节 = 32 bit,可在 10 分钟内离线爆破(就算走 bcrypt,超管持有明文返回值且要邮件/IM 明文传给运营方——泄漏风险真实存在)。go
func generateRandomHex(byteLen int) (string, error) {
b := make([]byte, byteLen)
if _, err := rand.Read(b); err != nil { return "", err }
return hex.EncodeToString(b), nil // 返回 2*byteLen 个 hex 字符
}
调用方按"字节数"来传;初始管理员密码至少 12 字节随机 → 96 bit 熵,或者直接改用人类可读的 passphrase。UserDetailsLoader 对 "不存在的用户 / 错误的 (userId, productCode)" 没有负缓存 → 每次请求都穿透到 DBinternal/loaders/userDetailsLoader.go:119-144loadFromDB 若 ud.Username == ""(用户不存在或已被删),sf.Do 返回 (nil, nil),不写任何缓存。Load 返回一个空 UserDetails。下一次同样请求仍然走一次完整 DB 链路(FindOne → 命中模型层 cache miss → DB)。sys_user.FindOne 未命中的 DB 查询(直到 token 过期,默认可达 1h~24h)。userId 为不存在 id)即可放大 DB 压力;虽然签名伪造需要 secret,但一个离职用户保留的 token 就能让内网攻击者制造 DB 噪声。loadFromDB 识别到 "用户不存在 / 已删除" 时,写一条 TTL 很短(15~60s)的负缓存标记(例如 cache 一个 ud.Username=="_not_found_"),Load 检测到该标记直接返回"不存在",无需再次查库。UserDetailsLoader 的 "index set 添加 (SADD) 与数据 key 写入 (SETEX)" 非原子 → 并发下 Clean 可能漏清internal/loaders/userDetailsLoader.go:127-132,185-199,201-219SETEX(key, json) → SADD(userIdxKey, key) → EXPIRE(userIdxKey);清链路是 SMEMBERS(userIdxKey) → DEL(keys...) → DEL(userIdxKey)。
交错:
SETEX 完成。Clean(userId):SMEMBERS 尚未看到新 key → DEL 清单中不含它 → DEL idxKey 清掉索引。SADD 把 key 加回到(已被删除的)索引 → 再 EXPIRE(这步会重新创建 set,于是孤儿索引);同时数据 key 仍在 Redis 里。Clean 却已经认为"清完了"。stale 数据持续到 ttl=300s 才自然过期。
这在 BindRoles → Clean、UpdateUser → Clean、UpdateRole → BatchDel 等高频写入路径都可复现。SETEX + SADD + EXPIRE 打包成单次 EVAL;SMEMBERS + DEL keys + DEL idxKey 原子化;Del/Clean 后发一条 "延迟二次清除" 消息(比如 DEL after 1s),补偿这一窄竞态。UpdateRole / BindRolePerms 在事务提交后读 FindUserIdsByRoleId 时忽略错误,Redis 抖动时会漏清缓存internal/logic/role/updateRoleLogic.go:73-75、internal/logic/role/bindRolePermsLogic.go:127-128描述:
affectedUserIds, _ := l.svcCtx.SysUserRoleModel.FindUserIdsByRoleId(l.ctx, req.Id)
l.svcCtx.UserDetailsLoader.BatchDel(l.ctx, affectedUserIds, role.ProductCode)
_,_ 丢 err,affectedUserIds 为空即 BatchDel 无作为。底层 DB 任何抖动都会让这次变更的缓存失效丢失;用户最多需要 5 分钟才拿到新权限。
return err 并在调用链把这个错误归为 "已更新但缓存未清" 的 500;或者失败时写进 retry 队列,由后台任务重做。UpdateRole / UpdateProduct / UpdateMember 没有乐观锁(与 UpdateUser / UpdateDept 的策略不一致)internal/logic/role/updateRoleLogic.go:69(SysRoleModel.Update(ctx, role))internal/logic/product/updateProductLogic.go:58(SysProductModel.Update(...))internal/logic/member/updateMemberLogic.go:75(SysProductMemberModel.UpdateWithTx(...))UpdateUserLogic 和 UpdateDeptLogic 用了 UpdateWithOptLock(expectedUpdateTime) 防并发覆盖;但相同模式的 UpdateRole / UpdateProduct / UpdateMember 仍是无条件 UPDATE:UpdateWithOptLock(expectedUpdateTime) / 或在 SQL WHERE 子句中加 updateTime = ?,并在受影响行数为 0 时返回 ErrUpdateConflict。AdminLoginLogic 缺少"不存在账号的 dummy bcrypt",响应时间可用于 username 枚举internal/logic/pub/adminLoginLogic.go:48-66ValidateProductLogin 在用户不存在时跑一次 dummyBcryptHash 做恒时对齐(loginService.go:53),但 AdminLoginLogic 没有——直接 return "用户名或密码错误"。结合错误信息差异("账号已被冻结" vs "仅超级管理员可通过管理后台登录")和响应时间差异(bcrypt ~100ms vs 不调用 bcrypt ~1ms),攻击者可在 AdminLoginRateLimit(20/min)、UsernameLoginLimit(10/5min) 限额内做较精确枚举。ValidateProductLogin 对齐:
go
if errors.Is(err, user.ErrNotFound) {
bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(req.Password))
return nil, response.ErrUnauthorized("用户名或密码错误")
}
并把"账号已被冻结" / "仅超级管理员可通过管理后台登录" 这两个分支统一归到 "用户名或密码错误"(管理后台侧不暴露任何有效账号的状态信息),让登录失败无法区分。
ProductList / ProductDetail / DeptTree 无访问控制,普通成员能看全量产品名 / 组织架构(上一轮 M-4/M-5,仍未修复)internal/logic/product/productListLogic.go:31-58internal/logic/product/productDetailLogic.go:29-48internal/logic/dept/deptTreeLogic.go:27-67JwtAuth,没有任何"是当前产品成员 / 当前部门管辖"过滤。ProductList 只是把 appKey 在非超管时置空,名称/备注仍返回;ProductDetail 给任何 id 都能读。DeptTree 更是一次性返回所有部门——对外泄漏组织结构和产品清单。ProductList 改为"按 sys_product_member inner join productCode 过滤成当前调用者所属的产品集合";超管才能看全量;ProductDetail 对非超管校验 caller.ProductCode == product.Code;DeptTree 至少要求 RequireSuperAdmin 或返回以调用者部门为根的子树。SetUserPermsLogic 重复执行了同一条 FindOneByProductCodeUserId 查询(CheckManageAccess → checkPermLevel 内部做一次,随后 SetUserPerms 里又做一次)internal/logic/user/setUserPermsLogic.go:54-64 + internal/logic/auth/access.go:148-157CheckManageAccess(req.UserId, productCode) 内部 checkPermLevel 已经 FindOneByProductCodeUserId(productCode, targetUserId);紧随其后 SetUserPerms 又做一次同样的读,再加上一次 FindOne(user)、一次 FindOneByCode(product)、一次 FindByIds(perms)——在写路径上累计 5~6 次 DB read。考虑到模型层都有 CachedConn,多数命中缓存,但 FindMinPermsLevelByUserIdAndProductCode 是无缓存查询,每次接口调用都会实际打一次 DB。CheckManageAccess 把解析出来的 targetMember / targetRoleLevel 通过返回值或 context 透出,SetUserPerms 直接复用;VerifyToken gRPC 对"无效 token"完全不记录日志,也没有调用方识别字段(appKey/serviceName)→ 生产上不可观测internal/server/permserver.go:173-208return &pb.VerifyTokenResp{Valid: false}, nil,不分类、不打日志。线上"用户反馈总是 401"时没法判断是 TokenVersion 不符还是 ProductStatus 禁用还是别的;更没有"该产品服务在过去 10 分钟发生了 10k 次 VerifyToken 失败"这种告警能力。logx.WithContext(ctx).Infof("verifyToken fail userId=%d reason=%s", claims.UserId, reason);reason 单独落字段方便日志聚合。PermServer.Login 在 peer.FromContext 失败时静默跳过限流(fail-open)internal/server/permserver.go:62-76if s.svcCtx.GrpcLoginLimiter != nil { if p, ok := peer.FromContext(ctx); ok { … rate limit … } }——如果 ok == false(走 in-process / socket 无 peer 信息的场景)就不限流。配合 ExecuteSyncPerms 同样逻辑(gRPC 不限流,HTTP 层才有),理论上内部调用或错误配置下会绕过保护。ok == false 时把 key 当作 "grpc:login:unknown" 走限流,更严苛的做法是"没 peer → 直接拒绝"(因为生产环境都在 gRPC over TCP)。internal/middleware/adminloginratelimitMiddleware.gointernal/middleware/productloginratelimitMiddleware.gointernal/middleware/syncratelimitMiddleware.goHandle 方法(直接 passthrough),路由里实际使用的是 svc.ServiceContext 里基于 RateLimitMiddleware 构造出来的实例。两边同名但互不相干,静态分析工具看来这些 *Middleware 类型无任何引用。// Deprecated. Use ... 并在下次发版一起删。bindRolesLogic 的 "role level" 检查对 DEVELOPER 无条件放行internal/logic/user/bindRolesLogic.go:85-91描述:
if !caller.IsSuperAdmin &&
caller.MemberType != consts.MemberTypeAdmin &&
caller.MemberType != consts.MemberTypeDeveloper {
if caller.MinPermsLevel == math.MaxInt64 || r.PermsLevel < caller.MinPermsLevel {
return response.ErrForbidden("不能分配权限级别高于自身的角色")
}
}
DEVELOPER 绕过 perms-level 校验——语义正确前提是"DEVELOPER 已经拿到产品全部权限(loadPerms 里走全权分支)",因此让 TA 给 MEMBER 分高阶角色不算越权。但这条业务语义并不显式写在代码里,任何未来把 DEVELOPER 权限收窄的改动都会立刻让这里成为漏洞。
access.go 里统一写一个 callerIsFullPermInProduct(ud) bool(条件:SuperAdmin / ADMIN / DEVELOPER / 或 DeptType==DEV 且成员启用),所有依赖"caller 已拥有全权"做的短路都复用它,变更只需改一处。loadPerms 的判定也统一走它。strings.Contains(err.Error(), "1062") 脆弱的错误识别(多处)internal/logic/user/createUserLogic.go:92-96internal/logic/role/createRoleLogic.go:67-71internal/logic/member/addMemberLogic.go:76-80internal/logic/product/createProductLogic.go:134-144"1062" / "Duplicate entry"。一旦换 driver 版本或 MySQL 升级导致文案变化,这些检测直接失效,逻辑全部变成 500。产品/用户的唯一索引冲突会被当成内部错误吃掉。mysql.MySQLError 类型判断:
go
import "github.com/go-sql-driver/mysql"
var me *mysql.MySQLError
if errors.As(err, &me) && me.Number == 1062 {
return response.ErrConflict(...)
}
ChangePasswordLogic 在 bcrypt 校验之前不检查 user.Status 冻结状态internal/logic/auth/changePasswordLogic.go:37-45FindOne → bcrypt.CompareHashAndPassword,没有 if user.Status != StatusEnabled。JWT 中间件已经对冻结用户拦截,但 (a) 冻结后中间件 loader cache 未及时失效的 race 窗口;(b) 冻结状态的用户理论上仍可调 changePassword(虽然此 JWT 已被拦截)。防御纵深建议双保险。修复:
if user.Status != consts.StatusEnabled {
return response.ErrForbidden("账号已被冻结")
}
SetUserPerms / BindRoles / BindRolePerms 的去重写回到了 req(副作用入参)setUserPermsLogic.go:72-86bindRolesLogic.go:58-68bindRolePermsLogic.go:44-54go
uniquePerms := make([]types.UserPermItem, 0, len(req.Perms))
...
req.Perms = uniquePerms
对外部传入的 req 做原地写入会让上层(例如 handler、中间件、单元测试)读到被改过的结构,违背"logic 不修改入参"原则。
perms := uniquePerms;后续所有流程使用 perms,不再碰 req.Perms。memberTypePriority("") 返回 MaxInt32,在 CheckMemberTypeAssignment 下会与未知类型保持等价判定internal/logic/auth/access.go:15-28,67-69caller.MemberType == ""(例如产品禁用后被 loader 清空),memberTypePriority("")==MaxInt32;if MaxInt32 >= priority(assigned) 恒真 → CheckMemberTypeAssignment 直接拒绝。这里"fail-closed"是正确行为,但逻辑靠的是 sentinel 值而非显式分支,容易在将来扩展 MemberType 时出错。caller.MemberType == "" → return ErrForbidden("缺少产品成员上下文")。UserDetailsLoader.Clean 的错误忽略会扩散internal/loaders/userDetailsLoader.go:150-153,177-179,186-198Errorf 记日志,然后继续。当 Redis 间歇不可用时,管理员以为"清完了",实际上旧缓存可能至少 5 分钟内生效。Del 失败 → 在队列里起一次 retry;或者把 UserDetails 的缓存层 TTL 缩短到 60s 并接受 DB 压力(负载允许前提下)。DeptTree 对"孤儿 parent"只打日志即当 root 并继续返回 → 静默数据异常internal/logic/dept/deptTreeLogic.go:55-63warnings 字段或在 HTTP 头里 flag,让前端报警;后台做一条 alerting 指标 dept_orphan_count > 0。return err 而不包装为统一 500,生产会透出底层 sqlx/gorm/bcrypt 错误文本response.Setup 会把非 *CodeError 的错误映射成 {code: 500, msg: "服务器内部错误"} 并 logx.Errorf。这本身没问题,但很多 logic 在上下文敏感的位置(比如 bcrypt 生成失败)返回原始 err 到调用栈,日志里出现原始 bcrypt 错误文本也是一种信息披露。建议所有 logic 统一包装成 response.ErrInternal("xxx 失败") 并把原 err 放入 logx.Errorf 里,避免上下层关心如何转换。| 优先级 | 问题 | 关键词 |
|---|---|---|
| P0 | H-1 | UpdateMember 禁用最后 ADMIN 绕过 |
| P0 | H-2 | RemoveMember 事务内外视图脱钩 |
| P0 | H-3 | CountActiveAdmins 跨行 TOCTOU |
| P0 | H-4 | DeleteDept 子部门/用户 TOCTOU |
| P0 | H-5 | ChangePassword 无限流可暴力破旧密码 |
| P1 | M-1 | UpdateUserStatus "无变化也踢下线" |
| P1 | M-2 | generateRandomHex 熵减半 |
| P1 | M-6 | UpdateRole/Product/Member 缺乐观锁 |
| P1 | M-7 | AdminLogin username 枚举 |
| P1 | M-8 | ProductList / ProductDetail / DeptTree 无访问控制 |
| P2 | M-3/M-4/M-5/M-9/M-10/M-11/M-13/M-14/M-15 | 可观测 / 容错 / 代码一致性 |
| P3 | M-12 / L-* | 僵尸代码、副作用入参、错误透传 |
guardLastActiveAdminTx(session, productCode, targetMemberId) helper,UpdateMember 和 RemoveMember 都调用它;内部先 SELECT id ... FOR UPDATE 锁定所有活跃 ADMIN,再做 count/是否将失活判断。SELECT 1 ... FOR UPDATE 做存在性锁定读;CreateDept/CreateUser/UpdateUser 写 deptId 前对父部门 SELECT ... FOR SHARE。短期方案即可生效,长期改 FK。TokenOpLimiter.Take("chpwd:%d");补充失败日志。注:本轮报告不再列"已在上轮修复"或"已被单测覆盖"的 finding(比如 H-A/H-B、TokenVersion 相关等),见
test-report.md对应条目。