audit-report.md 22 KB

权限管理系统代码审计报告

审计范围perms-system-server 项目全部生产代码(排除 *_test.go
审计时间:2026-04-16
审计维度:逻辑一致性、并发与竞态、资源管理、数据完整性、安全漏洞、边界崩溃


🚩 核心逻辑漏洞 (High Risk)


H-01:UpdateUser 允许普通用户修改自身 DeptId — 权限提升漏洞

  • 位置internal/logic/user/updateUserLogic.go:31-73
  • 描述UpdateUser 的权限检查仅为"非超管只能改自己",但请求体中包含 DeptIdStatus 字段。普通用户可以通过修改自己的 DeptId 将自己挪到一个 DEV(研发)类型部门下。根据 loadPerms 的逻辑(userDetailsLoader.go:298),研发部门成员自动获得当前产品的全部权限
  • 影响:任意已登录用户可将自己提升为拥有全量权限的用户,完全绕过 RBAC 权限体系。
  • 修复方案
    1. DeptIdStatus 的修改权限从"自我编辑"中拆离,仅限超管或产品管理员操作;
    2. 或者在 UpdateUser 中检查当 callerId == req.Id 时,禁止修改 DeptIdStatus 字段:
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-45internal/server/permserver.go:157-159
  • 描述RefreshToken 接口允许请求参数中传入 ProductCode 来覆盖 refresh token 中原有的 ProductCode。这意味着用户只需获得任意一个产品的 refresh token,就能生成另一个产品的 access token,前提是该用户在目标产品中有成员记录。
  • 影响:跨产品越权。用户用产品 A 的 refresh token 获取产品 B 的 access token(携带产品 B 的权限),即使产品 A 已被禁用。
  • 修复方案:禁止 refresh token 更换产品上下文,或者在切换时进行严格的产品成员资格验证:
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 不会报错,静默写入脏数据。
  • 修复方案
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 接收 PermIdEffect 字段,但不校验:
    1. PermId 是否存在或是否属于当前产品;
    2. Effect 是否为合法值(ALLOW / DENY)。 虽然数据库 effect 列为 enum('ALLOW','DENY'),非法值会被 MySQL 拒绝并返回错误,但这依赖数据库约束而非应用层防御。
  • 影响:写入无效 PermId 不报错;如果数据库 SQL Mode 不严格(如未启用 STRICT_TRANS_TABLES),非法 Effect 值可能被默认为空字符串静默写入。
  • 修复方案:在业务层增加校验:
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-93internal/server/permserver.go:79-93
  • 描述SyncPerms 依次执行 BatchInsertBatchUpdateDisableNotInCodes 三个独立的数据库操作,没有包裹在同一个事务中。如果 BatchInsert 成功但 BatchUpdateDisableNotInCodes 失败,权限数据将处于不一致状态。
  • 影响
    • 部分权限被插入但旧权限未被更新/禁用,导致权限表"半更新";
    • 在高并发场景下,两个 SyncPerms 请求同时执行可能互相覆盖。
  • 修复方案:将三步操作包裹在事务中:
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:35internal/server/permserver.go:34
  • 描述product.AppSecret != req.AppSecret 使用普通字符串比较运算符,而不是 crypto/subtle.ConstantTimeCompare。Go 的 != 运算在发现第一个不同字节时即返回,攻击者可通过测量响应时间逐字节暴力破解 AppSecret。
  • 影响:具备网络时序测量能力的攻击者可逐步推断出完整的 AppSecret。
  • 修复方案
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 为空字符串;
    • checkDeptHierarchystrings.HasPrefix(targetDept.Path, caller.DeptPath) 永远返回 true(因为所有字符串都以 "" 为前缀),导致部门隔离机制失效。
  • 修复方案
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 缓存错误等),就获得跨部门管理权限。
  • 修复方案
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 个字符甚至空字符串的密码。
  • 影响:可创建弱密码用户,容易被暴力破解。
  • 修复方案:统一密码校验逻辑:
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/ProductDetailAppKey 字段仅对超管可见;
    • RoleDetail 应校验操作者是否有权查看该产品的角色。

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


M-01:ChangePassword / UpdateUserStatus 存在 Read-Modify-Write 竞态

  • 位置internal/logic/auth/changePasswordLogic.go:40-63internal/logic/user/updateUserStatusLogic.go:41-58
  • 描述:先 FindOne 读取整条记录,修改某个字段后整条 Update。在高并发场景下,两个操作可能同时读取到相同的旧数据,后写入者覆盖先写入者的修改。
  • 风险等级:Medium(密码修改并发场景较少,但状态修改可能并发)
  • 建议:对关键字段使用针对性 UPDATE 语句,而非全字段覆盖:
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 的并发请求进行去重:
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 脚本:
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 的生命周期:
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-71sysUserModel_gen.go:77-78
  • 描述newSysPermModel 等函数直接修改包级别的 cacheSysPermIdPrefix 等全局变量。虽然当前 NewModels 在启动时仅调用一次,但这种模式不是并发安全的——如果将来在测试或多实例场景下并发初始化,会发生数据竞争。
  • 风险等级:Low(当前安全,但代码气味不好)
  • 建议:将 cache prefix 存储为实例字段而非包级变量(需修改代码生成模板)。

M-06:MemberType 缺少应用层白名单校验

  • 位置
    • internal/logic/member/addMemberLogic.go:31req.MemberType 无校验
    • internal/logic/member/updateMemberLogic.go:30req.MemberType 无校验
  • 描述MemberType 字段完全依赖数据库 enum('DEVELOPER','ADMIN','MEMBER') 约束来限制合法值,应用层不做白名单校验。
  • 风险等级:Medium
  • 建议
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
  • 建议
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(如 DEVNORMAL),子部门的用户权限可能因为父部门类型变更而需要重新计算,但不会被刷新。
  • 风险等级:Medium(依赖于部门类型是否影响子部门的权限逻辑)
  • 建议:当 deptType 变更时,清除所有子部门用户的缓存。

M-11:Login/AdminLogin 无暴力破解防护

  • 位置internal/logic/pub/loginLogic.gointernal/logic/pub/adminLoginLogic.go
  • 描述:登录接口没有速率限制、验证码或账号锁定机制。攻击者可无限次尝试暴力破解密码。AdminLoginManagementKey 也是静态值,无速率限制保护。
  • 风险等级:Medium
  • 建议
    1. 接入 go-zero 的 PeriodLimit 或 Redis 滑动窗口限流;
    2. 连续失败 N 次后临时锁定账号或要求验证码。

M-12:ProductList 和 ProductDetail 泄露 AppKey

  • 位置internal/logic/product/productListLogic.go:37internal/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
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
  • 建议:使用显式拷贝:
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-357internal/logic/auth/perms.go:11-115
  • 描述:权限计算逻辑(角色权限 + 用户 allow - 用户 deny)在 UserDetailsLoader.loadPermsGetUserPerms 中各实现了一遍。逻辑高度相似但不完全相同(如 loadPerms 多了 DeptType == DEV 的判断),容易在后续维护中出现不一致。
  • 风险等级:Low
  • 建议:将权限计算逻辑抽取为单一函数,两处共用。

📋 审计总结

等级 数量 关键发现
🚩 High 11 权限提升、跨产品越权、数据完整性缺陷、敏感信息泄露
⚠️ Medium 14 竞态条件、缓存不一致、缺少暴力破解防护、输入校验不足
📝 Low 3 代码重复、HTTP 状态码规范、slice append 陷阱