审计范围:
/internal下全部非测试、非_gen.go生产代码(含internal/server/permserver.go、HTTP logic / handler / middleware、loaders、model 定制层、svc、util、consts)。 审计时间:2026-04-19 审计维度:逻辑一致性 / 并发与 RMW / 资源管理 / 数据完整性 / 安全漏洞 / 边界坍塌 / DB 性能 / 僵尸代码 / 接口契约与对象完整性。 与第 7 轮对比:
- 已落地(本轮不再复列):H-1
loadPermsdeny fail-open、H-3AddMember目标侧授权、H-4 JWT HMAC 断言、M-1Load半成品缓存污染、M-2UpdatePassword/UpdateStatus未校验RowsAffected、M-4CreateProduct明文密码(切成一次性credentialsTicket)、M-7 gRPC 限流剥端口、L-1DeleteDept锁序列、L-2IncrementTokenVersionWARN 注释、L-3loadPerms其余分支 fail-close、L-4 SQLstatus = ?参数化(sysUserRole/sysUserPerm/sysRole三处)、L-5FindMapByProductCodeUserIds/FindMapByProductCode非事务版移除、L-6 gRPC 负缓存预污染(TTL=10s + 预写前强一致FindOne)。- 未落地 / 回归:H-2 PII 暴露(第 6~7 轮持续未修),M-3 残留分支(
GuardRoleLevelAssignable已走 fresh read,但CheckManageAccess → checkPermLevel仍读缓存caller.MinPermsLevel,降级 admin 的 TOCTOU 窗口只封了"分配角色"一个出口),L-5CountActiveAdminsTx零调用,L-7 历史账号DeptId=0兜底。- 新发现:M-N1
CreateProduct → Redis 票据写入失败导致产品/管理员孤儿化;M-N2checkPermLevel读缓存MinPermsLevel的 TOCTOU 口子(M-3 未闭合的另一半);M-N3SyncPermsError{Code:404}在 gRPC 映射里被同化成codes.Internal;L-N1sysPermModel仍用fmt.Sprintf("... status = %d", consts.StatusEnabled),与 L-4 修复风格不一致;L-N2SetUserPermsFindByIds校验与BatchInsert之间的 TOCTOU(影响面轻,列入存档)。
UserDetailLogic / UserListLogic 仍把 Email / Phone / Remark 暴露给任意同产品成员(第 6 轮 M-3、第 7 轮 H-2,三轮未落地)internal/logic/user/userDetailLogic.go:64-76(types.UserItem 构造)internal/logic/user/userListLogic.go:77-89UserDetail 只用 FindOneByProductCodeUserId 核对 caller 与 target 是否在同一产品,之后直接塞 Email / Phone / Remark(纯文本,无脱敏)。UserList 分页返回同产品所有成员,每条也原样带上 Email / Phone / Remark。一次分页可以把整个产品通讯录灌下来。UserList 一次拿走本产品全员通讯录;修复方案(与前两轮报告一致,代码未落):
在 internal/logic/auth/access.go 新增:
// CanViewContact 判定 caller 是否可以看 target 的联系信息字段。
// - caller 是 SuperAdmin → true
// - caller.UserId == target.Id(看自己) → true
// - CheckManageAccess(caller, target) 通过(caller 在 target 的管理链上) → true
// - 其余情况一律 false,由 filterPIIForCaller 落地脱敏。
func CanViewContact(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, target *userModel.SysUser, productCode string) bool
authHelper.MaskEmail / MaskPhone(138****1234 / a***@b.com),在 Logic 构造 DTO 前调用 filterPIIForCaller(caller, target, &item) 统一覆盖 Email / Phone / Remark 三字段。CheckManageAccess → checkPermLevel 仍靠 caller 的缓存 MinPermsLevel 决策,降级 admin 在 5 分钟 TTL 内仍可管辖原本够不到的目标(M-3 只封了一半)internal/logic/auth/access.go:279-316(checkPermLevel)internal/logic/member/updateMemberLogic.go、internal/logic/member/removeMemberLogic.go、internal/logic/user/updateUserLogic.go、internal/logic/user/updateUserStatusLogic.go、internal/logic/user/setUserPermsLogic.goGuardRoleLevelAssignable 的修复方式是"在做 role 分配决策前 FindMinPermsLevelByUserIdAndProductCode 走一次 NoCache 查 DB"。但同样类型的 TOCTOU 问题在更一般的 CheckManageAccess 里没修:
go
// access.go:312
if caller.MinPermsLevel >= targetLevel {
return response.ErrForbidden("无权管理权限级别高于或等于您的用户")
}
这里 caller 来自 middleware.GetUserDetails(ctx),也就是 UserDetailsLoader.Load 缓存的那份;TTL 5 分钟。
攻击路径:
MinPermsLevel=10(总监级)降到 20(经理级)。超管调用业务接口时会触发 UserDetailsLoader.Clean(C.UserId),但 Redis 抖动 / 集群主从切换期间,Clean 单次失败只会被 logx.Errorf,没有重试、没有降级旁路。RemoveMember / UpdateMember / UpdateUser / UpdateUserStatus / SetUserPerms 去管理一个 MinPermsLevel=15 的目标 D。checkPermLevel 读到 C 的缓存 MinPermsLevel=10,判定 10 >= 15 为 false → 放行。C 的实际级别 20,20 >= 15 为 true,本应被拦。GuardRoleLevelAssignable 修的是"C 用旧身份去授角色给别人"的路径;checkPermLevel 漏的是"C 用旧身份去直接动别人"的路径。两条路径一个在出一个在改,安全边界对称才叫完整。CheckManageAccess),且 RemoveMember / UpdateUserStatus 可以直接产生不可逆的破坏性操作(把管理员从产品踢出、冻结账号)。修复方案:
checkPermLevel 对 caller.MinPermsLevel 采用与 GuardRoleLevelAssignable 一致的策略:
freshCallerLevel, err := svcCtx.SysRoleModel.FindMinPermsLevelByUserIdAndProductCode(ctx, caller.UserId, productCode)
if err != nil {
if errors.Is(err, sqlx.ErrNotFound) {
// caller 当前已无产品角色 → 等同最低等级,对同级管辖一律拒绝
return response.ErrForbidden("无权管理权限级别高于或等于您的用户")
}
return response.NewCodeError(500, "校验权限级别失败,请稍后重试")
}
if freshCallerLevel >= targetLevel {
return response.ErrForbidden("无权管理权限级别高于或等于您的用户")
}
loadFreshMinPermsLevel(ctx, svcCtx, userId, productCode) (int64, error),GuardRoleLevelAssignable 和 checkPermLevel 统一调用;同时顺手把两处的 ErrNotFound 语义文档化。UserDetailsLoader 加一个 LoadFresh(ctx, userId, productCode) 接口,授权决策点强制 bypass 缓存;普通业务依然走缓存。CreateProduct 事务已提交后,Redis 票据写入失败会把产品 + admin 用户永久孤儿化(第 7 轮 M-4 修复方案遗留的新缺陷)internal/logic/product/createProductLogic.go:109-185SysProductModel.TransactCtx 里同时 INSERT sys_product / sys_user(admin 账号)/ sys_product_member。成功后 productId 已经持久化。generateRandomHex(32) 生成 ticket —— 理论上 crypto/rand.Read 出错概率 ≈ 0,但已不在事务内。json.Marshal(&payload) —— 对固定结构体几乎不会失败,但落出错分支同样只返回 500。Redis.SetexCtx(ticketKey, …) —— 这里才是真实风险面。Redis 短暂不可用 / 超时 / 集群 failover 都会让 Setex 返 err。
落到任一 fail 分支时:sys_product 里新建的产品记录已经落盘;admin_<code> 账号已经落盘,bcrypt 密码是我们刚刚在内存里生成、随 500 响应丢弃的那串随机 12 字节;DeleteProductLogic(只有 CreateProduct / UpdateProduct / ProductList / ProductDetail);ChangePasswordLogic,且它要求 oldPassword 校验通过;CreateProduct 再试一次会命中 product.Code / admin_<code> 的 FindOneByCode / FindOneByUsername 前置判定,直接 ErrConflict。
运维只能下场直接 SQL:要么跑一次 UPDATE sys_user SET password = ? WHERE username = ? 硬改 admin 密码,要么 DELETE 三张表对应数据。这两种手法都绕过了业务不变式和审计日志。Redis.SetexCtx 失败时,在同一 handler 内做补偿 DB 事务——删除刚才创建的 sys_product / sys_user / sys_product_member,回到"从未创建"状态再返 500。为防止补偿事务自己也失败,至少要把 productId / adminUserId 落一条 logx.Errorw("createProduct compensation required", ...) 带有 ERROR 等级 + 结构化字段,方便告警侧接管人工回捞。SuperAdmin 专用接口 RegenerateInitialCredentials(productCode) —— 找到 admin_<code>,重新生成随机密码、bcrypt 后写 DB,再走一次 SetexCtx + 返回新的 credentialsTicket;与 ChangePassword 解耦,有独立审计日志字段 audit=regenerate_init_cred。sys_outbox 行同时写;单独 worker 消费 outbox,驱动 Redis 写入;对调用方接口异步化(响应体先给 ticketId,轮询拉真值)。落地成本高,按本仓规模不建议。CreateProduct 集成测试中把 Redis.Setex 做成主动注错路径,断言 "产品不存在 / admin 用户不存在(方案 1)" 或 "可以通过 Regenerate 拿回凭证(方案 2)",防止未来重构又漏掉这一路径。SyncPermsError{Code: 404} 在 gRPC 映射里被同化成 codes.Internal,接口契约泄露 DB 语义internal/logic/pub/syncPermsService.go:75-79(事务内 LockByCodeTx 返 ErrNotFound → SyncPermsError{Code: 404})internal/server/permserver.go:67-83(gRPC 层映射)switch se.Code 只列了 400 / 401 / 403 / 409,其余(含 404 / 500 / 任何异常值)一律落 default → codes.Internal。于是:FindOneByAppKey 已经能 hit 到一条 sys_product,但事务内 LockByCodeTx 又 ErrNotFound(极罕见:并发 "DeleteProduct" —— 虽然目前没有 Delete 入口,但一旦加上就中招);codes.Internal + 消息 "产品不存在",但 SDK 侧的重试策略一般对 Internal 是 "not retriable" —— 调用方会直接当作永久错误上报,而我们实际希望它是 codes.NotFound 让其按 404 处理。go
case 404:
return nil, status.Error(codes.NotFound, se.Message)
同时把 HTTP 侧的 response.NewCodeError(se.Code, …) / gRPC 侧的映射表都抽到一个 mapSyncPermsErr helper,避免两边漂移。
RefreshToken CAS 成功后,若 GenerateAccessToken / GenerateRefreshTokenWithExpiry 失败,tokenVersion 已递增但客户端拿不到新令牌,用户被强制退出internal/server/permserver.go:198-229IncrementTokenVersionIfMatch 成功把 DB 的 tokenVersion 从 N 增到 N+1,返回 newVersion。UserDetailsLoader.Clean(ctx, claims.UserId) 清缓存(此时 TokenVersion 在 DB 是 N+1,但在用户手上的 refreshToken 还是 N)。return nil, status.Error(codes.Internal / codes.Unauthenticated, ...)。客户端的老 refreshToken 因为 tokenVersion 对不上,下一次刷新会被 claims.TokenVersion != ud.TokenVersion 一刀切成 "登录状态已失效"。用户必须完整重登,体验上等同于被踢下线。audit=refresh_post_cas_sign_fail userId=X oldVer=N newVer=N+1 的 ERROR 日志,并把返回消息改成用户可感知的 "登录刷新失败,请重新登录"(保留现有行为但补上 observability)。sysPermModel.go SQL 仍用 fmt.Sprintf("... status = %d", consts.StatusEnabled),与 L-4 本轮修复风格不一致位置:internal/model/perm/sysPermModel.go:59,102,135
query := fmt.Sprintf("SELECT `code` FROM %s WHERE `productCode` = ? AND `status` = %d", m.table, consts.StatusEnabled)
findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `productCode` = ? AND `status` = %d", sysPermRows, m.table, consts.StatusEnabled)
updateQuery := fmt.Sprintf("UPDATE %s SET `status` = %d, `updateTime` = ? WHERE `productCode` = ? AND `status` = %d", m.table, consts.StatusDisabled, consts.StatusEnabled)
sysUserRole / sysUserPerm / sysRole 三处改成了占位符 ? + 参数传 consts.StatusEnabled,但 sysPermModel 的三条查询还是走 fmt.Sprintf 把 int 直接嵌到 SQL 里。问题不是注入(consts.StatusEnabled 是编译期常量),而是:StatusEnabled 从 int 改成 int8 / uint8 / ActiveStatus enum,%d 可能要改,占位符版本可以稳定。sysPermModel 的三处仍然把数字编进 SQL 字符串(虽然是间接通过 Sprintf),与 L-4 的意图偏离。go
query := fmt.Sprintf("SELECT `code` FROM %s WHERE `productCode` = ? AND `status` = ?", m.table)
// 调用处:
QueryRowsNoCacheCtx(ctx, &dest, query, productCode, consts.StatusEnabled)
三条一致处理;BatchUpdateWithTx 里的 status = ? 同理。CountActiveAdminsTx 零调用仍在接口里公开(第 7 轮 L-5 部分遗留)internal/model/productmember/sysProductMemberModel.go(接口 + 实现)FindMapByProductCodeUserIds / FindMapByProductCode(非事务版)从模型接口里干掉。但与之同批引入的 CountActiveAdminsTx(不带 Other)在业务层的实际调用方是 CountOtherActiveAdminsTx;CountActiveAdminsTx 自身只在 mock / test 里被引用。验证方式:
rg -n 'CountActiveAdminsTx' internal/logic | wc -l # 0
rg -n 'CountOtherActiveAdminsTx' internal/logic | wc -l # >0
CountActiveAdminsTx 还是 CountOtherActiveAdminsTx?"的歧义)。CountActiveAdminsTx;保留一条 CountOtherActiveAdminsTx(业务语义是"除自己以外还有几个活跃 admin",刚好吻合"不能移除/降级最后一个 admin"的不变式)。CheckManageAccess 对 caller DeptId=0 且非 ADMIN / SuperAdmin 的历史账号直接 403(第 7 轮 L-7 未消化)internal/logic/auth/access.go(checkDeptHierarchy 内 caller.DeptId == 0 || caller.DeptPath == "" 分支)DeptId=0 的 MEMBER / DEVELOPER,即使在自己的产品范围内想做简单的"看自己 / 改自己"操作,也会被 checkDeptHierarchy 拒 403(除非上层已短路)。sql
UPDATE sys_user SET deptId = <DEFAULT_NORMAL_DEPT_ID>
WHERE deptId = 0 AND isSuperAdmin = 0 AND (
userId IN (SELECT userId FROM sys_product_member WHERE memberType != 'ADMIN')
OR userId NOT IN (SELECT userId FROM sys_product_member)
);
同时 UserDetailsLoader.CleanByUserIds 一次性批量清缓存。CheckManageAccess 早期追加
go
if caller.UserId == targetUserId {
return nil
}
(这一条已经在 L60-69 实现了,但仅对"看自己"生效;真正被 403 的路径是"看同部门的人"——仍需要数据修复)。
### L-4. SetUserPerms 在 FindByIds 校验与 BatchInsertWithTx 之间存在 perm 状态 TOCTOU
- 位置:internal/logic/user/setUserPermsLogic.go:90-130
- 描述:
- L95-109:循环检查 dbPerms 里每条权限都 ProductCode == productCode 且 Status == StatusEnabled;
- L112-131:在新事务里 DeleteByUserIdForProductTx + BatchInsertWithTx。
- 两段之间若并发一次 SyncPermissions 把某个 permId 的 status 置成 DISABLED,本次 SetUserPerms 依然会把那条 user_perm 落库。
- 影响:loadPerms 在组装缓存时是 JOIN sys_perm WHERE sys_perm.status = ?(L-4 修过的部分),失效权限不会真正生效。本漏洞不造成越权,但会留下一行"脏"sys_user_perm,让审计查询 "X 拥有哪些权限" 与 "X 实际享有哪些权限" 出现微小偏差,是数据一致性噪声。
- 修复方案(按实际需要决定是否做):
1. 把 FindByIds 挪到事务内,并对 sys_perm 这几行加 FOR SHARE(SyncPerms 已经通过 LockByCodeTx 在 sys_product 行上串行化;这里加 FOR SHARE 只是为了在事务边界内读到一致的 status,开销很低);
2. 或者最廉价:在 BatchInsertWithTx 之后补一条 COUNT(*) 校验 —— "我刚才插入的 permId 全部仍是 Enabled",不满足就主动回滚事务。任一方案都能把 TOCTOU 窗口缩到零。
---
## 📋 修复优先级汇总
| 优先级 | finding | 一句话概要 |
| :----- | :------------------------------------------------------------ | :------------------------------------------------------------------------------------------ |
| P0 | H-1 UserDetail/UserList PII 暴露(3 轮未落地) | 任意同产品 MEMBER 可读全员手机邮箱备注,违反 PIPL 最小必要 |
| P0 | H-2 checkPermLevel 仍读缓存 MinPermsLevel(M-3 另一半) | 降级 admin 在 5 分钟 TTL 内仍可 RemoveMember / UpdateMember / SetUserPerms |
| P1 | M-1 CreateProduct Redis 写失败 → 产品 + admin 孤儿化 | 一次 Redis 失败 → 永久不可恢复,只能手动 SQL |
| P1 | M-2 SyncPermsError 404 → codes.Internal 契约错位 | 接入方 SDK 的错误分类/重试策略失真 |
| P1 | M-3 RefreshToken CAS 后签名失败 → 用户被强制登出 | tokenVersion 已推进,客户端必须重登,可用性事件 |
| P2 | L-1 sysPermModel 仍用 fmt.Sprintf(L-4 风格漂移) | 三条 SQL 统一改占位符,接近审计一致性 |
| P2 | L-2 CountActiveAdminsTx 零调用(L-5 遗留) | 接口层可见的僵尸方法 |
| P2 | L-3 CheckManageAccess 对 DeptId=0 老账号 403(L-7) | 需数据迁移或 "看自己" 短路 |
| P3 | L-4 SetUserPerms FindByIds / BatchInsert TOCTOU | 不越权,但会留脏 sys_user_perm 行 |
---
## 🛠 建议修复次序
1. 本轮必修 P0(对称封口):
- H-1:filterPIIForCaller + CanViewContact 两个 helper,落到 UserDetail / UserList 返回前。
- H-2:checkPermLevel 把 caller.MinPermsLevel 换成 loadFreshMinPermsLevel(...);与 GuardRoleLevelAssignable 共享同一个 helper。两条 P0 建议同批上线,一起做回归单测。
2. P1(稳定性与契约):
- M-1:CreateProduct 失败路径补偿(删 sys_product + sys_user + sys_product_member)或新增 SuperAdmin-only RegenerateInitialCredentials;集成测试强制注入 Redis fault。
- M-2:SyncPermsError{Code:404} 补到 switch 里,映射到 codes.NotFound;抽 mapSyncPermsErr 统一 HTTP / gRPC。
- M-3:RefreshToken 重排为"先签新 token 成功才做 CAS";或至少给 post-CAS 失败路径加 audit 字段。
3. P2 / P3 收尾:
- L-1:sysPermModel 三条 SQL 改占位符;
- L-2:CountActiveAdminsTx 删除(接口 + 实现 + mock),CountOtherActiveAdminsTx 保留;
- L-3:数据迁移脚本批量把历史 deptId=0 账号挪到默认部门,然后 CleanByUserIds 刷缓存;
- L-4:SetUserPerms 要么把 FindByIds 挪进事务 + FOR SHARE,要么事务末补一条 status 复核 COUNT(*)。
---
## 🔎 备注:本轮已验证仍在正轨上的机制
下列机制经过完整阅读后认为仍然安全、无需调整,特此备注以免未来误改:
- UserDetailsLoader:H-1(deny fail-close)、M-1(半加载不写缓存 + 中间件按 (ud, err) 分流 401/503)、L-3(loadPerms 任一子步骤错误都 fail-close)、L-6(负缓存 TTL=10s + 写前 FindOne)全部落地。
- RefreshToken / Logout 走 IncrementTokenVersionIfMatch / IncrementTokenVersion 两把刀,前者是 CAS(单会话轮转),后者是"无条件大杀器"(强制全量失效),L-2 的 WARN 注释已就位。
- DeleteDeptLogic 的锁序列:X(target dept) → FOR SHARE(children) → FOR SHARE(users in dept range),AB-BA 死锁风险大幅收敛。
- SyncPermissions 通过 LockByCodeTx 把同 product 的 sync 串行化,sys_perm UNIQUE 再撞 1062 会落一条 audit=mysql_error_1062 ERROR 日志,足以触发告警。
- LoginLogic / AdminLoginLogic / UserInfoLogic 里的 Email / Phone 返回属于"看自己"合法场景,本轮不纳入 H-1 修复范围。
- ExtractClientIP 对 X-Forwarded-For / X-Real-IP 做了严格解析 + firstValidIP 过滤,gRPC 侧 net.SplitHostPort 剥端口(M-7 已修)。
- CheckAddMemberAccess 对 ADMIN 也强制走部门链校验 + 拒绝 target.IsSuperAdmin,H-3 入口已封住。