# 权限管理系统代码审计报告 > **审计范围**:`perms-system-server` 项目全部生产代码(排除 `*_test.go`) > **审计时间**:2026-04-16 > **审计维度**:逻辑一致性、并发与竞态、资源管理、数据完整性、安全漏洞、边界崩溃 --- ## 🚩 核心逻辑漏洞 (High Risk) --- ### H-01:UpdateUser 允许普通用户修改自身 DeptId — 权限提升漏洞 - **位置**:`internal/logic/user/updateUserLogic.go:31-73` - **描述**:`UpdateUser` 的权限检查仅为"非超管只能改自己",但请求体中包含 `DeptId` 和 `Status` 字段。普通用户可以通过修改自己的 `DeptId` 将自己挪到一个 `DEV`(研发)类型部门下。根据 `loadPerms` 的逻辑(`userDetailsLoader.go:298`),研发部门成员自动获得当前产品的**全部权限**。 - **影响**:任意已登录用户可将自己提升为拥有全量权限的用户,完全绕过 RBAC 权限体系。 - **修复方案**: 1. 将 `DeptId` 和 `Status` 的修改权限从"自我编辑"中拆离,仅限超管或产品管理员操作; 2. 或者在 `UpdateUser` 中检查当 `callerId == req.Id` 时,禁止修改 `DeptId` 和 `Status` 字段: ```go if callerId == req.Id { if req.DeptId != nil || req.Status != 0 { return response.ErrForbidden("不允许修改自己的部门和状态") } } else { if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.Id, productCode); err != nil { return err } } ``` --- ### H-02:RefreshToken 允许跨产品切换 — 越权访问 - **位置**:`internal/logic/pub/refreshTokenLogic.go:42-45` 及 `internal/server/permserver.go:157-159` - **描述**:`RefreshToken` 接口允许请求参数中传入 `ProductCode` 来覆盖 refresh token 中原有的 `ProductCode`。这意味着用户只需获得任意一个产品的 refresh token,就能生成**另一个产品**的 access token,前提是该用户在目标产品中有成员记录。 - **影响**:跨产品越权。用户用产品 A 的 refresh token 获取产品 B 的 access token(携带产品 B 的权限),即使产品 A 已被禁用。 - **修复方案**:禁止 refresh token 更换产品上下文,或者在切换时进行严格的产品成员资格验证: ```go if productCode != "" && productCode != claims.ProductCode { // 方案1:直接拒绝 return nil, response.ErrBadRequest("不允许切换产品") // 方案2:验证目标产品成员资格 // _, err := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, productCode, claims.UserId) // if err != nil { // return nil, response.ErrForbidden("您不是该产品成员") // } } ``` --- ### H-03:BindRoles 不校验角色归属 — 跨产品角色绑定 - **位置**:`internal/logic/user/bindRolesLogic.go:42-59` - **描述**:`BindRoles` 接口直接删除用户所有角色绑定后,批量插入传入的 `RoleIds`。全过程**不校验**: 1. RoleId 是否存在; 2. RoleId 对应的角色是否属于当前操作者的产品上下文; 3. RoleId 对应的角色是否已启用。 - **影响**:管理员可将其他产品的角色绑定到用户身上,可能导致权限计算逻辑混乱。外部传入非法 RoleId 不会报错,静默写入脏数据。 - **修复方案**: ```go if len(req.RoleIds) > 0 { roles, err := l.svcCtx.SysRoleModel.FindByIds(l.ctx, req.RoleIds) if err != nil { return err } if len(roles) != len(req.RoleIds) { return response.ErrBadRequest("包含无效的角色ID") } for _, r := range roles { if r.ProductCode != productCode { return response.ErrBadRequest("不能绑定其他产品的角色") } } } ``` --- ### H-04:SetUserPerms 不校验权限 ID 和 Effect — 脏数据写入 - **位置**:`internal/logic/user/setUserPermsLogic.go:42-61` - **描述**:`SetUserPerms` 接收 `PermId` 和 `Effect` 字段,但不校验: 1. `PermId` 是否存在或是否属于当前产品; 2. `Effect` 是否为合法值(`ALLOW` / `DENY`)。 虽然数据库 `effect` 列为 `enum('ALLOW','DENY')`,非法值会被 MySQL 拒绝并返回错误,但这依赖数据库约束而非应用层防御。 - **影响**:写入无效 PermId 不报错;如果数据库 SQL Mode 不严格(如未启用 `STRICT_TRANS_TABLES`),非法 Effect 值可能被默认为空字符串静默写入。 - **修复方案**:在业务层增加校验: ```go for _, p := range req.Perms { if p.Effect != consts.PermEffectAllow && p.Effect != consts.PermEffectDeny { return response.ErrBadRequest("effect 值无效,仅支持 ALLOW 和 DENY") } } ``` --- ### H-05:SyncPerms 三步操作无事务保护 — 数据中间态 - **位置**:`internal/logic/pub/syncPermsLogic.go:80-93` 及 `internal/server/permserver.go:79-93` - **描述**:`SyncPerms` 依次执行 `BatchInsert` → `BatchUpdate` → `DisableNotInCodes` 三个独立的数据库操作,没有包裹在同一个事务中。如果 `BatchInsert` 成功但 `BatchUpdate` 或 `DisableNotInCodes` 失败,权限数据将处于不一致状态。 - **影响**: - 部分权限被插入但旧权限未被更新/禁用,导致权限表"半更新"; - 在高并发场景下,两个 SyncPerms 请求同时执行可能互相覆盖。 - **修复方案**:将三步操作包裹在事务中: ```go err = l.svcCtx.SysPermModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error { if len(toInsert) > 0 { if err := l.svcCtx.SysPermModel.BatchInsertWithTx(ctx, session, toInsert); err != nil { return err } } if len(toUpdate) > 0 { if err := l.svcCtx.SysPermModel.BatchUpdateWithTx(ctx, session, toUpdate); err != nil { return err } } // DisableNotInCodes 也需要提供 Tx 版本 return nil }) ``` --- ### H-06:SyncPerms AppSecret 明文比较 — 时序攻击风险 - **位置**:`internal/logic/pub/syncPermsLogic.go:35` 及 `internal/server/permserver.go:34` - **描述**:`product.AppSecret != req.AppSecret` 使用普通字符串比较运算符,而不是 `crypto/subtle.ConstantTimeCompare`。Go 的 `!=` 运算在发现第一个不同字节时即返回,攻击者可通过测量响应时间逐字节暴力破解 AppSecret。 - **影响**:具备网络时序测量能力的攻击者可逐步推断出完整的 AppSecret。 - **修复方案**: ```go import "crypto/subtle" if subtle.ConstantTimeCompare([]byte(product.AppSecret), []byte(req.AppSecret)) != 1 { return nil, response.ErrUnauthorized("appSecret验证失败") } ``` --- ### H-07:DeleteDept 不检查关联用户 — 孤儿数据 - **位置**:`internal/logic/dept/deleteDeptLogic.go:33-42` - **描述**:`DeleteDept` 仅检查是否有子部门,但**不检查**该部门下是否有关联用户(`sys_user.deptId`)。删除部门后,这些用户的 `deptId` 指向不存在的部门记录。 - **影响**: - `UserDetailsLoader.loadDept` 会静默失败,`DeptPath` 为空字符串; - `checkDeptHierarchy` 中 `strings.HasPrefix(targetDept.Path, caller.DeptPath)` 永远返回 true(因为所有字符串都以 "" 为前缀),导致部门隔离机制失效。 - **修复方案**: ```go userIds, _ := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id) if len(userIds) > 0 { return response.ErrBadRequest("该部门下仍有关联用户,无法删除") } ``` --- ### H-08:DeptPath 为空时部门隔离完全失效 - **位置**:`internal/logic/auth/access.go:122` - **描述**:`checkDeptHierarchy` 使用 `strings.HasPrefix(targetDept.Path, caller.DeptPath)` 判断部门归属。如果 `caller.DeptPath` 为空字符串(例如用户部门被删除、或 loadDept 失败),`HasPrefix` 始终返回 `true`,使得任何人都能"管理"任何部门的用户。 - **影响**:部门隔离机制被绕过。只要操作者的 DeptPath 加载失败(部门被删、Redis 缓存错误等),就获得跨部门管理权限。 - **修复方案**: ```go if caller.DeptPath == "" { return response.ErrForbidden("您的部门信息异常,无法执行此操作") } ``` --- ### H-09:配置文件包含明文密码和密钥 - **位置**:`etc/perm-api-prod.yaml`(及其他环境配置文件) - **描述**:生产环境配置文件中以明文存储了 MySQL 密码、Redis 密码、JWT Secret、ManagementKey 等敏感信息,且这些文件被提交到 Git 仓库。 - **影响**:任何可访问仓库的人(包括已离职员工、合作方、泄露的 Git 历史)都能获取全部生产凭据。 - **修复方案**: 1. 将敏感信息移至环境变量或密钥管理服务(如 Vault、AWS Secrets Manager); 2. 将 `etc/*.yaml` 加入 `.gitignore`,从 Git 历史中清除已提交的密码; 3. **立即轮换**已泄露的所有密码和密钥。 --- ### H-10:CreateUser 缺少密码强度校验 - **位置**:`internal/logic/user/createUserLogic.go:34-76` - **描述**:`CreateUser` 不检查密码长度和复杂度,但 `ChangePassword` 有 6-72 字符的限制。创建用户时可以设置 1 个字符甚至空字符串的密码。 - **影响**:可创建弱密码用户,容易被暴力破解。 - **修复方案**:统一密码校验逻辑: ```go if len(req.Password) < 6 { return nil, response.ErrBadRequest("密码长度不能少于6个字符") } if len(req.Password) > 72 { return nil, response.ErrBadRequest("密码长度不能超过72个字符") } ``` --- ### H-11:UserList / UserDetail / RoleList 等查询接口缺少权限过滤 - **位置**: - `internal/logic/user/userListLogic.go` — 无权限检查,查询全表 - `internal/logic/user/userDetailLogic.go` — 无权限检查,可查任意用户 - `internal/logic/role/roleListLogic.go` — 无权限检查 - `internal/logic/role/roleDetailLogic.go` — 无权限检查,可查任意产品角色 - `internal/logic/product/productListLogic.go` — 无权限检查,AppKey 泄露 - `internal/logic/product/productDetailLogic.go` — 无权限检查,AppKey 泄露 - **描述**:所有列表/详情查询接口虽然经过 JWT 认证,但不做任何业务权限过滤。任何已登录用户可以: - 查看系统中所有用户的信息(含邮箱、手机号) - 查看所有产品的 AppKey - 查看任何产品的角色和权限详情 - **影响**:敏感信息大面积泄露;违反最小权限原则。 - **修复方案**: - `UserList` 应按操作者的产品/部门作用域进行过滤; - `ProductList/ProductDetail` 的 `AppKey` 字段仅对超管可见; - `RoleDetail` 应校验操作者是否有权查看该产品的角色。 --- ## ⚠️ 健壮性与性能建议 (Medium/Low) --- ### M-01:ChangePassword / UpdateUserStatus 存在 Read-Modify-Write 竞态 - **位置**:`internal/logic/auth/changePasswordLogic.go:40-63` 及 `internal/logic/user/updateUserStatusLogic.go:41-58` - **描述**:先 `FindOne` 读取整条记录,修改某个字段后整条 `Update`。在高并发场景下,两个操作可能同时读取到相同的旧数据,后写入者覆盖先写入者的修改。 - **风险等级**:Medium(密码修改并发场景较少,但状态修改可能并发) - **建议**:对关键字段使用针对性 UPDATE 语句,而非全字段覆盖: ```sql UPDATE sys_user SET password = ?, mustChangePassword = ?, updateTime = ? WHERE id = ? ``` --- ### M-02:UserDetailsLoader 无缓存击穿防护 - **位置**:`internal/loaders/userDetailsLoader.go:94-113` - **描述**:`Load` 方法未使用 `singleflight` 或分布式锁。当缓存过期瞬间,大量并发请求会同时回源数据库执行 6 次查询(loadUser + loadDept + loadProduct + loadMembership + loadRoles + loadPerms)。 - **风险等级**:Medium - **建议**:使用 `golang.org/x/sync/singleflight` 对相同 key 的并发请求进行去重: ```go import "golang.org/x/sync/singleflight" type UserDetailsLoader struct { // ... sf singleflight.Group } func (l *UserDetailsLoader) Load(ctx context.Context, userId int64, productCode string) *UserDetails { key := l.cacheKey(userId, productCode) if val, err := l.rds.GetCtx(ctx, key); err == nil && val != "" { var ud UserDetails if err := json.Unmarshal([]byte(val), &ud); err == nil { return &ud } } v, _, _ := l.sf.Do(key, func() (interface{}, error) { return l.loadFromDB(ctx, userId, productCode), nil }) ud := v.(*UserDetails) // set cache ... return ud } ``` --- ### M-03:cleanByPattern Lua 脚本在 Redis Cluster 下不兼容 - **位置**:`internal/loaders/userDetailsLoader.go:149-169` - **描述**:`cleanByPattern` 使用 `SCAN` + `DEL` 的 Lua 脚本传入 `[]string{}` 作为 KEYS(空切片),但在 Redis Cluster 中,所有脚本操作的 key 必须位于同一个 slot,且必须通过 KEYS 参数传入。使用 `ARGV` 传递模式+SCAN 在 Cluster 环境下会被拒绝。 - **风险等级**:Medium(当前使用单节点 Redis,但未来扩展 Cluster 时会直接报错) - **建议**:改用 Go 侧循环 `SCAN` + Pipeline `DEL` 替代 Lua 脚本: ```go func (l *UserDetailsLoader) cleanByPattern(ctx context.Context, pattern string) { var cursor uint64 for { keys, cur, err := l.rds.ScanCtx(ctx, cursor, pattern, 100) if err != nil { logx.WithContext(ctx).Errorf("scan keys failed: %v", err) return } if len(keys) > 0 { if _, err := l.rds.DelCtx(ctx, keys...); err != nil { logx.WithContext(ctx).Errorf("del keys failed: %v", err) } } if cur == 0 { return } cursor = cur } } ``` --- ### M-04:gRPC Server 在 goroutine 中启动,失败不可感知 - **位置**:`perm.go:32-40` - **描述**:gRPC Server 通过 `go func()` 在后台 goroutine 中启动。如果 gRPC Server 启动失败(如端口冲突)或运行时 panic,主进程(HTTP Server)不会感知,继续以"残缺"状态运行。 - **风险等级**:Medium - **建议**:使用 `errgroup` 或 channel 同步两个 server 的生命周期: ```go errCh := make(chan error, 1) go func() { rpcServer := zrpc.MustNewServer(...) defer rpcServer.Stop() rpcServer.Start() errCh <- nil }() // 主 goroutine 也监听 errCh ``` --- ### M-05:gen 代码修改包级全局变量 — 初始化竞态风险 - **位置**:所有 `*_gen.go` 文件中的 `newSys*Model` 函数(如 `sysPermModel_gen.go:70-71`、`sysUserModel_gen.go:77-78`) - **描述**:`newSysPermModel` 等函数直接修改包级别的 `cacheSysPermIdPrefix` 等全局变量。虽然当前 `NewModels` 在启动时仅调用一次,但这种模式不是并发安全的——如果将来在测试或多实例场景下并发初始化,会发生数据竞争。 - **风险等级**:Low(当前安全,但代码气味不好) - **建议**:将 cache prefix 存储为实例字段而非包级变量(需修改代码生成模板)。 --- ### M-06:MemberType 缺少应用层白名单校验 - **位置**: - `internal/logic/member/addMemberLogic.go:31` — `req.MemberType` 无校验 - `internal/logic/member/updateMemberLogic.go:30` — `req.MemberType` 无校验 - **描述**:`MemberType` 字段完全依赖数据库 `enum('DEVELOPER','ADMIN','MEMBER')` 约束来限制合法值,应用层不做白名单校验。 - **风险等级**:Medium - **建议**: ```go validTypes := map[string]bool{ consts.MemberTypeAdmin: true, consts.MemberTypeDeveloper: true, consts.MemberTypeMember: true, } if !validTypes[req.MemberType] { return nil, response.ErrBadRequest("无效的成员类型") } ``` --- ### M-07:generateRandomHex 忽略 crypto/rand.Read 错误 - **位置**:`internal/logic/product/createProductLogic.go:117-121` - **描述**:`rand.Read(b)` 的返回错误被忽略。虽然 `crypto/rand` 在主流系统上几乎不会失败,但在某些极端环境(如容器中 `/dev/urandom` 不可用)下可能返回错误,此时 `b` 包含零值或不完整的随机数据。 - **风险等级**:Low - **建议**: ```go func generateRandomHex(length int) (string, error) { b := make([]byte, length) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("generate random bytes failed: %w", err) } return hex.EncodeToString(b)[:length], nil } ``` --- ### M-08:DeptTree 全表加载无上限限制 - **位置**:`internal/logic/dept/deptTreeLogic.go:27` - **描述**:`DeptTree` 使用 `FindAll` 一次性加载所有部门数据到内存中构建树。如果部门数量增长到数万级别,会导致内存占用激增和响应延迟。 - **风险等级**:Low(当前数据量小) - **建议**:考虑添加分页或根据 `parentId` 按需加载子树。 --- ### M-09:SyncPerms 缺乏去重校验 - **位置**:`internal/logic/pub/syncPermsLogic.go:54-78` - **描述**:如果请求中的 `Perms` 数组包含重复的 `Code`,后出现的会覆盖前出现的在 `existingMap` 中的查询结果,但 `toInsert` 中可能包含重复条目,导致 `BatchInsert` 时触发唯一键冲突。 - **风险等级**:Low - **建议**:在处理前对 `codes` 去重。 --- ### M-10:UpdateDept 修改 deptType 后未级联刷新子部门用户缓存 - **位置**:`internal/logic/dept/updateDeptLogic.go:43-58` - **描述**:`UpdateDept` 仅清除该部门直接关联用户的缓存。如果修改了 `deptType`(如 `DEV` → `NORMAL`),子部门的用户权限可能因为父部门类型变更而需要重新计算,但不会被刷新。 - **风险等级**:Medium(依赖于部门类型是否影响子部门的权限逻辑) - **建议**:当 `deptType` 变更时,清除所有子部门用户的缓存。 --- ### M-11:Login/AdminLogin 无暴力破解防护 - **位置**:`internal/logic/pub/loginLogic.go`、`internal/logic/pub/adminLoginLogic.go` - **描述**:登录接口没有速率限制、验证码或账号锁定机制。攻击者可无限次尝试暴力破解密码。`AdminLogin` 的 `ManagementKey` 也是静态值,无速率限制保护。 - **风险等级**:Medium - **建议**: 1. 接入 go-zero 的 `PeriodLimit` 或 Redis 滑动窗口限流; 2. 连续失败 N 次后临时锁定账号或要求验证码。 --- ### M-12:ProductList 和 ProductDetail 泄露 AppKey - **位置**:`internal/logic/product/productListLogic.go:37` 及 `internal/logic/product/productDetailLogic.go:33` - **描述**:`ProductItem` 响应体包含 `AppKey` 字段,任何已登录用户均可获取。`AppKey` 是产品的接入标识,泄露后配合时序攻击(H-06)有助于暴力破解 `AppSecret`。 - **风险等级**:Medium - **建议**:`AppKey` 仅对超管可见,或在列表接口中排除该字段。 --- ### M-13:BindRoles 事务后的缓存清理仅删除单个产品维度 - **位置**:`internal/logic/user/bindRolesLogic.go:64` - **描述**:`BindRoles` 先删除用户全部角色绑定(不限产品),再插入新角色。但缓存清理只 `Del` 了当前操作者的 `productCode` 维度。如果用户在多个产品中都有角色绑定,其他产品的缓存不会被清除,导致缓存与 DB 不一致。 - **风险等级**:Medium - **建议**:使用 `Clean`(通配删除所有产品缓存)代替 `Del`: ```go l.svcCtx.UserDetailsLoader.Clean(l.ctx, req.UserId) ``` --- ### M-14:CreateProduct 响应返回明文密码和 AppSecret - **位置**:`internal/logic/product/createProductLogic.go:107-115` - **描述**:`CreateProductResp` 中包含 `AdminPassword`(明文)和 `AppSecret`。如果响应被日志框架记录或被中间件拦截,这些敏感信息会出现在日志中。 - **风险等级**:Medium - **建议**:确保该接口的响应不被 access log 或审计日志完整记录;或者改为只生成一次性查看链接。 --- ### L-01:Response 统一返回 HTTP 200 — 不利于监控 - **位置**:`internal/response/response.go:44-50` - **描述**:所有业务错误(401、403、404 等)的 HTTP 状态码均返回 `200`,仅在 body 中的 `code` 字段区分。这导致 HTTP 监控(如 Nginx、ALB 的 5xx 告警)无法发现业务异常。 - **风险等级**:Low - **建议**:视项目规范决定是否保持,但至少应确保 APM 层能抓取 body 中的 code 字段做告警。 --- ### L-02:FindListByDeptIds 的 append 可能修改原 slice - **位置**:`internal/model/user/sysUserModel.go:69` - **描述**:`pageArgs := append(args, ...)` 如果 `args` 底层数组容量足够,会修改 `args` 的底层数据。虽然 `args` 在此处之后不再使用,但这是一个潜在的陷阱。 - **风险等级**:Low - **建议**:使用显式拷贝: ```go pageArgs := make([]interface{}, len(args), len(args)+2) copy(pageArgs, args) pageArgs = append(pageArgs, (page-1)*pageSize, pageSize) ``` --- ### L-03:权限计算逻辑在两处重复(loadPerms 与 GetUserPerms) - **位置**:`internal/loaders/userDetailsLoader.go:289-357` 与 `internal/logic/auth/perms.go:11-115` - **描述**:权限计算逻辑(角色权限 + 用户 allow - 用户 deny)在 `UserDetailsLoader.loadPerms` 和 `GetUserPerms` 中各实现了一遍。逻辑高度相似但不完全相同(如 `loadPerms` 多了 `DeptType == DEV` 的判断),容易在后续维护中出现不一致。 - **风险等级**:Low - **建议**:将权限计算逻辑抽取为单一函数,两处共用。 --- ## 📋 审计总结 | 等级 | 数量 | 关键发现 | | :----- | :------ | :---------- | | 🚩 High | 11 | 权限提升、跨产品越权、数据完整性缺陷、敏感信息泄露 | | ⚠️ Medium | 14 | 竞态条件、缓存不一致、缺少暴力破解防护、输入校验不足 | | 📝 Low | 3 | 代码重复、HTTP 状态码规范、slice append 陷阱 |