# 深度审计报告 · Round 14 > 基线:R13 审计后的代码库快照。R13 的 5 条 Low 风险项(L-R13-1 ~ L-R13-5)中,L-R13-1/2/3/4 已在对应 logic 文件中看到修复(见文末"R13 回归验证");L-R13-5 仅做到"大部分调用点切换成 `DetachCacheCleanCtx`",仍遗留两处关键路径未改造,本轮升级归入 M-R14-1。 --- ## 🚩 核心逻辑漏洞 (High Risk) ### H-R14-1 · 授权漏洞 / 跨产品权限升级 —— 产品 ADMIN 可借 `UpdateUser` 把他人调入 DEV 部门,间接赋予跨产品全权 **描述** `internal/logic/user/updateUserLogic.go` 在处理 `req.DeptId != nil && *req.DeptId > 0` 分支时,对「新部门是否是 DEV 类型」没有任何特殊校验;且第 137-141 行对调用方的部门前缀校验通过 `caller.MemberType != consts.MemberTypeAdmin` **直接短路**: ```137:141:internal/logic/user/updateUserLogic.go if !caller.IsSuperAdmin && caller.MemberType != consts.MemberTypeAdmin && !strings.HasPrefix(newDept.Path, caller.DeptPath) { return response.ErrForbidden("无权将用户调入非自己管辖的部门") } ``` 配合 `internal/loaders/userDetailsLoader.go` 第 540-554 行的全权分支: ```540:554:internal/loaders/userDetailsLoader.go if ud.IsSuperAdmin || ud.MemberType == consts.MemberTypeAdmin || ud.MemberType == consts.MemberTypeDeveloper || (ud.MemberType != "" && ud.DeptType == consts.DeptTypeDev && ud.DeptStatus == consts.StatusEnabled) { codes, err := l.models.SysPermModel.FindAllCodesByProductCode(ctx, ud.ProductCode) ... ud.Perms = codes return nil } ``` 只要 `sys_user.deptId` 指向 `DeptType=DEV` 的部门,该用户在**任何他已加入的产品**下都自动拿到该产品的全量权限。而 `sys_user.deptId` 是全局字段——`UpdateUser` 在 caller 作用域内用 `CheckManageAccess(productCode=caller.ProductCode, ...)` 判权,却会把修改向**所有其他产品**级联。 **攻击链(复现路径)** 1. 攻击者 A 是产品 P1 的 ADMIN(正常业务赋予的权限),同伙 B 是产品 P1 的 MEMBER;B 同时也是产品 P2 的 MEMBER。 2. A 调 `UpdateUser(id=B.Id, deptId=)`: - `CheckManageAccess` 仅作用在 P1,通过(B 在 P1 的 memberType=MEMBER,ADMIN 绕过 `checkDeptHierarchy`,`checkPermLevel` 也因 callerPri 0` 分支里加入对「目标新部门是 DEV」的显式护栏,**仅允许超级管理员**把用户调入 DEV;同理对 `deptId=0`(移出部门)保留现状即可,但把 "移入 DEV" 与 "跨越产品范围" 这条路径堵死: ```go if newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin { // DEV 部门承载"加入即全权"的跨产品语义,任何产品 ADMIN / DEVELOPER / MEMBER // 发起的调入都可能越过其 productCode 作用域,污染其它产品的 loadPerms 全权分支。 // 与之对称,CreateUser 对非超管 + DEV 目标部门已经被 DeptPath 前缀校验天然约束 // (DEV 部门路径通常不在 ADMIN 的 caller.DeptPath 子树里),这里补齐 UpdateUser // 被 ADMIN 短路掉的缺口。 return response.ErrForbidden("仅超级管理员可将用户调入研发部门") } ``` 同时建议在 `CreateUser` 中显式镜像一份同样的 `newDept.DeptType == DEV` 护栏,避免未来维护者误将 ADMIN 纳入可跨入 DEV 的行列。 验收测试(对应 `internal/logic/user/updateUserLogic_test.go`)应补: 1. caller=ADMIN of P1,target=MEMBER of P1(且是 P2 的 MEMBER),`req.DeptId=DEV 部门 id` → 返回 `ErrForbidden("仅超级管理员可将用户调入研发部门")`;`sys_user.deptId` 不变;`UserDetailsLoader.Clean` 不被触发。 2. caller=SuperAdmin 同请求 → 正常放行,提交后 `UserDetailsLoader.Clean` 被调用一次。 3. caller=ADMIN of P1,`req.DeptId=<普通部门且在 caller.DeptPath 子树>` → 正常放行(与现有行为一致)。 --- ## ⚠️ 健壮性与性能建议 (Medium / Low) ### M-R14-1 · 资源管理 / 可观测性 —— `RotateRefreshToken` 与 `SyncPermsService` 两处 post-commit 缓存失效未接入 `DetachCacheCleanCtx` **位置** - `internal/logic/auth/rotateRefreshToken.go:82` ```go svcCtx.UserDetailsLoader.Clean(ctx, claims.UserId) ``` - `internal/logic/pub/syncPermsService.go:171` ```go svcCtx.UserDetailsLoader.CleanByProduct(ctx, product.Code) ``` R13 提出的 Solution A 是"事务已提交的缓存清理必须与请求 ctx 解耦,用 `loaders.DetachCacheCleanCtx` 拿一个 parent-cancel-不穿透 + 3s 硬超时的 ctx"。审计当下,`changePasswordLogic / logoutLogic / updateDeptLogic / addMember / removeMember / updateMember / updateProduct / bindRolePerms / deleteRole / updateRole / bindRoles / setUserPerms / updateUser / updateUserStatus` 均已落地,**唯独这两处仍用原始 `ctx`**。两条路径都是高敏感后果: - `RotateRefreshToken`:CAS 提交新 `tokenVersion` 已落库,此时 client 断连 / HTTP ctx 被 deadline 触发 → `UserDetailsLoader.Clean` 被立刻 canceled → Redis 里 UD 仍缓存旧 `tokenVersion`。最多 5 分钟 TTL 内: - 旧 access token(tokenVersion=N)仍能通过中间件(因为 UD 缓存里就是 N); - 客户端拿到新 refresh 失败时自然会重试 refresh,但若 TTL 没过就会命中 `IncrementTokenVersionIfMatch(expected=N)` 失败(DB 已是 N+1),被强制重登录,形成"无故踢出"的 UX 抖动。 - 更坏:如果攻击者的目标是**让用户的老 access token 继续生效**,TTL 5 分钟的窗口是确定可利用的。 - `SyncPermsService`:`CleanByProduct` 失败后,被禁用 / 删除的 perm 仍在 5min TTL 内出现在所有该产品成员的 UD 缓存里——被本产品服务端用 `VerifyToken / GetUserPerms` 查询到的 perms 列表里,`checkStillValid` 逻辑会把失效 perm 从结果里剔除,但 `UserDetails.Perms` 缓存本身不会因 miss 而重建,最多延迟至 TTL。即使下游 caller 做了 `checkStillValid`,也依赖"下游每次查询都保留 db round-trip"的前提;若 perms-system 的消费方只看 `GetUserPerms` 返回,仍会命中窗口。 **修复方案** 两处统一改造为 detach ctx: ```go cleanCtx, cancel := loaders.DetachCacheCleanCtx(ctx) defer cancel() svcCtx.UserDetailsLoader.Clean(cleanCtx, claims.UserId) ``` 和: ```go cleanCtx, cancel := loaders.DetachCacheCleanCtx(ctx) defer cancel() svcCtx.UserDetailsLoader.CleanByProduct(cleanCtx, product.Code) ``` `rotateRefreshToken.go` 是 helper,被 `internal/logic/pub/refreshTokenLogic.go`、`internal/server/permserver.go` 的 `RefreshToken` 同时调用,改 helper 一处即可覆盖 HTTP / gRPC 两个入口,避免外部调用方再各自 detach 的重复代码。 回归测试建议: - 模拟 `IncrementTokenVersionIfMatch` 成功后把 `ctx` 显式 cancel,验证 `Clean` 仍能在 detach ctx 下走到 Redis DEL(或在 redis mock 上观察 DEL 触发)。 - 模拟 `BatchWriteProductPerms` 事务成功后 cancel ctx,验证 `CleanByProduct` 仍走到 cache invalidation。 --- ### L-R14-1 · 信息泄露 / 枚举信号 —— Role 维度的 `FindOne → RequireProductAdminFor` 顺序泄漏跨产品 roleId 存在性 **位置** - `internal/logic/role/updateRoleLogic.go`(先 `SysRoleModel.FindOne(req.Id)` → 再 `RequireProductAdminFor(role.ProductCode)`) - `internal/logic/role/deleteRoleLogic.go`(同上) - `internal/logic/role/bindRolePermsLogic.go`(同上) 任何已登录用户(即使只是某产品的普通 MEMBER)都能构造上述三个接口,用顺序 id 扫 `req.RoleId=1..N`: - 若 `FindOne` 返回 `ErrNotFound` → `响应 404 "角色不存在"`; - 若角色存在且属于其他产品 → `响应 403 "仅超级管理员或该产品的管理员可执行此操作"`。 两个响应码不一致,直接把"该 roleId 是否存在以及属于别的产品"漏给了认证用户,和 R13 的 L-R13-1(`addMember / setUserPerms / bindRoles` 的枚举信号)同族。对比已经收敛过的 `roleDetailLogic.go`(M-N3:把"不存在"和"他产品角色"统一回 404),这三个接口属于遗漏。 **修复方案** 在 `FindOne` 之后、`RequireProductAdminFor` 之前,对非超管调用方先做"产品归属比对",把跨产品情况降级成与"不存在"完全一致的 404: ```go role, err := l.svcCtx.SysRoleModel.FindOne(l.ctx, req.Id) if err != nil { return response.ErrNotFound("角色不存在") } caller := middleware.GetUserDetails(l.ctx) if caller == nil { return response.ErrUnauthorized("未登录") } // 与 RoleDetailLogic 的 M-N3 收敛口径一致:非超管看到"非本产品角色"一律返回 404, // 杜绝通过 403/404 文案差异枚举跨产品 roleId。 if !caller.IsSuperAdmin && role.ProductCode != middleware.GetProductCode(l.ctx) { return response.ErrNotFound("角色不存在") } if err := authHelper.RequireProductAdminFor(l.ctx, role.ProductCode); err != nil { return err } ``` 三个文件统一同一模板,推荐抽成 `authHelper.ResolveOwnRoleOr404(ctx, svcCtx, roleId)` 以避免日后漂移。 --- ### L-R14-2 · 信息泄露 —— `BindRoles` 对入参 roleIds 的错误文案区分"不存在 / 跨产品" **位置** `internal/logic/user/bindRolesLogic.go` 当 `FindByIds(req.RoleIds)` 返回的角色里有跨产品项,返回 `"不能绑定其他产品的角色"`;缺项时返回 `"包含无效的角色ID"`。已通过 `CheckManageAccess` 的调用方即便只是某产品 MEMBER,也可以借此枚举他人产品的 roleId 分布(比 L-R14-1 门槛更低,因为他只需管得了本产品某个下属即可)。 **修复方案** 将"跨产品"与"不存在"折叠成同一个 `ErrBadRequest("包含无效的角色ID")` 文案(日志里保留细分标记供审计分析): ```go valid := 0 for _, r := range roles { if r.ProductCode != productCode || r.Status != consts.StatusEnabled { continue } valid++ } if valid != len(req.RoleIds) { logx.WithContext(l.ctx).Infow("bind roles: invalid ids", logx.Field("audit", "bind_roles_invalid_ids"), logx.Field("requested", req.RoleIds), logx.Field("productCode", productCode), ) return response.ErrBadRequest("包含无效的角色ID") } ``` 不要把产品归属揭露给响应体,只保留到日志里给运营同学排障。 --- ### L-R14-3 · 可维护性 —— `UpdateUserLogic` 的 ADMIN 分支注释缺少对 DEV 部门语义的声明 即便 H-R14-1 选择保留现有 ADMIN 可调部门的语义(决定不改代码),line 131-141 的注释也仅讨论了"caller.DeptPath 为空"的边界,没有钉出**"此分支允许 ADMIN 把用户调入 DEV 部门 = 跨产品全权赋予"**这一事实。建议至少在注释层面明确写一段: ```text 注意:ADMIN 分支短路 DeptPath 前缀校验,意味着 ADMIN 可以把目标调入任何部门, 包括 DeptType=DEV 的部门。DEV 部门在 loadPerms 中等价于"加入任一产品即全权", 这条路径相当于把 ADMIN 本地产品外的权限一并下发到目标的其他产品身份上;若产品 间信任不对称,请额外增加 `newDept.DeptType == DEV → RequireSuperAdmin` 的护栏 (见审计 H-R14-1)。 ``` 这样后续维护者能在修改前看到明确的风险披露,避免把 H-R14-1 的推论再次拆掉。 --- ## R13 回归验证(附录) | 条目 | 期望修复 | 代码现状 | 判定 | | --- | --- | --- | --- | | L-R13-1 枚举信号 | `addMember / setUserPerms / bindRoles` 在 `RequireProductAdminFor` / 早期 caller 校验前不泄漏对象存在性 | `addMemberLogic.go:41` 把 `RequireProductAdminFor` 置于 User / Product `FindOne` 之前;`setUserPermsLogic.go:45` 同;`bindRolesLogic.go:34-49` 新增 `caller.MemberType == ""` 快速 403 | ✅ 已收敛(roleId 维度新出现 L-R14-1/2) | | L-R13-2 `SetUserPerms` TOCTOU | ADMIN/DEVELOPER → DENY 的拦截必须在事务里并锁 `sys_product_member` | `setUserPermsLogic.go` 事务内 `FindOneForShareTx(targetUserId, productCode)` + `memberType` 再检,`sysProductMemberModel.go` 新增 `FindOneForShareTx` | ✅ 已闭合 | | L-R13-3 冗余条件 | 删除 `UpdateUserLogic` 里 `caller.DeptPath != ""` | `updateUserLogic.go:131-136` 已删除并附注释 | ✅ 已收敛 | | L-R13-4 负数 `deptId` | `CreateUser / UpdateUser` 显式 `< 0` 返回 400 | `createUserLogic.go`、`updateUserLogic.go:117-119` 均有 `*req.DeptId < 0 → BadRequest` | ✅ 已收敛 | | L-R13-5 post-commit 缓存失效 | 方案 A `DetachCacheCleanCtx` + 方案 B 审计日志标签 | 主流程已切换(见文件列表);`loaders/cacheCleanCtx.go` 提供 API 与 `logCacheInvalidationErr`;**遗漏 `rotateRefreshToken.go` / `syncPermsService.go` 两处**,本轮按 M-R14-1 升级跟进 | 🟡 部分收敛 | --- 本轮新增发现一条 High(H-R14-1)、一条 Medium(M-R14-1)、三条 Low(L-R14-1 / L-R14-2 / L-R14-3)。建议优先处置 H-R14-1(跨产品升级,实际可被产品 ADMIN 触发)和 M-R14-1(两处遗留的缓存失效路径,修复代价极小)。