审计范围:
perms-system-server项目全部生产代码(排除*_test.go)
审计时间:2026-04-16
审计维度:逻辑一致性、并发与竞态、资源管理、数据完整性、安全漏洞、边界崩溃
internal/logic/user/updateUserLogic.go:31-73UpdateUser 的权限检查仅为"非超管只能改自己",但请求体中包含 DeptId 和 Status 字段。普通用户可以通过修改自己的 DeptId 将自己挪到一个 DEV(研发)类型部门下。根据 loadPerms 的逻辑(userDetailsLoader.go:298),研发部门成员自动获得当前产品的全部权限。DeptId 和 Status 的修改权限从"自我编辑"中拆离,仅限超管或产品管理员操作;UpdateUser 中检查当 callerId == req.Id 时,禁止修改 DeptId 和 Status 字段: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
}
}
internal/logic/pub/refreshTokenLogic.go:42-45 及 internal/server/permserver.go:157-159RefreshToken 接口允许请求参数中传入 ProductCode 来覆盖 refresh token 中原有的 ProductCode。这意味着用户只需获得任意一个产品的 refresh token,就能生成另一个产品的 access 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("您不是该产品成员")
// }
}
internal/logic/user/bindRolesLogic.go:42-59BindRoles 接口直接删除用户所有角色绑定后,批量插入传入的 RoleIds。全过程不校验:
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("不能绑定其他产品的角色")
}
}
}
internal/logic/user/setUserPermsLogic.go:42-61SetUserPerms 接收 PermId 和 Effect 字段,但不校验:
PermId 是否存在或是否属于当前产品;Effect 是否为合法值(ALLOW / DENY)。
虽然数据库 effect 列为 enum('ALLOW','DENY'),非法值会被 MySQL 拒绝并返回错误,但这依赖数据库约束而非应用层防御。STRICT_TRANS_TABLES),非法 Effect 值可能被默认为空字符串静默写入。for _, p := range req.Perms {
if p.Effect != consts.PermEffectAllow && p.Effect != consts.PermEffectDeny {
return response.ErrBadRequest("effect 值无效,仅支持 ALLOW 和 DENY")
}
}
internal/logic/pub/syncPermsLogic.go:80-93 及 internal/server/permserver.go:79-93SyncPerms 依次执行 BatchInsert → BatchUpdate → DisableNotInCodes 三个独立的数据库操作,没有包裹在同一个事务中。如果 BatchInsert 成功但 BatchUpdate 或 DisableNotInCodes 失败,权限数据将处于不一致状态。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
})
internal/logic/pub/syncPermsLogic.go:35 及 internal/server/permserver.go:34product.AppSecret != req.AppSecret 使用普通字符串比较运算符,而不是 crypto/subtle.ConstantTimeCompare。Go 的 != 运算在发现第一个不同字节时即返回,攻击者可通过测量响应时间逐字节暴力破解 AppSecret。import "crypto/subtle"
if subtle.ConstantTimeCompare([]byte(product.AppSecret), []byte(req.AppSecret)) != 1 {
return nil, response.ErrUnauthorized("appSecret验证失败")
}
internal/logic/dept/deleteDeptLogic.go:33-42DeleteDept 仅检查是否有子部门,但不检查该部门下是否有关联用户(sys_user.deptId)。删除部门后,这些用户的 deptId 指向不存在的部门记录。UserDetailsLoader.loadDept 会静默失败,DeptPath 为空字符串;checkDeptHierarchy 中 strings.HasPrefix(targetDept.Path, caller.DeptPath) 永远返回 true(因为所有字符串都以 "" 为前缀),导致部门隔离机制失效。userIds, _ := l.svcCtx.SysUserModel.FindIdsByDeptId(l.ctx, req.Id)
if len(userIds) > 0 {
return response.ErrBadRequest("该部门下仍有关联用户,无法删除")
}
internal/logic/auth/access.go:122checkDeptHierarchy 使用 strings.HasPrefix(targetDept.Path, caller.DeptPath) 判断部门归属。如果 caller.DeptPath 为空字符串(例如用户部门被删除、或 loadDept 失败),HasPrefix 始终返回 true,使得任何人都能"管理"任何部门的用户。if caller.DeptPath == "" {
return response.ErrForbidden("您的部门信息异常,无法执行此操作")
}
etc/perm-api-prod.yaml(及其他环境配置文件)etc/*.yaml 加入 .gitignore,从 Git 历史中清除已提交的密码;internal/logic/user/createUserLogic.go:34-76CreateUser 不检查密码长度和复杂度,但 ChangePassword 有 6-72 字符的限制。创建用户时可以设置 1 个字符甚至空字符串的密码。if len(req.Password) < 6 {
return nil, response.ErrBadRequest("密码长度不能少于6个字符")
}
if len(req.Password) > 72 {
return nil, response.ErrBadRequest("密码长度不能超过72个字符")
}
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 泄露UserList 应按操作者的产品/部门作用域进行过滤;ProductList/ProductDetail 的 AppKey 字段仅对超管可见;RoleDetail 应校验操作者是否有权查看该产品的角色。internal/logic/auth/changePasswordLogic.go:40-63 及 internal/logic/user/updateUserStatusLogic.go:41-58FindOne 读取整条记录,修改某个字段后整条 Update。在高并发场景下,两个操作可能同时读取到相同的旧数据,后写入者覆盖先写入者的修改。UPDATE sys_user SET password = ?, mustChangePassword = ?, updateTime = ? WHERE id = ?
internal/loaders/userDetailsLoader.go:94-113Load 方法未使用 singleflight 或分布式锁。当缓存过期瞬间,大量并发请求会同时回源数据库执行 6 次查询(loadUser + loadDept + loadProduct + loadMembership + loadRoles + loadPerms)。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
}
internal/loaders/userDetailsLoader.go:149-169cleanByPattern 使用 SCAN + DEL 的 Lua 脚本传入 []string{} 作为 KEYS(空切片),但在 Redis Cluster 中,所有脚本操作的 key 必须位于同一个 slot,且必须通过 KEYS 参数传入。使用 ARGV 传递模式+SCAN 在 Cluster 环境下会被拒绝。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
}
}
perm.go:32-40go func() 在后台 goroutine 中启动。如果 gRPC Server 启动失败(如端口冲突)或运行时 panic,主进程(HTTP Server)不会感知,继续以"残缺"状态运行。errgroup 或 channel 同步两个 server 的生命周期:errCh := make(chan error, 1)
go func() {
rpcServer := zrpc.MustNewServer(...)
defer rpcServer.Stop()
rpcServer.Start()
errCh <- nil
}()
// 主 goroutine 也监听 errCh
*_gen.go 文件中的 newSys*Model 函数(如 sysPermModel_gen.go:70-71、sysUserModel_gen.go:77-78)newSysPermModel 等函数直接修改包级别的 cacheSysPermIdPrefix 等全局变量。虽然当前 NewModels 在启动时仅调用一次,但这种模式不是并发安全的——如果将来在测试或多实例场景下并发初始化,会发生数据竞争。internal/logic/member/addMemberLogic.go:31 — req.MemberType 无校验internal/logic/member/updateMemberLogic.go:30 — req.MemberType 无校验MemberType 字段完全依赖数据库 enum('DEVELOPER','ADMIN','MEMBER') 约束来限制合法值,应用层不做白名单校验。validTypes := map[string]bool{
consts.MemberTypeAdmin: true,
consts.MemberTypeDeveloper: true,
consts.MemberTypeMember: true,
}
if !validTypes[req.MemberType] {
return nil, response.ErrBadRequest("无效的成员类型")
}
internal/logic/product/createProductLogic.go:117-121rand.Read(b) 的返回错误被忽略。虽然 crypto/rand 在主流系统上几乎不会失败,但在某些极端环境(如容器中 /dev/urandom 不可用)下可能返回错误,此时 b 包含零值或不完整的随机数据。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
}
internal/logic/dept/deptTreeLogic.go:27DeptTree 使用 FindAll 一次性加载所有部门数据到内存中构建树。如果部门数量增长到数万级别,会导致内存占用激增和响应延迟。parentId 按需加载子树。internal/logic/pub/syncPermsLogic.go:54-78Perms 数组包含重复的 Code,后出现的会覆盖前出现的在 existingMap 中的查询结果,但 toInsert 中可能包含重复条目,导致 BatchInsert 时触发唯一键冲突。codes 去重。internal/logic/dept/updateDeptLogic.go:43-58UpdateDept 仅清除该部门直接关联用户的缓存。如果修改了 deptType(如 DEV → NORMAL),子部门的用户权限可能因为父部门类型变更而需要重新计算,但不会被刷新。deptType 变更时,清除所有子部门用户的缓存。internal/logic/pub/loginLogic.go、internal/logic/pub/adminLoginLogic.goAdminLogin 的 ManagementKey 也是静态值,无速率限制保护。PeriodLimit 或 Redis 滑动窗口限流;internal/logic/product/productListLogic.go:37 及 internal/logic/product/productDetailLogic.go:33ProductItem 响应体包含 AppKey 字段,任何已登录用户均可获取。AppKey 是产品的接入标识,泄露后配合时序攻击(H-06)有助于暴力破解 AppSecret。AppKey 仅对超管可见,或在列表接口中排除该字段。internal/logic/user/bindRolesLogic.go:64BindRoles 先删除用户全部角色绑定(不限产品),再插入新角色。但缓存清理只 Del 了当前操作者的 productCode 维度。如果用户在多个产品中都有角色绑定,其他产品的缓存不会被清除,导致缓存与 DB 不一致。Clean(通配删除所有产品缓存)代替 Del:l.svcCtx.UserDetailsLoader.Clean(l.ctx, req.UserId)
internal/logic/product/createProductLogic.go:107-115CreateProductResp 中包含 AdminPassword(明文)和 AppSecret。如果响应被日志框架记录或被中间件拦截,这些敏感信息会出现在日志中。internal/response/response.go:44-50200,仅在 body 中的 code 字段区分。这导致 HTTP 监控(如 Nginx、ALB 的 5xx 告警)无法发现业务异常。internal/model/user/sysUserModel.go:69pageArgs := append(args, ...) 如果 args 底层数组容量足够,会修改 args 的底层数据。虽然 args 在此处之后不再使用,但这是一个潜在的陷阱。pageArgs := make([]interface{}, len(args), len(args)+2)
copy(pageArgs, args)
pageArgs = append(pageArgs, (page-1)*pageSize, pageSize)
internal/loaders/userDetailsLoader.go:289-357 与 internal/logic/auth/perms.go:11-115UserDetailsLoader.loadPerms 和 GetUserPerms 中各实现了一遍。逻辑高度相似但不完全相同(如 loadPerms 多了 DeptType == DEV 的判断),容易在后续维护中出现不一致。| 等级 | 数量 | 关键发现 |
|---|---|---|
| 🚩 High | 11 | 权限提升、跨产品越权、数据完整性缺陷、敏感信息泄露 |
| ⚠️ Medium | 14 | 竞态条件、缓存不一致、缺少暴力破解防护、输入校验不足 |
| 📝 Low | 3 | 代码重复、HTTP 状态码规范、slice append 陷阱 |