审计范围:
internal/下所有非测试.go文件、perm.sql、perm.api、pb/gRPC 服务
审计日期:2026-04-18
审计维度:逻辑一致性、并发竞态、资源管理、数据完整性、安全漏洞、边界崩溃
GetUserPerms 接口无任何鉴权internal/server/permserver.go:186-197GetUserPerms 方法直接调用 UserDetailsLoader.Load(ctx, req.UserId, req.ProductCode),没有任何身份校验逻辑——不要求 JWT、不校验调用方服务身份、不验证 mTLS。任何能连接 gRPC 端口的客户端,只需知道 userId 和 productCode 即可获取该用户的 MemberType 与完整权限列表。GetUserPerms 改为要求传入一个有效的 AccessToken,在方法内部验证 token 后,仅返回该 token 对应用户的权限。// 建议在 GetUserPerms 中增加调用方鉴权
func (s *PermServer) GetUserPerms(ctx context.Context, req *pb.GetUserPermsReq) (*pb.GetUserPermsResp, error) {
// 校验内部调用凭证
if err := s.verifyInternalCaller(ctx); err != nil {
return nil, status.Error(codes.Unauthenticated, "未授权的调用方")
}
// ... 原有逻辑
}
AddMember 缺乏产品归属校验,可跨产品添加成员internal/logic/member/addMemberLogic.go:46AddMember 使用 CheckManageAccess(ctx, svcCtx, req.UserId, req.ProductCode) 进行鉴权。然而 CheckManageAccess(access.go:47)对"操作自己"无条件放行(caller.UserId == targetUserId → return nil),不校验 req.ProductCode 是否等于 caller.ProductCode。这意味着:
req.UserId == caller.UserId)。CheckMemberTypeAssignment 只校验 caller.MemberType 的优先级,不区分产品上下文——A 产品的 ADMIN 身份被用来判断是否可分配 B 产品的 MEMBER 类型。AddMember 中增加显式的产品归属校验:func (l *AddMemberLogic) AddMember(req *types.AddMemberReq) (resp *types.IdResp, err error) {
// 新增:要求操作者必须是目标产品的管理员(或超管)
if err := authHelper.RequireProductAdminFor(l.ctx, req.ProductCode); err != nil {
return nil, err
}
// ... 其余逻辑保持不变
}
UpdateUserStatus 缺少"目标用户属于当前产品"的校验internal/logic/user/updateUserStatusLogic.go:32-60UpdateUserStatus 仅调用 CheckManageAccess 校验管理权限,没有像 SetUserPerms / BindRoles 那样先验证 SysProductMemberModel.FindOneByProductCodeUserId(目标用户必须是当前产品的成员)。而 CheckManageAccess 中的 checkPermLevel 在目标无成员记录时,targetMemberType="" → memberTypePriority=MaxInt32 → callerPri < targetPri → 直接放行。SysUser.Status 是全局字段,冻结后影响用户在所有产品下的登录。SetUserPerms / BindRoles 保持一致:func (l *UpdateUserStatusLogic) UpdateUserStatus(req *types.UpdateUserStatusReq) error {
// ... 现有校验 ...
productCode := middleware.GetProductCode(l.ctx)
// 新增:确保目标用户是当前产品的成员
if _, err := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, productCode, req.Id); err != nil {
return response.ErrBadRequest("目标用户不是当前产品的成员")
}
if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.Id, productCode); err != nil {
return err
}
// ...
}
UpdateUser 中 Update + UpdateStatus 非原子操作internal/logic/user/updateUserLogic.go:111-118SysUserModel.Update(ctx, user) 更新所有字段(包括 Status),若状态确实变更又额外调用 SysUserModel.UpdateStatus(ctx, req.Id, req.Status)。这两次写操作没有在同一事务中:
Update 已经将 Status 写入了 DB,第二次 UpdateStatus 是冗余的(其主要目的是递增 tokenVersion 使旧 token 失效)。Update 成功但第二次 UpdateStatus 失败,用户状态已被冻结但 tokenVersion 未递增,导致用户的旧 token 仍然有效——被冻结的用户仍可继续访问系统直到 token 过期。UpdateStatus(它内部已经包含了 tokenVersion 递增逻辑):// 方案:移除冗余的双重写入,在 Update 中统一处理
if statusChanged {
// 使用事务确保 status 变更和 tokenVersion 递增的原子性
if err := l.svcCtx.SysUserModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
if err := l.svcCtx.SysUserModel.UpdateWithTx(ctx, session, user); err != nil {
return err
}
return l.svcCtx.SysUserModel.UpdateStatusWithTx(ctx, session, req.Id, req.Status)
}); err != nil {
return err
}
} else {
if err := l.svcCtx.SysUserModel.Update(l.ctx, user); err != nil {
return err
}
}
checkPermLevel 对非产品成员目标默认放行internal/logic/auth/access.go:147-177FindOneByProductCodeUserId 返回错误,targetMemberType 保持为空字符串 ""。memberTypePriority("") 返回 math.MaxInt32(即优先级最低)。接下来比较 callerPri < targetPri(例如 ADMIN 的 1 < MaxInt32)→ 直接 return nil 放行。这意味着:任何低级别管理者都可以"管理"一个不属于本产品的用户。func checkPermLevel(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, targetUserId int64, productCode string) error {
if productCode == "" {
return response.ErrBadRequest("缺少产品上下文,无法进行权限级别判定")
}
targetMember, err := svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(ctx, productCode, targetUserId)
if err != nil {
// 目标不是当前产品成员,应拒绝操作而非放行
return response.ErrForbidden("目标用户不是当前产品的成员,无法执行管理操作")
}
targetMemberType := targetMember.MemberType
// ... 后续比较逻辑不变
}
perm.go:38reflection.Register(grpcServer) 无条件开启了 gRPC Server Reflection。攻击者可使用 grpcurl 等工具发现所有 RPC 方法、请求/响应结构,大幅降低攻击门槛。GetUserPerms 无鉴权),攻击者可自动化发现并利用无保护接口。if c.Mode == "dev" {
reflection.Register(grpcServer)
}
RequireProductAdmin 未绑定具体产品(Medium)internal/logic/auth/access.go:86-98RequireProductAdmin 只检查 caller.MemberType == MemberTypeAdmin,不校验 caller.ProductCode 是否与目标操作的产品一致。虽然目前代码中大多数场景使用的是 RequireProductAdminFor(带产品校验),但如果未来有开发者误用 RequireProductAdmin,将产生跨产品越权。RequireProductAdmin 为 deprecated,或重构为必须传入 targetProductCode。UserDetail 对 ProductCode="" 的非超管跳过成员校验(Medium)internal/logic/user/userDetailLogic.go:35-38!caller.IsSuperAdmin && caller.ProductCode != "" 时才校验目标是否为本产品成员。如果运行时出现 ProductCode == "" 的非超管用户(例如直接通过 adminLogin 且未指定产品),则可读取任意用户的基础信息。ProductCode 为空时应直接拒绝访问其他用户详情。UserList 与 ProductCode 筛选逻辑不一致(Medium)internal/logic/user/userListLogic.go:55-62ProductCode,都走 FindListByPage(全量分页),而不是按产品筛选。但后续又用 ProductCode 去查 MemberType 并附加到每条记录上。用户在前端选择按产品过滤时,列表仍然是全库数据,仅是 MemberType 字段有值,与"按产品筛选"的语义不一致。ProductCode 作为筛选条件,按产品成员过滤列表。loadPerms 静默忽略数据库错误(Medium)internal/loaders/userDetailsLoader.go:373-379allowIds, _ := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(...) 忽略了错误。如果数据库查询失败,用户的 ALLOW 权限列表为空,最终计算出的权限集合会比实际少——用户会被"静默降权"而非收到错误提示。allowIds, err := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(...)
if err != nil {
logx.WithContext(ctx).Errorf("load allow perms failed: %v", err)
return // 或给 ud.Perms 设置默认空值并标记加载失败
}
internal/logic/user/createUserLogic.go:48-54、internal/logic/auth/changePasswordLogic.gointernal/svc/servicecontext.go 中 LoginRateLimit 为 60秒/IP/20次perm.sqluserId、roleId、permId、productCode 等字段关联,均无 FOREIGN KEY。如果应用层代码遗漏了级联清理(如删除产品时忘记清理成员表),会产生孤儿数据。DeleteRole 有事务内级联清理 role_perm 和 user_role;RemoveMember 有事务内清理 user_role 和 user_perm。但无删除产品和删除用户的接口,若未来添加需注意。sys_user_role 表缺少 roleId 的单独索引(Low)perm.sql 中 sys_user_role 表唯一索引为 (userId, roleId)FindUserIdsByRoleId(删除角色、更新角色时批量清除缓存用到)按 roleId 单列查询,复合索引左前缀为 userId,无法高效利用。在角色关联用户数较多时可能影响查询性能。KEY idx_role (roleId) 索引。CreateProduct 中 TOCTOU 竞态(Low)internal/logic/product/createProductLogic.go:53-56、72-75FindOneByCode 检查产品编码是否存在、再 FindOneByUsername 检查管理员用户名是否存在,最后在事务中执行插入。预检与插入之间存在时间窗口,极低概率下两个并发请求可同时通过预检,但实际上 DB 层唯一约束会兜底(返回 1062 错误),不会导致数据损坏。Duplicate entry 判断并返回友好错误信息。BindRoles 中 MinPermsLevel == 0 时绕过角色级别约束(Low)internal/logic/user/bindRolesLogic.go:78-80caller.MinPermsLevel > 0 && r.PermsLevel < caller.MinPermsLevel。当调用者的 MinPermsLevel 为 0(即无角色绑定或角色 PermsLevel 为 0)时,整个条件不生效,允许绑定任意 PermsLevel 的角色。PermsLevel=0 意味着"无限制"则合理;否则应补充边界处理。MinPermsLevel == 0 的业务语义。如果 0 不是合法值,应在 loadRoles 或角色创建时强制 PermsLevel >= 1(目前 CreateRole 已做 1-999 校验,故此问题风险较低)。internal/loaders/userDetailsLoader.go:190-207cleanByIndex 会尝试删除已不存在的键(不会报错但产生无效 DEL 命令)。反向情况:若 Clean 在数据键写入后、索引 SADD 前被调用(极低概率),则数据键不会被清理直到自然过期。internal/logic/dept/deptTreeLogic.goParentId 对应的父节点不在查询结果中(数据不一致),该节点会被当作根节点挂到 roots,而不是报错。这会掩盖数据损坏问题。ParentId != 0 但找不到父节点的情况,记录告警日志。response.Setup 中内部错误信息可能通过日志泄露(Low)internal/response/response.go:46logx.WithContext(ctx).Errorf("internal error: %+v", err) 使用 %+v 打印了完整的错误堆栈到服务端日志。虽然不会返回给客户端(客户端只收到"服务器内部错误"),但日志中可能包含 SQL 语句、表结构等敏感信息。| 维度 | 评估 |
|---|---|
| 逻辑一致性 | SetUserPerms/BindRoles 与 UpdateUserStatus/AddMember 在产品成员校验上不一致(H2/H3),是主要风险点 |
| 并发与竞态 | CreateProduct 存在 TOCTOU 但有唯一约束兜底(M9);UpdateUser 的双重写入有部分失败风险(H4) |
| 资源管理 | go-zero 框架层面管理连接池和 Redis,未发现泄漏;singleflight 有效防止缓存穿透 |
| 数据完整性 | 关键写操作使用了事务(角色删除、成员删除、权限同步);无外键依赖应用层级联(M7) |
| 安全漏洞 | gRPC GetUserPerms 无鉴权是最高优先级修复项(H1);跨产品成员添加(H2)和状态越权修改(H3)次之 |
| 边界崩溃 | 整体处理较好,FindOne 错误统一返回 ErrNotFound;loadPerms 静默忽略错误有隐患(M4) |
| SQL 注入 | 所有自定义 SQL 均使用参数化查询,LIKE 做了通配符转义,未发现注入风险 |
建议修复优先级:H1 > H2 = H3 = H5 > H4 > H6 > M1 ~ M6