audit-report.md 17 KB

权限系统 (perms-system-server) 代码审计报告

审计范围:internal/ 下所有非测试 .go 文件、perm.sqlperm.apipb/ gRPC 服务
审计日期:2026-04-18
审计维度:逻辑一致性、并发竞态、资源管理、数据完整性、安全漏洞、边界崩溃


🚩 核心逻辑漏洞 (High Risk)

H1. gRPC GetUserPerms 接口无任何鉴权

  • 位置internal/server/permserver.go:186-197
  • 描述GetUserPerms 方法直接调用 UserDetailsLoader.Load(ctx, req.UserId, req.ProductCode),没有任何身份校验逻辑——不要求 JWT、不校验调用方服务身份、不验证 mTLS。任何能连接 gRPC 端口的客户端,只需知道 userIdproductCode 即可获取该用户的 MemberType 与完整权限列表。
  • 影响:攻击者可枚举用户 ID 和产品编码,批量拉取所有用户的权限数据,属于严重信息泄露与越权。
  • 修复方案
    • 方案 A:为 gRPC 服务增加 Interceptor,校验调用方携带的内部服务 Token(如 gRPC Metadata 中传递一个共享密钥或签名)。
    • 方案 B:部署层面使用 mTLS,确保只有可信服务节点能连接 gRPC 端口。
    • 方案 C:将 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, "未授权的调用方")
    }
    // ... 原有逻辑
}

H2. AddMember 缺乏产品归属校验,可跨产品添加成员

  • 位置internal/logic/member/addMemberLogic.go:46
  • 描述AddMember 使用 CheckManageAccess(ctx, svcCtx, req.UserId, req.ProductCode) 进行鉴权。然而 CheckManageAccessaccess.go:47)对"操作自己"无条件放行(caller.UserId == targetUserId → return nil),不校验 req.ProductCode 是否等于 caller.ProductCode。这意味着:
    1. 用户 A(产品 X 的 ADMIN)可以将自己添加为产品 Y 的成员(只要 req.UserId == caller.UserId)。
    2. 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
    }
    // ... 其余逻辑保持不变
}

H3. UpdateUserStatus 缺少"目标用户属于当前产品"的校验

  • 位置internal/logic/user/updateUserStatusLogic.go:32-60
  • 描述UpdateUserStatus 仅调用 CheckManageAccess 校验管理权限,没有SetUserPerms / BindRoles 那样先验证 SysProductMemberModel.FindOneByProductCodeUserId(目标用户必须是当前产品的成员)。而 CheckManageAccess 中的 checkPermLevel 在目标无成员记录时,targetMemberType=""memberTypePriority=MaxInt32callerPri < targetPri直接放行
  • 影响:产品 A 的 ADMIN 可以修改不属于产品 A 的全局用户的账号状态(启用/冻结),因为 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
    }
    // ...
}

H4. UpdateUserUpdate + UpdateStatus 非原子操作

  • 位置internal/logic/user/updateUserLogic.go:111-118
  • 描述:当修改用户信息且包含状态变更时,代码先调用 SysUserModel.Update(ctx, user) 更新所有字段(包括 Status),若状态确实变更又额外调用 SysUserModel.UpdateStatus(ctx, req.Id, req.Status)。这两次写操作没有在同一事务中:
    1. 第一次 Update 已经将 Status 写入了 DB,第二次 UpdateStatus 是冗余的(其主要目的是递增 tokenVersion 使旧 token 失效)。
    2. 如果第一次 Update 成功但第二次 UpdateStatus 失败,用户状态已被冻结但 tokenVersion 未递增,导致用户的旧 token 仍然有效——被冻结的用户仍可继续访问系统直到 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
    }
}

H5. checkPermLevel 对非产品成员目标默认放行

  • 位置internal/logic/auth/access.go:147-177
  • 描述:当目标用户不是当前产品的成员时,FindOneByProductCodeUserId 返回错误,targetMemberType 保持为空字符串 ""memberTypePriority("") 返回 math.MaxInt32(即优先级最低)。接下来比较 callerPri < targetPri(例如 ADMIN 的 1 < MaxInt32)→ 直接 return nil 放行。这意味着:任何低级别管理者都可以"管理"一个不属于本产品的用户。
  • 影响:与 H3 联动,扩大了越权操作的范围。非产品成员的目标被视为"权限最低的人",反而最容易被操作。
  • 修复方案:当目标用户不是当前产品成员时,应拒绝而非放行:
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
    // ... 后续比较逻辑不变
}

