audit-report.md 20 KB

深度审计报告 · 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-142createUserLogic.go:99-101 双侧落地 SuperAdmin-only 护栏;攻击链已被切断。


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

M-R15-1 · 缓存 TTL 下的 MemberType TOCTOU —— HasFullProductPerms / LoadCallerAssignableLevel 仅读 UD 缓存的 MemberType,降权期内仍按 ADMIN 放行

位置

  • internal/logic/auth/access.go:265-272HasFullProductPerms
  • internal/logic/auth/access.go:211-226LoadCallerAssignableLevel 短路分支)
  • internal/logic/auth/access.go:337-338checkDeptHierarchy 的 ADMIN 绕过)
  • internal/logic/auth/access.go:132-144RequireProductAdminFor
  • 所有把 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 仍然只走缓存:

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:<userId>:<productCode> 键,TTL=5min。一旦 UpdateMember 的 post-commit Del 因 Redis 抖动未成功(本身已用 DetachCacheCleanCtx 3s 超时兜底,但 Redis 真故障时仍会留日志 → 不复查),缓存里的 MemberType 会在 最长 5min 内保持 ADMIN / DEVELOPER 语义。

同时 UpdateMember 本身不递增 sys_user.tokenVersion(对比 UpdateUserStatus 会自动 tokenVersion = tokenVersion + 1UpdatePassword / 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 未变)调用业务接口:
    • jwtauthMiddlewareLoad(userId, productCode) 命中 Redis 旧 UD → 把 MemberType=ADMIN 的 ud 注入 ctx;
    • BindRoles / UpdateRole 等看 caller.MemberType == consts.MemberTypeAdmin 放行;
    • CheckManageAccess → checkDeptHierarchy 的 ADMIN 分支直接 return nil,跳过部门链校验;
    • LoadCallerAssignableLevelHasFullProductPerms 返回 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} → MEMBEREnabled → Disabled)里显式递增目标用户的 tokenVersion,与 UpdateUserStatus 口径对齐:

// 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 对称:

// 新增 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(把用户调出部门树)分支,现状:

} 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<targetPri 放行);
  2. 提交后 B 的全局 sys_user.deptId=0DeptPath=""DeptType=""
  3. 在 P2 视角里,B 成为组织结构里的孤儿节点
    • P2 的 MEMBER/DEVELOPER 走 checkDeptHierarchy 时会命中 target.DeptId == 0 → 403 "目标用户未归属部门,仅超管或产品管理员可管理";
    • 只有 P2 的 ADMIN 能靠自身 ADMIN 绕过 + checkPermLevel 在 P2 成员表找到 B 才可继续管理;
    • DeptTree 里 B 不再出现(caller.DeptPath 前缀过滤),运营侧看不到 B 的存在。
  4. 与 H-R14-1 最大区别:不构成权限升级——B 的 DeptType 空、DeptStatus=0,不会触发 loadPerms 的 DEV 全权分支。

影响

  • 组织结构可用性攻击:P1 ADMIN 可以制造 P2 的"隐形成员",P2 的日常管理层级(MEMBER/DEVELOPER/产品内子 ADMIN)全部够不到 B;B 仍能正常使用 P2,但 P2 运营侧排障困难;
  • 与 H-R14-1 的攻击面叠加:攻击者可以先调入 DEV 部门拿全权(已被 H-R14-1 修掉),现在也无法叠加这条——但"调出部门树"作为次等破坏面仍存在。
  • 可审计性差:sys_user.deptId 变更在 P2 的日志链路里看不到变更发起方——整条 UpdateUser 调用落在 P1 的审计上下文内。

修复方案

与 H-R14-1 对称收敛给 SuperAdmin:

// internal/logic/user/updateUserLogic.go
} else {
    // 审计 L-R15-1:与 H-R14-1 对称——sys_user.deptId 是全局字段,改为 0 会把用户从
    // **所有**产品的部门结构里移除,跨产品影响。产品 ADMIN 的授权范围仅限自己产品,
    // 不应执行改变全局字段的破坏性操作。移出部门树由 SuperAdmin 执行(例如离职流程)。
    if !caller.IsSuperAdmin {
        return response.ErrForbidden("仅超级管理员可将用户移出部门")
    }
}

如业务上确实需要保留"产品 ADMIN 可把非跨产品成员移出部门"的能力,可以加一个"目标是否同时在其他产品"的判断,但复杂度明显提升,不如直接收敛给 SuperAdmin;离职/转岗属于行政 HR 流程,SuperAdmin 执行更合理。

回归测试建议

  • caller=P1 ADMIN,target=P1+P2 MEMBER,req.DeptId=0ErrForbidden("仅超级管理员可将用户移出部门")sys_user.deptId 不变;UserDetailsLoader.Clean 不触发。
  • caller=SuperAdmin 同请求 → 正常放行。
  • caller=P1 ADMIN,target=P1-only MEMBER,req.DeptId=0 → 根据业务决策(本轮建议改为一致拒绝)。

L-R15-2 · 跨产品信息泄露 —— DeptTree 对任何产品的 ADMIN 暴露完整组织架构

位置 internal/logic/dept/deptTreeLogic.go:45

描述

fullAccess := caller.IsSuperAdmin || caller.MemberType == consts.MemberTypeAdmin

if !fullAccess {
    if caller.DeptPath == "" {
        return make([]*types.DeptItem, 0), nil
    }
    filtered := make([]*deptModel.SysDept, 0, len(list))
    for _, d := range list {
        if strings.HasPrefix(d.Path, caller.DeptPath) {
            filtered = append(filtered, d)
        }
    }
    list = filtered
}

sys_dept全局命名空间(一份组织架构服务于所有产品);任何一个产品的 product ADMIN 都能走 fullAccess=true 分支拿到全公司所有部门——包括其他产品的敏感研发子树、跨 BU 的 HR/财务部门、未归属到任何产品的战略部门等。

这与 DeptTreeLogic 注释里的"超管 / 产品 ADMIN 返回完整组织架构树;其他成员仅返回以其 DeptPath 为根的子树"一致,但在多产品共享组织架构的部署下,对"小产品的 ADMIN 看到大产品的 DEV 子树名称 + 层级"这一点暴露并没有防御。配合 M-2(MEMBER 级不能看全产品列表以防 admin_ 撞库)的既有意图——审计链路里"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. 收敛给 SuperAdminfullAccess := 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 · 降权 / 禁用不强制吊销会话 —— UpdateMemberUpdateProduct(禁用)均不递增 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 + 乐观锁)、ChangePasswordUpdatePasswordtokenVersion = tokenVersion + 1)、LogoutIncrementTokenVersion)形成鲜明对比:

变更 旧 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
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 禁用批量递增可分作下一轮单独跟进。