审计范围:
/internal下全部非测试、非_gen.go生产代码(含internal/server/permserver.go、HTTP logic / handler / middleware、loaders、model 定制层、svc、util、consts)。 审计时间:2026-04-19 审计维度:逻辑一致性 / 并发与 RMW / 资源管理 / 数据完整性 / 安全漏洞 / 边界坍塌 / DB 性能 / 僵尸代码 / 接口契约与对象完整性。 与上一轮对比:第 6 轮的 H-1 / H-2 / H-3 / M-1 / M-2 / M-4 / M-5 / M-6 / M-8 / L-1 / L-3 / L-5 均已在 HEAD 代码中落地。本轮聚焦第 6 轮未修复项(M-3 / M-7 / L-2 / L-4 / L-6)和这一轮深挖出来的新漏洞,尤其是UserDetailsLoader.loadPerms中 deny-list fail-open、AddMemberLogic的目标侧授权缺失等高风险项。
UserDetailsLoader.loadPerms 在 deny 列表查询失败时 fail-open,且把"少了 deny"的权限集写入 5 分钟缓存 —— 单次 DB 抖动 → 用户越权internal/loaders/userDetailsLoader.go:456-499FindPermIdsByUserIdAndEffectForProduct(allow) —— 失败时 return,ud.Perms 保持 nil(fail-close,OK)。FindPermIdsByUserIdAndEffectForProduct(deny) —— 失败时只 log,然后继续往下跑,denyIds 为 nil,denySet 为空。permIdSet 里塞 rolePermIds + allowIds,然后直接把这个未经 deny 过滤的集合作为最终权限写回缓存(缓存 TTL 5 分钟)。FindPermIdsByUserIdAndEffectForProduct 走 QueryRowsNoCacheCtx,任何瞬时 DB 错误(连接池耗尽、slow query 触发 context deadline、主从漂移等)都会让 deny 查询失败。结果就是:
effect=DENY 显式撤销权限的用户,立刻拿回这条被撤销的权限;json.Marshal 进 UD JSON 后写入 ud:userId:productCode Redis key,持续 5 分钟;GetUserPerms)读取的都是这份"无 deny"权限集,有多少次请求就有多少次越权。setUserPermsLogic 的主要用途就是"临时撤销某用户对敏感权限的访问",这类 deny 往往就是最后一道安全闸。闸被 silently 打开 5 分钟。sys_user_perm 的短时读失败(例如对该表发起 hot-row 争抢使 denyIds 的查询 timeout),即可让目标用户的 deny 被旁路。修复方案:把两次查询在错误语义上对称处理:
denyIds, err := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(ctx, ud.UserId, consts.PermEffectDeny, ud.ProductCode)
if err != nil {
logx.WithContext(ctx).Errorf("userDetailsLoader: load deny perms failed: %v", err)
return // fail-close:宁可让用户看到 0 perms 让他们刷新,也不能把 deny 旁路
}
同时本次加载不要写缓存,交给下一次 Load 重试,或者把失败信号往上传(见 M-1)。
UserDetailLogic / UserListLogic 仍把 Email / Phone / Remark 暴露给任意同产品成员(R6 M-3 未落地)internal/logic/user/userDetailLogic.go:68-70internal/logic/user/userListLogic.go:81-83UserDetail 仅用 FindOneByProductCodeUserId 检查目标与调用方在同一产品内,就返回 Email、Phone、Remark(纯文本,无脱敏)。UserList 更严重 —— 一次分页可以批量拿到同产品所有成员的手机号与邮箱。AddMember 不做目标侧授权)组合后威力更强:一个产品 ADMIN 可先把想看的任意人(包括其部门树外、不归自己管的用户)强行拉入自己的产品,再通过 UserDetail 或 UserList 抽走其 PII。authHelper.CanViewContact(caller *UserDetails, target *SysUser, targetMember) bool,只在以下任一条件时返回 true:
CheckManageAccess 通过(即 caller 在管理链上)。filterPIIForCaller,其余情况把 Email / Phone / Remark 置空或做掩码(如 138****1234)。AddMemberLogic 缺失目标侧 CheckManageAccess + 超管防御,产品 ADMIN 可把部门树外 / 超管用户拉入自己产品internal/logic/member/addMemberLogic.go:41-75AddMember 仅做了三件事:RequireProductAdminFor(req.ProductCode):caller 是该产品 ADMIN 或超管。CheckMemberTypeAssignment(req.MemberType):caller 允许分配这种 MemberType。FindOneByProductCodeUserId 排查重复加入。缺失的两道防线:
req.UserId 目标的 CheckManageAccess:没有任何基于 DeptPath 的部门链校验,也没有任何基于 MinPermsLevel 的权限级校验。产品 ADMIN 可以把自己部门树之外的用户(例如 HR 部、财务部员工)强行拉入自己的产品。targetUser.IsSuperAdmin 的显式拒绝:如果系统中存在"超管但未主动加入任何产品"的账号,产品 ADMIN 可通过 AddMember 把这个超管拉入自己产品成为 MEMBER。虽然 loadMembership 会在 ud.IsSuperAdmin == true 时把 MemberType 固定为 SuperAdmin(实际权限没被限制),但这在审计日志里会留下一条"product_admin 把 super_admin 纳入自己产品"的假成员关系,为后续权限推理工具 / 审计系统制造混淆,也是第一步社工放大点。UpdateMemberLogic 组合:拉入后还可以赋予任何允许的 MemberType,制造跨部门的管理权扩张。RequireProductAdminFor / CheckMemberTypeAssignment 之后、Insert 之前,追加如下两段:
go
if targetUser.IsSuperAdmin == consts.IsSuperAdminYes {
return nil, response.ErrForbidden("无法将超级管理员加入具体产品")
}
if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, req.ProductCode,
authHelper.WithPrefetchedTarget(targetUser)); err != nil {
return nil, err
}
注意:CheckManageAccess 内部对 "caller 是 ADMIN" 会短路 checkDeptHierarchy,所以这不会让产品 ADMIN 失去管理自己下属 / 旁部门用户的能力,但会拦住"部门树外 + 不归自己管"这类真正的越权路径。
keyfunc 未显式断言 *jwt.SigningMethodHMAC(R6 M-7 未落地)internal/middleware/jwtauthMiddleware.go:59-61(HTTP access token)internal/server/permserver.go:242-244(gRPC access token)internal/logic/auth/jwt.go:78-80(refresh token)return []byte(secret), nil,不检查 token.Method 类型。当前使用 jwt/v4 且 accessSecret / refreshSecret 都是对称密钥,不受 alg=none 攻击,但这是深度防御盲区:
keyfunc 仍然把 []byte 塞进去,攻击者可以用把公钥当 HMAC 密钥的经典手法伪造 token —— 这在 jwt-go 历史上是实打实出过 CVE 的(CVE-2016-10555 同类问题)。alg=HS512 的 token,也不会被明确拒绝,而是当作 HS256 尝试解析,带来噪音和潜在被动兼容。alg 白名单的强制要求。修复方案:抽出一个通用 helper,三处统一调用:
// internal/logic/auth/jwt.go
func parseWithHMAC(tokenStr, secret string, claims jwt.Claims) (*jwt.Token, error) {
return jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secret), nil
})
}
UserDetailsLoader.Load 把 "DB 瞬时故障" 同化为 "用户不存在",副作用是把半成品 UD 写入 5 分钟缓存internal/loaders/userDetailsLoader.go:119-165, 284-304loadFromDB 在 loadUser 失败(非 NotFound)时返回 ud, err;在 Load 的 singleflight 闭包里转化为 (nil, err)。Load 最后的 if !ok || ud == nil 分支会构造一个空 UD 返回。HTTP jwtauthMiddleware 看到 ud.Username == "" 就直接 401 "用户不存在或已被删除"。loadDept / loadProduct / loadMembership / loadRoles / loadPerms 这五个子步骤里的任何错误都是 log + 静默继续。然后 Load 在 singleflight 成功分支里照常 json.Marshal(ud) 并 SetexCtx 写入缓存。于是当 dept 表抖动时,用户的 DeptPath / DeptType 变空白,checkDeptHierarchy 直接 403(除非 caller 是 ADMIN/超管),这份"半残" UD 还会被缓存 5 分钟。loadPerms 的 deny 失败同样落入这个"半加载也写缓存"的通道。Load 返回 (*UserDetails, error),让中间件自己区分 "NotFound → 401 用户不存在" 与 "其他错误 → 503 服务暂时不可用"。loadFromDB 里任何子步骤出错,都不要写缓存(让下次 Load 重试)。PartiallyLoaded bool 字段,Load 写缓存前检查这一位。SysUserModel.UpdatePassword / UpdateStatus 不校验 RowsAffected,对已删除 / 条件不满足的用户会静默成功internal/model/user/sysUserModel.go:128-140(UpdatePassword)internal/model/user/sysUserModel.go:143-155(UpdateStatus)m.ExecCtx(..., conn.ExecCtx(...)) 然后直接 return err,不读 sql.Result.RowsAffected。如果 FindOne 到 ExecCtx 之间用户被另一会话删除,或者主从延迟导致 WHERE id = ? 命中 0 行,调用方拿到的是 nil err,以为"更新成功"——实际 DB 里没有变化,但 DelCacheCtx(sysUserIdKey, sysUserUsernameKey) 已经执行了。ChangePassword 对已删除用户会返回 200 "成功",客户端以为改密成功,用户下次登录发现密码没变。UpdateUserStatus 对跨进程并发删除的用户会"假装成功",上层以为冻结生效。UpdatePassword / UpdateStatus 自身没有乐观锁(UpdateProfile 有 updateTime CAS),两次并发 ChangePassword 会"后写覆盖先写"且都返回成功,用户拿到的新密码是无法预测的那一份(在已知旧密码共谋场景下影响低)。go
res, err := m.ExecCtx(...)
if err != nil { return err }
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound // 或自定义 ErrUpdateConflict
}
return nil
另外建议对 UpdatePassword 加上 AND updateTime = ? 的乐观锁,语义上与 UpdateProfile 对齐,避免并发改密"最后一写赢"的隐式行为。
GuardRoleLevelAssignable 的授权依据是 caller 的缓存 MinPermsLevel,被降级的调用者在 5 分钟缓存 TTL 内仍可分配原等级角色internal/logic/auth/access.go(GuardRoleLevelAssignable);调用方 internal/logic/user/bindRolesLogic.go。BindRoles 的授权判断是"caller 的 MinPermsLevel 必须严格小于被分配角色的 PermsLevel",而 caller 是从 UserDetailsLoader.Load(callerUserId, productCode) 取的,缓存 TTL 5 分钟。攻击窗口:
BindRoles 改 C 的角色(Clean(C.UserId))。BindRoles 给下属 X 分配"总监级角色 (permsLevel=10)"。MinPermsLevel,它要靠 UD 缓存。如果 C 的 UD 缓存在 T=0 被 Clean,第二次读会打 DB 拿到新级别 → 授权失败。但只要 Clean 因为 Redis 抖动失败了一次,C 的 UD 缓存还在,GuardRoleLevelAssignable 读到 10,判定"严格小于 10 即可"不通过,判定 10 >= 10 → 授权失败(这条实际 OK)。caller.MinPermsLevel(缓存=10) >= 15 不成立 → 允许。实际 DB 里 C 是 20,20 >= 15 成立 → 应该拒绝。C 用缓存越权分配了自己现在够不到的角色。GuardRoleLevelAssignable 里,对 caller 的 MinPermsLevel 额外做一次"旁路缓存直查 DB"校验(只在这条授权点,不影响其他用 UD 缓存的路径)。Clean 走 Redis pipeline + 一次 Lua script 保证原子,失败时 retry 2~3 次,失败后把 userId 入一个短 ttl 的降级黑名单,命中就强制走 DB。UserDetailsLoader 加个 LoadFresh(ctx, userId, productCode) 方法专供授权点使用,bypass cache。CreateProductLogic 响应体里明文返回初始 admin 密码,穿过任何响应日志 / 监控都会落盘internal/logic/product/createProductLogic.go(返回 types.CreateProductResp.AdminPassword 明文)、internal/types/types.go:47-54CreateProductResp 包含 AdminPassword string,go-zero 默认响应序列化走 httpx,响应体默认不自动打日志,但在以下三个常见运维情况下会落盘:
IsSuperAdmin 产品默认密码一旦落到长期存储就需要紧急全量改密。AdminPassword 字段标记为 "一次性展示",并在文档里强制要求立刻改密。adminUser,密码随后走带 nonce 的一次性链接(Redis 中存 5 分钟、一次消费后删除),新产品 owner 登录后自己重置。response.Middleware 中把 AdminPassword 字段加入日志脱敏白名单。DeleteDeptLogic 事务内多段 FOR UPDATE 锁序列仍保留 AB-BA 死锁理论风险(R6 L-2 未消化)internal/logic/dept/deleteDeptLogic.goFOR UPDATE 了 sys_dept(row) → sys_dept(children range) → sys_user(dept range)。如果另一事务(比如 UpdateUser 要改 DeptId,同时 CreateDept 插一个子部门)以不同顺序抢锁,理论上存在 AB-BA 交叉死锁。实际频率低,但 MySQL 死锁 retry 会被 go-zero 上浮成 500。UpdateUserLogic 涉及 DeptId 变更的时候显式 SELECT ... FOR SHARE 一下新旧部门。或把 DeleteDept 中排查子部门 / 关联用户的 FOR UPDATE 降成 FOR SHARE(因为这里是存在性判断而不是修改)。SysUserModel.IncrementTokenVersion 仍是"无条件大杀器",缺安全注释 / 调用点约束(R6 L-4 未消化)internal/model/user/sysUserModel.go(IncrementTokenVersion),调用方 internal/logic/auth/logoutLogic.go:46。RefreshToken 已经切到 IncrementTokenVersionIfMatch(CAS 语义正确),Logout 还在用老的 IncrementTokenVersion(业务语义正确,"强制所有会话失效")。风险是这个 API 现在对整个仓库可见,未来任何改 Refresh / Rotate 场景的开发者都可能误调它,退回到 R5 以前的会话劫持窗口。IncrementTokenVersion 加个 // WARN: 仅限强制全量失效(Logout / 封禁)。Refresh/Rotate 必须使用 IncrementTokenVersionIfMatch。 的显式 header 注释。IncrementTokenVersion 改成 package-private,再在 logout 所在 package 用显式命名的 wrapper(ForceRevokeAllSessions)暴露,新接入者一眼看到红色标签。loadPerms 其他分支的错误同样被静默 log(rolePermIds / allowIds / FindAllCodesByProductCode),和 H-1 同宗internal/loaders/userDetailsLoader.go:435-498FindPermIdsByRoleIds 失败 → rolePermIds 保持空。若此时 role 权限正常、但查询临时失败,用户的"角色→权限"整块就被丢掉。FindAllCodesByProductCode 失败 → ud.Perms = nil(对 ADMIN / DEV 部门这类"全量权限"角色来说直接降成 0 perm)。FindByIds 失败 → ud.Perms = nil。所有这些都会被 Load 写入 5 分钟缓存。对 ADMIN 来说是"5 分钟内所有权限消失",对普通成员来说是"5 分钟内权限表不一致"。用户体感就是间歇性 403,定位困难。
Load,由上层决定是 503 还是 401。status = 1 硬编码,与 consts.StatusEnabled 脱钩internal/model/userrole/sysUserRoleModel.go:51 —— r.status = 1internal/model/userperm/sysUserPermModel.go:35 —— p.status = 1internal/model/role/sysRoleModel.go(FindMinPermsLevelByUserIdAndProductCode)—— r.status = 1consts.StatusEnabled 当前定义为 1,但三处 SQL 把它写死。一旦运维 / 迁移脚本把 StatusEnabled 的语义改掉,或者业务加出 "status=2 已归档" 之类的新状态,这几条查询会默默返回错误数据集,没有编译期 / 单元测试期信号。... AND r.status = ? + 参数传 consts.StatusEnabled,与其他同类查询(如 sysProductMemberModel.CountOtherActiveAdminsTx)风格一致。internal/model/productmember/sysProductMemberModel.go 中两个僵尸接口方法FindMapByProductCodeUserIds(定义在接口和实现中)CountActiveAdmins(非事务版)rg 扫下来这两个方法仅在 testutil/mocks/mock_productmember_model.go 与 sysProductMemberModel_test.go 中被引用,整个 /internal/logic/** 没有一处调用。实现里还包含手写 SQL,是维护负担与误用风险源。
同样地,internal/model/perm/sysPermModel.go 中的 FindMapByProductCode(非事务版)也仅在 mock 与 test 中出现,syncPermsService 已切到 FindMapByProductCodeWithTx。GetUserPerms 可被有效 AppKey/AppSecret 拥有者用作负缓存预污染工具internal/server/permserver.go(GetUserPerms);结合 internal/loaders/userDetailsLoader.go:134-154 的负缓存写入路径。GetUserPerms 接受任意 req.UserId,内部会调用 UserDetailsLoader.Load。若攻击者拥有有效的产品凭证(如被泄漏的 appKey/appSecret),可批量请求未来将分配的自增 ID(userId = maxUserId+1 ... maxUserId+N):
negativeCacheMarker(TTL 30s);CreateUser 自增到这个 ID,他的 UD 缓存键已被占用为负缓存;Load,直接命中 _NOT_FOUND_,Username 返回空,JWT middleware 判定"用户已被删除",登录 / 使用失败。Load 在写入负缓存之前,再通过 SysUserModel.FindOne(ctx, userId) 强一致校验一次(绕过 cache),确认真 NotFound 才写哨兵。CreateUserLogic 成功插入之后主动 Del 掉 ud:newId:* 的负缓存键(需要遍历产品维度,因此成本较高)。negativeCacheTTL 从 30 → 10,并加一条 svc 级的"新用户创建后 30s 内绕过负缓存"的白名单(按 userId > watermark 判定)。CheckManageAccess 对 caller DeptId=0 且非 ADMIN 的历史账号直接 403(R6 L-6 未消化)internal/logic/auth/access.go(checkDeptHierarchy)DeptId=0,但存量数据中有遗留的 DeptId=0 MEMBER 账号。这类账号即便通过 checkPermLevel 校验也会因为 caller.DeptPath == "" 在 checkDeptHierarchy 被直接 403。UPDATE sys_user SET deptId = <default_dept_id> WHERE deptId = 0 AND isSuperAdmin = 0 AND memberType NOT IN ('ADMIN')。CheckManageAccess 最上面加 if callerUserId == targetUserId && productCode == caller.ProductCode { return nil } 避免纯看自己的操作被部门树误伤。| 优先级 | finding | 一句话概要 |
|---|---|---|
| P0 | H-1 loadPerms deny-list fail-open |
DB 抖动一次 → 用户越权 5 分钟,纯代码路径,修最简单 |
| P0 | H-2 UserDetail/UserList PII 暴露(R6 M-3 未落地) | 任意同产品 MEMBER 可读全员手机邮箱 |
| P0 | H-3 AddMember 缺 CheckManageAccess + 超管防御 | 产品 ADMIN 可拉跨部门 / 超管用户入产品,直接放大 H-2 |
| P0 | H-4 JWT keyfunc 未断言 HMAC(R6 M-7 未落地) | 深度防御盲区,未来密钥体系迁移的定时炸弹 |
| P1 | M-1 Load 把 DB 故障同化为用户不存在;半加载也写缓存 | 单点 DB 抖动触发雪崩 + 5 分钟半残缓存污染 |
| P1 | M-2 UpdatePassword/UpdateStatus 不校验 RowsAffected | 对已删除用户静默成功,语义欺骗客户端 |
| P1 | M-3 GuardRoleLevelAssignable 依赖缓存 MinPermsLevel | TOCTOU + Clean 失败 → 降级 admin 在 5 分钟内仍能授出原等级 |
| P1 | M-4 CreateProduct 响应里带明文初始密码 | 穿过日志 / APM 就落盘,需紧急改密 |
| P2 | L-1 DeleteDept 多段 FOR UPDATE 锁序列(R6 L-2) | AB-BA 死锁理论风险 |
| P2 | L-2 IncrementTokenVersion 无安全注释(R6 L-4) | 易被未来改 Refresh 的开发者误用 |
| P2 | L-3 loadPerms 其余分支错误同样静默 | 和 H-1 同宗,应作为一个修复包一起上 |
| P2 | L-4 SQL 中 status = 1 硬编码 |
统一改成 consts.StatusEnabled 占位参数 |
| P2 | L-5 FindMapByProductCodeUserIds / CountActiveAdmins(非 Tx)等僵尸 |
仅 mock/test 引用,清理 |
| P3 | L-6 gRPC GetUserPerms 负缓存预污染 | 依赖 AppKey 泄漏 + 自增 ID 命中,概率低但可行 |
| P3 | L-7 CheckManageAccess caller DeptId=0 时 403(R6 L-6) | 历史遗留账号,运维侧补数据或代码兜底"看自己" |
P0 同批上线(同一次发版一起修,互相放大):
filterPIIForCaller 在 UserDetail / UserList 返回前强制走一遍。AddMember 追加 CheckManageAccess + 超管判定。parseWithHMAC helper,三处 keyfunc 替换。P1 紧随:
(*UserDetails, error),半加载不写缓存。ExecCtx 后加 RowsAffected 判定。GuardRoleLevelAssignable 改走 fresh read,不靠 UD 缓存。CreateProductResp 换成"一次性展示链接 + 立即改密"流程。P2 / P3 收尾:
status = 1 批量改占位参数。Load 写负缓存前再跑一次 fresh FindOne,或者缩 TTL 到 10s。CheckManageAccess "看自己" 短路。