H6. gRPC Reflection 在生产环境中开启

  • 位置perm.go:38
  • 描述reflection.Register(grpcServer) 无条件开启了 gRPC Server Reflection。攻击者可使用 grpcurl 等工具发现所有 RPC 方法、请求/响应结构,大幅降低攻击门槛。
  • 影响:结合 H1(GetUserPerms 无鉴权),攻击者可自动化发现并利用无保护接口。
  • 修复方案:仅在开发/测试环境开启反射:
if c.Mode == "dev" {
    reflection.Register(grpcServer)
}

⚠️ 健壮性与性能建议 (Medium / Low)

M1. RequireProductAdmin 未绑定具体产品(Medium)

  • 位置internal/logic/auth/access.go:86-98
  • 描述RequireProductAdmin 只检查 caller.MemberType == MemberTypeAdmin不校验 caller.ProductCode 是否与目标操作的产品一致。虽然目前代码中大多数场景使用的是 RequireProductAdminFor(带产品校验),但如果未来有开发者误用 RequireProductAdmin,将产生跨产品越权。
  • 建议:标记 RequireProductAdmin 为 deprecated,或重构为必须传入 targetProductCode

M2. UserDetailProductCode="" 的非超管跳过成员校验(Medium)

  • 位置internal/logic/user/userDetailLogic.go:35-38
  • 描述:仅当 !caller.IsSuperAdmin && caller.ProductCode != "" 时才校验目标是否为本产品成员。如果运行时出现 ProductCode == "" 的非超管用户(例如直接通过 adminLogin 且未指定产品),则可读取任意用户的基础信息。
  • 建议:非超管用户在 ProductCode 为空时应直接拒绝访问其他用户详情。

M3. 超管 UserListProductCode 筛选逻辑不一致(Medium)

  • 位置internal/logic/user/userListLogic.go:55-62
  • 描述:超管无论是否传了 ProductCode,都走 FindListByPage(全量分页),而不是按产品筛选。但后续又用 ProductCode 去查 MemberType 并附加到每条记录上。用户在前端选择按产品过滤时,列表仍然是全库数据,仅是 MemberType 字段有值,与"按产品筛选"的语义不一致
  • 建议:超管也应支持 ProductCode 作为筛选条件,按产品成员过滤列表。

M4. loadPerms 静默忽略数据库错误(Medium)

  • 位置internal/loaders/userDetailsLoader.go:373-379
  • 描述allowIds, _ := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(...) 忽略了错误。如果数据库查询失败,用户的 ALLOW 权限列表为空,最终计算出的权限集合会比实际少——用户会被"静默降权"而非收到错误提示。
  • 建议:DB 错误应向上传递或记录日志并返回错误,避免静默降权:
allowIds, err := l.models.SysUserPermModel.FindPermIdsByUserIdAndEffectForProduct(...)
if err != nil {
    logx.WithContext(ctx).Errorf("load allow perms failed: %v", err)
    return // 或给 ud.Perms 设置默认空值并标记加载失败
}

M5. 密码策略偏弱(Medium)

  • 位置internal/logic/user/createUserLogic.go:48-54internal/logic/auth/changePasswordLogic.go
  • 描述:密码最低长度仅 6 字符,无大小写混合、数字、特殊字符等复杂度要求。对于后台管理系统,安全性偏弱。
  • 建议:至少增加「包含大小写字母和数字」要求,或将最低长度提高至 8 位。

M6. 登录仅 IP 维度限流,无账户级锁定(Medium)

  • 位置internal/svc/servicecontext.goLoginRateLimit 为 60秒/IP/20次
  • 描述:限流仅基于 IP,攻击者可通过分布式 IP 绕过。对同一用户名的暴力破解尝试没有账户级锁定机制。
  • 建议:增加按用户名维度的登录失败计数,连续 N 次失败后临时锁定账户或要求验证码。

M7. 数据库无外键约束,完全依赖应用层维护引用完整性(Low)

  • 位置perm.sql
  • 描述:所有表之间通过 userIdroleIdpermIdproductCode 等字段关联,均无 FOREIGN KEY。如果应用层代码遗漏了级联清理(如删除产品时忘记清理成员表),会产生孤儿数据。
  • 当前状态DeleteRole 有事务内级联清理 role_permuser_roleRemoveMember 有事务内清理 user_roleuser_perm。但无删除产品删除用户的接口,若未来添加需注意。
  • 建议:至少通过定期数据校验脚本排查孤儿行,或在关键关联字段上加外键。

