# 权限系统 (perms-system-server) 代码审计报告 > 审计范围:`internal/` 下所有非测试 `.go` 文件、`perm.sql`、`perm.api`、`pb/` 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 端口的客户端,只需知道 `userId` 和 `productCode` 即可获取该用户的 `MemberType` 与完整权限列表。 - **影响**:攻击者可枚举用户 ID 和产品编码,批量拉取所有用户的权限数据,属于严重信息泄露与越权。 - **修复方案**: - 方案 A:为 gRPC 服务增加 Interceptor,校验调用方携带的内部服务 Token(如 gRPC Metadata 中传递一个共享密钥或签名)。 - 方案 B:部署层面使用 mTLS,确保只有可信服务节点能连接 gRPC 端口。 - 方案 C:将 `GetUserPerms` 改为要求传入一个有效的 AccessToken,在方法内部验证 token 后,仅返回该 token 对应用户的权限。 ```go // 建议在 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)` 进行鉴权。然而 `CheckManageAccess`(`access.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` 中增加显式的产品归属校验: ```go 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=MaxInt32` → `callerPri < targetPri` → **直接放行**。 - **影响**:产品 A 的 ADMIN 可以修改**不属于产品 A 的全局用户**的账号状态(启用/冻结),因为 `SysUser.Status` 是全局字段,冻结后影响用户在**所有产品**下的登录。 - **修复方案**:增加产品成员校验,与 `SetUserPerms` / `BindRoles` 保持一致: ```go 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. `UpdateUser` 中 `Update` + `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` 递增逻辑): ```go // 方案:移除冗余的双重写入,在 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 联动,扩大了越权操作的范围。非产品成员的目标被视为"权限最低的人",反而最容易被操作。 - **修复方案**:当目标用户不是当前产品成员时,应拒绝而非放行: ```go 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` 无鉴权),攻击者可自动化发现并利用无保护接口。 - **修复方案**:仅在开发/测试环境开启反射: ```go 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. `UserDetail` 对 `ProductCode=""` 的非超管跳过成员校验(Medium) - **位置**:`internal/logic/user/userDetailLogic.go:35-38` - **描述**:仅当 `!caller.IsSuperAdmin && caller.ProductCode != ""` 时才校验目标是否为本产品成员。如果运行时出现 `ProductCode == ""` 的非超管用户(例如直接通过 adminLogin 且未指定产品),则可读取任意用户的基础信息。 - **建议**:非超管用户在 `ProductCode` 为空时应直接拒绝访问其他用户详情。 --- ### M3. 超管 `UserList` 与 `ProductCode` 筛选逻辑不一致(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 错误应向上传递或记录日志并返回错误,避免静默降权: ```go 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-54`、`internal/logic/auth/changePasswordLogic.go` - **描述**:密码最低长度仅 6 字符,无大小写混合、数字、特殊字符等复杂度要求。对于后台管理系统,安全性偏弱。 - **建议**:至少增加「包含大小写字母和数字」要求,或将最低长度提高至 8 位。 --- ### M6. 登录仅 IP 维度限流,无账户级锁定(Medium) - **位置**:`internal/svc/servicecontext.go` 中 `LoginRateLimit` 为 60秒/IP/20次 - **描述**:限流仅基于 IP,攻击者可通过分布式 IP 绕过。对同一用户名的暴力破解尝试没有账户级锁定机制。 - **建议**:增加按用户名维度的登录失败计数,连续 N 次失败后临时锁定账户或要求验证码。 --- ### M7. 数据库无外键约束,完全依赖应用层维护引用完整性(Low) - **位置**:`perm.sql` - **描述**:所有表之间通过 `userId`、`roleId`、`permId`、`productCode` 等字段关联,均**无 FOREIGN KEY**。如果应用层代码遗漏了级联清理(如删除产品时忘记清理成员表),会产生孤儿数据。 - **当前状态**:`DeleteRole` 有事务内级联清理 `role_perm` 和 `user_role`;`RemoveMember` 有事务内清理 `user_role` 和 `user_perm`。但**无删除产品**和**删除用户**的接口,若未来添加需注意。 - **建议**:至少通过定期数据校验脚本排查孤儿行,或在关键关联字段上加外键。 --- ### M8. `sys_user_role` 表缺少 `roleId` 的单独索引(Low) - **位置**:`perm.sql` 中 `sys_user_role` 表唯一索引为 `(userId, roleId)` - **描述**:`FindUserIdsByRoleId`(删除角色、更新角色时批量清除缓存用到)按 `roleId` 单列查询,复合索引左前缀为 `userId`,无法高效利用。在角色关联用户数较多时可能影响查询性能。 - **建议**:增加 `KEY idx_role (roleId)` 索引。 --- ### M9. `CreateProduct` 中 TOCTOU 竞态(Low) - **位置**:`internal/logic/product/createProductLogic.go:53-56`、`72-75` - **描述**:先 `FindOneByCode` 检查产品编码是否存在、再 `FindOneByUsername` 检查管理员用户名是否存在,最后在事务中执行插入。预检与插入之间存在时间窗口,极低概率下两个并发请求可同时通过预检,但实际上 DB 层唯一约束会兜底(返回 1062 错误),不会导致数据损坏。 - **影响**:极低风险,唯一约束可兜底,但错误信息可能不够友好(返回原始 DB 错误而非"产品编码已存在")。 - **建议**:在事务内的 Insert 错误中也做 `Duplicate entry` 判断并返回友好错误信息。 --- ### M10. `BindRoles` 中 `MinPermsLevel == 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`/`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