audit-report.md 14 KB

深度审计报告 · 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 直接短路

if !caller.IsSuperAdmin &&
    caller.MemberType != consts.MemberTypeAdmin &&
    !strings.HasPrefix(newDept.Path, caller.DeptPath) {
    return response.ErrForbidden("无权将用户调入非自己管辖的部门")
}

配合 internal/loaders/userDetailsLoader.go 第 540-554 行的全权分支:

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=<DEV 部门 id>)
    • CheckManageAccess 仅作用在 P1,通过(B 在 P1 的 memberType=MEMBER,ADMIN 绕过 checkDeptHierarchycheckPermLevel 也因 callerPri<targetPri 直接放行);
    • 第 137-141 行对 ADMIN 短路,允许把 B 调入 P1 作用域之外的 DEV 部门;
    • 第 146-148 行 "仅超管 / ADMIN 可移出部门" 也是按 ADMIN 放行。
  3. 事务提交后 UserDetailsLoader.Clean(B.Id) 刷新 UD,B 在产品 P2 下的下一次 Load 命中 DEV 全权分支 → ud.Perms = FindAllCodesByProductCode(P2),B 从 P2 的普通成员瞬间升级为P2 全权
  4. 如需抹痕,A 再把 B 移出 DEV 即可。该窗口期内 B 可调用 P2 的任何接口(含敏感读写)。

影响

  • 跨产品权限升级:P1 的 ADMIN 能为 P2 的任意共有成员授予 P2 的全量权限——这是典型的信任边界穿透
  • 对于多产品共享用户池(同一 sys_user 被多个产品加为成员)的部署,这个路径把"ADMIN 只应在自己产品内全权"的不变量彻底打破。
  • ADMIN 还可以顺带把其他产品的普通员工(B 可能是 P2 的 MEMBER 但在 P1 只是挂名)调入 DEV 后再移回原部门,对 P2 而言几乎不可审计(sys_user.deptId 的变更在 P2 日志里看不到是谁发起的)。

修复方案

updateUserLogic.go*req.DeptId > 0 分支里加入对「目标新部门是 DEV」的显式护栏,仅允许超级管理员把用户调入 DEV;同理对 deptId=0(移出部门)保留现状即可,但把 "移入 DEV" 与 "跨越产品范围" 这条路径堵死:

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 · 资源管理 / 可观测性 —— RotateRefreshTokenSyncPermsService 两处 post-commit 缓存失效未接入 DetachCacheCleanCtx

位置

  • internal/logic/auth/rotateRefreshToken.go:82

    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 分钟的窗口是确定可利用的。
  • SyncPermsServiceCleanByProduct 失败后,被禁用 / 删除的 perm 仍在 5min TTL 内出现在所有该产品成员的 UD 缓存里——被本产品服务端用 VerifyToken / GetUserPerms 查询到的 perms 列表里,checkStillValid 逻辑会把失效 perm 从结果里剔除,但 UserDetails.Perms 缓存本身不会因 miss 而重建,最多延迟至 TTL。即使下游 caller 做了 checkStillValid,也依赖"下游每次查询都保留 db round-trip"的前提;若 perms-system 的消费方只看 GetUserPerms 返回,仍会命中窗口。

修复方案

两处统一改造为 detach ctx:

cleanCtx, cancel := loaders.DetachCacheCleanCtx(ctx)
defer cancel()
svcCtx.UserDetailsLoader.Clean(cleanCtx, claims.UserId)

和:

cleanCtx, cancel := loaders.DetachCacheCleanCtx(ctx)
defer cancel()
svcCtx.UserDetailsLoader.CleanByProduct(cleanCtx, product.Code)

rotateRefreshToken.go 是 helper,被 internal/logic/pub/refreshTokenLogic.gointernal/server/permserver.goRefreshToken 同时调用,改 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:

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") 文案(日志里保留细分标记供审计分析):

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 部门 = 跨产品全权赋予"这一事实。建议至少在注释层面明确写一段:

注意:ADMIN 分支短路 DeptPath 前缀校验,意味着 ADMIN 可以把目标调入任何部门,
包括 DeptType=DEV 的部门。DEV 部门在 loadPerms 中等价于"加入任一产品即全权",
这条路径相当于把 ADMIN 本地产品外的权限一并下发到目标的其他产品身份上;若产品
间信任不对称,请额外增加 `newDept.DeptType == DEV → RequireSuperAdmin` 的护栏
(见审计 H-R14-1)。

这样后续维护者能在修改前看到明确的风险披露,避免把 H-R14-1 的推论再次拆掉。


R13 回归验证(附录)

条目 期望修复 代码现状 判定
L-R13-1 枚举信号 addMember / setUserPerms / bindRolesRequireProductAdminFor / 早期 caller 校验前不泄漏对象存在性 addMemberLogic.go:41RequireProductAdminFor 置于 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 冗余条件 删除 UpdateUserLogiccaller.DeptPath != "" updateUserLogic.go:131-136 已删除并附注释 ✅ 已收敛
L-R13-4 负数 deptId CreateUser / UpdateUser 显式 < 0 返回 400 createUserLogic.goupdateUserLogic.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(两处遗留的缓存失效路径,修复代价极小)。