# 深度审计报告 · Round 15 > 基线:R14 审计后的代码库快照。R14 提出的 1 条 High(H-R14-1)、1 条 Medium(M-R14-1)、3 条 Low(L-R14-1 / L-R14-2 / L-R14-3)已全部在对应 logic 文件中落地修复(见文末"R14 回归验证")。本轮聚焦"缓存 TTL 下的 MemberType TOCTOU"、"全局字段 `sys_user.deptId` 的跨产品副作用再审"与"降权路径是否吊销会话"等此前轮次未覆盖的面。 --- ## 🚩 核心逻辑漏洞 (High Risk) 本轮未新增 High 风险项。R14 的 H-R14-1(DEV 部门跨产品全权升级)已在 `updateUserLogic.go:140-142` 与 `createUserLogic.go:99-101` 双侧落地 SuperAdmin-only 护栏;攻击链已被切断。 --- ## ⚠️ 健壮性与性能建议 (Medium / Low) ### M-R15-1 · 缓存 TTL 下的 MemberType TOCTOU —— `HasFullProductPerms` / `LoadCallerAssignableLevel` 仅读 UD 缓存的 MemberType,降权期内仍按 ADMIN 放行 **位置** - `internal/logic/auth/access.go:265-272`(`HasFullProductPerms`) - `internal/logic/auth/access.go:211-226`(`LoadCallerAssignableLevel` 短路分支) - `internal/logic/auth/access.go:337-338`(`checkDeptHierarchy` 的 ADMIN 绕过) - `internal/logic/auth/access.go:132-144`(`RequireProductAdminFor`) - 所有把 `caller.MemberType` 当授权输入的调用点:BindRoles / CreateUser / CreateRole / BindRolePerms / UpdateRole / DeleteRole / AddMember / UpdateMember / SetUserPerms / RemoveMember / DeptTree 等 **描述** 审计 H-2 / M-R10-3 / GuardRoleLevelAssignable 已经为 **caller.MinPermsLevel** 建立了"授权决策点强制走 `loadFreshMinPermsLevel` 读 DB"的 TOCTOU 闭环,缩短了 UD 缓存 TTL 窗口。但同一份 UD 里另一个同等重要的授权字段 **MemberType** 仍然只走缓存: ```265:272:internal/logic/auth/access.go func HasFullProductPerms(caller *loaders.UserDetails) bool { if caller == nil { return false } return caller.IsSuperAdmin || caller.MemberType == consts.MemberTypeAdmin || caller.MemberType == consts.MemberTypeDeveloper } ``` `caller` 的来源是 `middleware.GetUserDetails(ctx)` → `UserDetailsLoader.Load` → Redis `ud::` 键,TTL=5min。一旦 `UpdateMember` 的 post-commit `Del` 因 Redis 抖动未成功(本身已用 `DetachCacheCleanCtx` 3s 超时兜底,但 Redis 真故障时仍会留日志 → 不复查),缓存里的 `MemberType` 会在 **最长 5min** 内保持 ADMIN / DEVELOPER 语义。 同时 `UpdateMember` 本身**不递增 sys_user.tokenVersion**(对比 `UpdateUserStatus` 会自动 `tokenVersion = tokenVersion + 1`、`UpdatePassword` / `Logout` 同),降权不触发强制重登录。因此攻击窗口是"缓存读"而非"token 读": 1. A 是产品 P1 的 product ADMIN; 2. SuperAdmin 通过 `UpdateMember` 把 A 降级为 MEMBER;事务提交,`UserDetailsLoader.Del(A.UserId, P1)` 被调用; 3. 若 Redis 在这 3s 内出现网络波动,`Del` 的 DelCtx 返回 err,日志打 `cache_invalidation_skipped_*`,缓存保留 `MemberType=ADMIN`; 4. A 继续用手里的 access token(claims.MemberType=ADMIN,tokenVersion 未变)调用业务接口: - `jwtauthMiddleware` 走 `Load(userId, productCode)` 命中 Redis 旧 UD → 把 `MemberType=ADMIN` 的 ud 注入 ctx; - `BindRoles` / `UpdateRole` 等看 `caller.MemberType == consts.MemberTypeAdmin` 放行; - `CheckManageAccess → checkDeptHierarchy` 的 ADMIN 分支直接 `return nil`,跳过部门链校验; - `LoadCallerAssignableLevel` 的 `HasFullProductPerms` 返回 true,`BindRoles` 循环里所有 `CheckRoleLevelAgainst` 全部放行,A 可以把**任意权限等级**(包括超出其当前 MEMBER 身份应有上限)的角色绑给下属; 5. 攻击窗口持续到 5min TTL 自然过期或下一次 Clean 成功。 对比之下,R13 在 H-2 的注释里已经准确钉出"UD 缓存 5 分钟 TTL 内旧 MinPermsLevel 可被利用";但同批同类风险的 MemberType 缺少对称防御,形成审计覆盖不对称。 **影响** - 降权后的 product ADMIN 在 Redis 抖动窗口内保留 ADMIN 权能:可继续创建角色 / 绑权限、管理他人(`checkDeptHierarchy` ADMIN 绕过)、修改产品内成员(`RequireProductAdminFor` 通过)。 - 降 ADMIN → MEMBER 的典型触发场景就是"怀疑滥权 / 内鬼排查"——恰恰是最不希望有 5 分钟残余窗口的操作。 - DEVELOPER → MEMBER 类似,但规模小,影响面小一档。 此风险本身是"缓存读一致性"架构层决定,不是单点 bug。闭环方案有两条,任选其一或组合: **修复方案(方案 A,最小代价,推荐)**:让 `UpdateMember` 在降级路径(`{ADMIN, DEVELOPER} → MEMBER` 或 `Enabled → Disabled`)里显式递增目标用户的 `tokenVersion`,与 `UpdateUserStatus` 口径对齐: ```go // internal/logic/member/updateMemberLogic.go,在 tx 内部 update member 之前/之后: wasPrivileged := locked.MemberType == consts.MemberTypeAdmin || locked.MemberType == consts.MemberTypeDeveloper || locked.Status == consts.StatusEnabled willBePrivileged := (nextType == consts.MemberTypeAdmin || nextType == consts.MemberTypeDeveloper) && nextStatus == consts.StatusEnabled if wasPrivileged && !willBePrivileged { // 事务内递增 sys_user.tokenVersion 强制再登录; // 与 UpdateUserStatus 的 WHERE updateTime=? 乐观锁语义对齐, // 并通过 DetachCacheCleanCtx 的 Clean 失效 UD 缓存(已存在)。 if _, err := l.svcCtx.SysUserModel.IncrementTokenVersionWithTx(ctx, session, member.UserId); err != nil { return err } } ``` 这样不论 Redis 是否抖动,旧 access token 都会在下一次中间件校验时因 `claims.TokenVersion != ud.TokenVersion` 被强制重登录;重登录走 `Login / RefreshToken` 会重新签发含新 `memberType` 的 token 并写入 Redis。注意需要**在 tx 内**递增 tokenVersion,否则如果 tx 回滚,`sys_user.tokenVersion` 会多走一步。目前 `sysUserModel` 只有 `IncrementTokenVersion(ctx, id, username)`(非 tx),需要补一个 `IncrementTokenVersionWithTx(ctx, session, id)`;username 仅用于失效缓存,可以延后到 tx commit 之后配合现有 `UserDetailsLoader.Del` 链路失效。 **修复方案(方案 B,加固授权决策点)**:在所有依赖 `caller.MemberType == ADMIN/DEVELOPER` 的授权决策点(`HasFullProductPerms` / `checkDeptHierarchy` 的 ADMIN 绕过 / `RequireProductAdminFor`)前做一次 `FindOneByProductCodeUserId(productCode, caller.UserId)` 的 DB 复核。代价是每个管理写接口多 1 次(带缓存的)DB 读,但概念上把 MemberType 的 TOCTOU 窗口从 TTL 级压到单查询级,与 `loadFreshMinPermsLevel` 对称: ```go // 新增 authHelper.ResolveCallerMembership(ctx, svcCtx, caller) (*SysProductMember, error) // 并让 HasFullProductPerms 改签名为 (ctx, svcCtx, caller) 走该 helper; // 在 checkDeptHierarchy ADMIN 分支、RequireProductAdminFor、LoadCallerAssignableLevel // 三处同步切换。 ``` 方案 A 与 B 的本质差异:A 让攻击窗口为 0(token 被废),B 让窗口为"单次 DB 读" ~5ms。优先推荐 A——成本更低、语义更清晰,与现有强制下线 (`UpdateUserStatus`, `ChangePassword`, `Logout`) 的口径一致。 **回归测试建议** - 模拟 ADMIN→MEMBER 降级后 **Redis DEL 失败**,验证下一次 API(BindRoles / UpdateRole)能通过 `tokenVersion mismatch` 拒绝,或(方案 B 下)通过 `RequireProductAdminFor` 的 DB 复核拒绝; - 事务回滚场景验证 tokenVersion **未**被递增(方案 A),避免"降级操作失败但用户被踢下线"的错误扩大面。 --- ### L-R15-1 · 跨产品结构性破坏 —— 产品 ADMIN 可借 `UpdateUser req.DeptId=0` 把共有用户调出部门树 **位置** `internal/logic/user/updateUserLogic.go:158-165` **描述** `H-R14-1` 已经把"调入 DEV 部门"这条真正能做跨产品**权限升级**的路径收敛给 SuperAdmin。对称地分析 `req.DeptId == 0`(把用户调出部门树)分支,现状: ```158:165:internal/logic/user/updateUserLogic.go } else { // deptId=0 意味着"把用户移出部门树";... if !caller.IsSuperAdmin && caller.MemberType != consts.MemberTypeAdmin { return response.ErrForbidden("仅超级管理员或产品管理员可将用户移出部门") } } ``` 产品 ADMIN 于是被放行。`sys_user.deptId` 是**全局字段**——产品 P1 的 ADMIN 对一位同时是 P2 成员的用户 B 调用 `UpdateUser(deptId=0)`: 1. P1 侧 `CheckManageAccess` 通过(`checkDeptHierarchy` ADMIN 绕过、`checkPermLevel` 对 MEMBER 级目标 callerPri 撞库)的既有意图——审计链路里"MEMBER 拿不到敏感组织信息"是刻意守住的,但对"任何 product ADMIN 可拿到全量 sys_dept"网开一面,属于覆盖不对称。 **影响** - 信息泄露级别,非权限升级:拿到部门结构后可用于社工 / 针对性撞库(例如针对 DEV 部门里 ops_* 命名的账号发起 H-1 维度的 username/ip 限流绕过尝试),以及 H-R14-1 修掉的攻击链在"选定目标部门 id"阶段的侦察输入; - 多产品共用一份 sys_dept 的前提下,"小产品 ADMIN 看到大产品 DEV 部门"的设计与后者对前者的最小授权原则冲突; - 若未来 `DeptType=DEV` 的子树被用作类似 H-R14-1 的"加入即全权"判据(已被 H-R14-1 的 SuperAdmin-only 护栏堵死),本项就是"知道调去哪个 deptId"的前置侦察接口。 **修复方案** 根据业务对"产品 ADMIN 是否需要全局部门视图"的真实需求,二选一: 1. **收敛给 SuperAdmin**:`fullAccess := caller.IsSuperAdmin`。产品 ADMIN 仅看自己 DeptPath 子树(与 DEVELOPER/MEMBER 对齐)。`AddMember` 若需要从其他部门拉人,由 SuperAdmin 批准或另开 `ListAddableDepts` 等独立接口。 2. **保留产品 ADMIN 全量视图,但脱敏其他产品的 DEV 子树**:在 `fullAccess=true` 分支里,若 caller 不是 SuperAdmin,额外跳过 `DeptType == DEV` 的部门。保留"正常部门"的全公司视图以支持 AddMember 跨部门拉人,但隐藏 DEV 子树名称避免针对性侦察。 从"最小授权"出发推荐方案 1;保留现状的话建议在审计报告里显式钉住"产品 ADMIN 可见全组织架构"这条信任边界,便于未来评审时不被新人误改。 --- ### L-R15-3 · 降权 / 禁用不强制吊销会话 —— `UpdateMember` 与 `UpdateProduct`(禁用)均不递增 tokenVersion **位置** - `internal/logic/member/updateMemberLogic.go:77-107`(整个事务体不触及 `sys_user.tokenVersion`) - `internal/logic/product/updateProductLogic.go:46-73`(产品禁用同样不递增其成员 tokenVersion) **描述** 与 `UpdateUserStatus`(会自动走 `sysUserModel.UpdateStatus` 内的 `tokenVersion = tokenVersion + 1` + 乐观锁)、`ChangePassword`(`UpdatePassword` 内 `tokenVersion = tokenVersion + 1`)、`Logout`(`IncrementTokenVersion`)形成鲜明对比: | 变更 | 旧 access token 失效方式 | | --- | --- | | UpdateUserStatus Disabled | ✅ tokenVersion +1 → 中间件下次 403 | | UpdateUserStatus Enabled(重启用) | ✅ tokenVersion +1(副作用无害) | | ChangePassword | ✅ tokenVersion +1 | | Logout | ✅ tokenVersion +1 | | RefreshToken rotate | ✅ CAS tokenVersion +1 | | **UpdateMember 降级** | ❌ 仅刷 UD 缓存(best-effort) | | **UpdateMember 禁用** | ❌ 仅刷 UD 缓存 | | **UpdateProduct 禁用** | ❌ 仅刷 UD 缓存 | | DeleteRole / UpdateRole | ❌ 仅刷 UD 缓存(可接受:角色细粒度) | 后三条依赖 `UserDetailsLoader.Del / CleanByProduct` 的 post-commit 失效(已用 `DetachCacheCleanCtx` 3s 超时 + 5min TTL 兜底)。Redis 抖动时的窗口见 M-R15-1 的详细分析——这里是"同一风险模式"的另一个触发点: - `UpdateMember` 降 ADMIN → MEMBER:M-R15-1 已详述; - `UpdateMember` 禁用(`Status=Disabled`):被禁成员的 access token 仍然能通过中间件——因为中间件读的是 `ud.Status`(来自 UD 缓存),缓存失效失败就等于"禁用无效" 5 分钟; - `UpdateProduct` 禁用产品:产品内所有成员的 `ud.ProductStatus` 需要失效,`CleanByProduct` 触发大范围 SUNION+DEL;任意一步失败就留下"产品禁用但成员仍能查 / 用"的窗口。 **影响** 与 M-R15-1 同一个攻击面,不同触发点:在 Redis 不可用期间,"降级 / 禁用 / 产品下线" 三类敏感变更都依赖缓存失效做唯一拦截。本项与 M-R15-1 的关系是"M-R15-1 是其中最危险的一种降权路径,L-R15-3 是全集"。 **修复方案** 按 M-R15-1 方案 A 的思路统一让这三个写路径在降权/禁用分支里递增目标 `tokenVersion`: 1. `updateMemberLogic.go`:降 ADMIN/DEVELOPER → MEMBER 或禁用成员时递增目标用户的 `tokenVersion`(见 M-R15-1 方案 A 代码样例)。 2. `updateProductLogic.go`:产品变更为 `Disabled` 时,**批量**递增该产品下所有成员的 `tokenVersion`——类似 `FindIdsByDeptId` 的接口语义,新增 `sysProductMember.FindActiveMemberUserIdsByProductCode` 拿一次 userIds,再用一条 `UPDATE sys_user SET tokenVersion = tokenVersion + 1 WHERE id IN (...)` 批量递增。注意:数据量就是该产品的成员数(量级一般在数千内),不是全站用户,不会爆 SQL。配合 `UserDetailsLoader.CleanByProduct` 并行清缓存即可。 3. 对应新增 `IncrementTokenVersionWithTx(ctx, session, id)` 与 `BatchIncrementTokenVersion(ctx, userIds)` 两个 model 方法,共享现有 `cacheSysUserIdPrefix / cacheSysUserUsernamePrefix` 失效链路。 测试要点: - Redis 完全不可用场景下,验证降级/禁用用户被上游中间件拒绝; - 批量递增 tokenVersion 的 SQL 使用占位符化的 IN(...),避免未来 N 过大时栈溢出或单 SQL 行数超限。 --- ### L-R15-4 · 可读性 —— 降权路径语义需要在注释里显式声明 随着 M-R15-1 / L-R15-3 如果按方案 A 采纳"降权即递增 tokenVersion",需要在以下文件顶部注释钉住语义: - `internal/logic/member/updateMemberLogic.go` 顶部:列明"任何从 {ADMIN, DEVELOPER} 向 MEMBER 的迁移、或从 Enabled 向 Disabled 的迁移都会强制对方重登录(tokenVersion+1)",方便未来维护者区分"刷缓存 + 重登录"的双重防御意图; - `internal/logic/product/updateProductLogic.go`:同样声明产品禁用会批量递增成员 tokenVersion。 这条本身不构成代码修改,仅提醒"代码修完后记得同步更新审计注释",与 L-R14-3 同类性质。 --- ## R14 回归验证(附录) | 条目 | 期望修复 | 代码现状 | 判定 | | --- | --- | --- | --- | | H-R14-1 DEV 部门跨产品全权升级 | UpdateUser + CreateUser 在 `DeptType=DEV` 目标部门分支拒绝非超管 | `updateUserLogic.go:140-142` + `createUserLogic.go:99-101` 两处均有显式 `newDept.DeptType == consts.DeptTypeDev && !caller.IsSuperAdmin → ErrForbidden` | ✅ 已闭合 | | M-R14-1 rotateRefreshToken / syncPermsService 缓存失效未 detach | 两处改用 `loaders.DetachCacheCleanCtx` | `rotateRefreshToken.go:87-89` + `syncPermsService.go:177-179` 均已改造;全仓扫描 `UserDetailsLoader.(Clean|Del|CleanByProduct|BatchDel|CleanByUserIds)` 调用点,所有路径都走 detached ctx | ✅ 已闭合 | | L-R14-1 UpdateRole/DeleteRole/BindRolePerms 404 vs 403 枚举 | 抽 `authHelper.ResolveOwnRoleOr404` 统一 404 | `access.go:146-176` 新增 `ResolveOwnRoleOr404`;三处调用方(`updateRoleLogic.go:46` / `deleteRoleLogic.go:33` / `bindRolePermsLogic.go:38`)均改用该 helper | ✅ 已闭合 | | L-R14-2 BindRoles 跨产品文案区分 | "缺项 / 跨产品 / 已禁用" 折叠为同一 `"包含无效的角色ID"` | `bindRolesLogic.go:91-117` 合并;事务内拿 S 锁失败(race_deleted_or_disabled)的分支 `bindRolesLogic.go:163-170` 也走同一响应体 | ✅ 已闭合 | | L-R14-3 UpdateUser ADMIN 分支注释披露 | 在 ADMIN 分支显式声明 DEV 部门语义 | `updateUserLogic.go:131-152` 已补注释;且 H-R14-1 的代码兜底已经让注释描述的路径被代码直接拦截,注释与代码互相印证 | ✅ 已闭合 | --- 本轮新增发现 1 条 Medium(M-R15-1,缓存 TTL 下的 MemberType TOCTOU)与 3 条 Low(L-R15-1 / L-R15-2 / L-R15-3)。Low 三条彼此有语义关联:L-R15-3 是 M-R15-1 的泛化,L-R15-1 是 H-R14-1 的结构对称项,L-R15-2 是跨产品信息泄露面的残留。优先处置 **M-R15-1**(按方案 A 让 `UpdateMember` 降级时强制 `tokenVersion+1`),可同时闭合 L-R15-3 的 UpdateMember 分支——代价仅是在 tx 内补一次 UPDATE;`UpdateProduct` 禁用批量递增可分作下一轮单独跟进。