M8. sys_user_role 表缺少 roleId 的单独索引(Low)

  • 位置perm.sqlsys_user_role 表唯一索引为 (userId, roleId)
  • 描述FindUserIdsByRoleId(删除角色、更新角色时批量清除缓存用到)按 roleId 单列查询,复合索引左前缀为 userId,无法高效利用。在角色关联用户数较多时可能影响查询性能。
  • 建议:增加 KEY idx_role (roleId) 索引。

M9. CreateProduct 中 TOCTOU 竞态(Low)

  • 位置internal/logic/product/createProductLogic.go:53-5672-75
  • 描述:先 FindOneByCode 检查产品编码是否存在、再 FindOneByUsername 检查管理员用户名是否存在,最后在事务中执行插入。预检与插入之间存在时间窗口,极低概率下两个并发请求可同时通过预检,但实际上 DB 层唯一约束会兜底(返回 1062 错误),不会导致数据损坏。
  • 影响:极低风险,唯一约束可兜底,但错误信息可能不够友好(返回原始 DB 错误而非"产品编码已存在")。
  • 建议:在事务内的 Insert 错误中也做 Duplicate entry 判断并返回友好错误信息。

M10. BindRolesMinPermsLevel == 0 时绕过角色级别约束(Low)

  • 位置internal/logic/user/bindRolesLogic.go:78-80
  • 描述:约束条件为 caller.MinPermsLevel > 0 && r.PermsLevel < caller.MinPermsLevel。当调用者的 MinPermsLevel 为 0(即无角色绑定或角色 PermsLevel 为 0)时,整个条件不生效,允许绑定任意 PermsLevel 的角色。
  • 影响:取决于业务设计——如果 PermsLevel=0 意味着"无限制"则合理;否则应补充边界处理。
  • 建议:明确 MinPermsLevel == 0 的业务语义。如果 0 不是合法值,应在 loadRoles 或角色创建时强制 PermsLevel >= 1(目前 CreateRole 已做 1-999 校验,故此问题风险较低)。

M11. Redis 缓存索引集合与数据键 TTL 不同步(Low)

  • 位置internal/loaders/userDetailsLoader.go:190-207
  • 描述:缓存数据键 TTL 为 300s,索引集合 TTL 为 360s。在 300-360s 之间的时间窗口内,索引集合中引用的数据键已过期但索引还在,cleanByIndex 会尝试删除已不存在的键(不会报错但产生无效 DEL 命令)。反向情况:若 Clean 在数据键写入后、索引 SADD 前被调用(极低概率),则数据键不会被清理直到自然过期。
  • 影响:极低风险,不影响正确性,仅可能导致少量无效 Redis 操作。

M12. 部门树对异常数据的静默处理(Low)

  • 位置internal/logic/dept/deptTreeLogic.go
  • 描述:构建部门树时,如果子节点的 ParentId 对应的父节点不在查询结果中(数据不一致),该节点会被当作根节点挂到 roots,而不是报错。这会掩盖数据损坏问题。
  • 建议:对 ParentId != 0 但找不到父节点的情况,记录告警日志。

M13. response.Setup 中内部错误信息可能通过日志泄露(Low)

  • 位置internal/response/response.go:46
  • 描述logx.WithContext(ctx).Errorf("internal error: %+v", err) 使用 %+v 打印了完整的错误堆栈到服务端日志。虽然不会返回给客户端(客户端只收到"服务器内部错误"),但日志中可能包含 SQL 语句、表结构等敏感信息。
  • 建议:确保日志收集系统有适当的访问控制;考虑对 SQL 相关错误做脱敏处理。

审计总结

维度 评估
逻辑一致性 SetUserPerms/BindRolesUpdateUserStatus/AddMember 在产品成员校验上不一致(H2/H3),是主要风险点
并发与竞态 CreateProduct 存在 TOCTOU 但有唯一约束兜底(M9);UpdateUser 的双重写入有部分失败风险(H4)
资源管理 go-zero 框架层面管理连接池和 Redis,未发现泄漏;singleflight 有效防止缓存穿透
数据完整性 关键写操作使用了事务(角色删除、成员删除、权限同步);无外键依赖应用层级联(M7)
安全漏洞 gRPC GetUserPerms 无鉴权是最高优先级修复项(H1);跨产品成员添加(H2)和状态越权修改(H3)次之
边界崩溃 整体处理较好,FindOne 错误统一返回 ErrNotFoundloadPerms 静默忽略错误有隐患(M4)
SQL 注入 所有自定义 SQL 均使用参数化查询,LIKE 做了通配符转义,未发现注入风险

建议修复优先级:H1 > H2 = H3 = H5 > H4 > H6 > M1 ~ M6