# 审计报告:module/base/mgt ## 1. 模块概览 `module/base/mgt` 是后管基础模块(服务名 `mgt`),承载后台管理的登录、用户、应用、角色、权限、部门六大子域,对外暴露 Gin REST 接口,前缀 `/rest/mgt`(`cmd/main/main.go:25` + `internal/routers/register.go:21`)。 - 技术栈:Gin `v1.12.0` + GORM `v1.31.2`(MySQL/PostgreSQL)+ Redis + `gin-contrib/sessions` cookie store + etcd(`go.mod:5-14`)。 - 鉴权模型:JWT(HS256,`Authorization` 头)+ SDK `middleware.JwtAuth(true)`(校验过期)+ 本模块自定义的 `mgtmw.RequireAdmin()`(仅要求「root 账号或名为『超级管理员』的角色」)。**没有基于权限点(permission code)的接口级鉴权**。 - 分层:`cmd/main`(进程入口)→ `internal/routers`(路由)→ `internal/logic/*`(业务)→ `internal/models`(GORM 模型 + 自动迁移 + root 种子数据)→ `internal/impl`(Redis/DB/etcd/go-cache 全局单例)。 - 3 个 `etc/*.yaml` 配置(dev/test/prod),prod 的 DB/Redis/SecretKey 均为 `CHANGE_ME` 占位符。 - 规模:78 个非 pb Go 文件、约 5269 行(与任务描述一致);`internal/logic` 下 7 个子包,`internal/models` 下 12 个模型 + 8 张关联表。 关键风险画像:**默认口令 + 默认 JWT 密钥构成「开箱即被接管」的完整链路;写接口只做「超级管理员」粗粒度校验,任何拥有该角色的人可对任意用户/角色/部门全量赋权且无审计;三处 `Updates+Select` 会把请求中省略的字段写成零值(含 `account`/`phone`/`code`)。** ## 2. 审计范围与方法 ### 2.1 覆盖方式 先侦察全部非 pb Go 文件、etc 配置、`cmd/main` 与 `internal/routers/register.go` 的完整路由表,再以「路由 → 中间件 → logic 写操作」为主线逐条核对,最后核对 SDK 侧依赖(`D:\work\bsm-sdk\core`,`go.mod:84` 的 replace 指向该本地目录)、`internal/models/init_db.go` 种子数据、GORM `v1.31.2` 更新语义(读源码 + 隔离环境 DryRun 实测)。 ### 2.2 命令执行结果 | 命令 | 结果 | |------|------| | `gofmt -l .`(在 `module/base/mgt`) | 无输出,exit 0(格式干净) | | `go vet ./...`(在 `module/base/mgt`) | 无输出,exit 0(无 vet 告警) | | GORM 零值更新语义实测 | 在仓库外临时目录 `D:\work\bsm-infra\full\.builds\_tmp_gorm_probe`(已删除,未触碰任何被审计文件)对 gorm v1.31.2 打桩 DryRun 实测,用于问题 3 / 问题 4(TODO 编号 P1-3 / P1-4)的判定 | | GORM 删除钩子语义核对 | 直接阅读本地 module cache 的 `gorm.io/gorm@v1.31.2\callbacks\{delete.go,callmethod.go,update.go}` 与 `statement.go` 源码,用于 P2-17 与 P1-3/P1-4 的判定(含 `SelectAndOmitColumns` 与 `ConvertToAssignments` 的零值分支) | | 网关/gRPC 适用性核对 | `Select-String "internal/server|gwRuntime|NewServeMux|RegisterHandlerServer|Gateway"` 在 `module/base/mgt` 零命中;`*.pb.go`/`*.proto` 零命中 → 该跨模块模板缺陷对本模块**不适用**,见 2.5 | | 命名返回值/裸 `return` 核对 | 逐函数核对本模块命名返回值与裸 `return`(唯一命中 `internal/models/impl.go:37,47`,已确认不会吞错),见 2.5 附注 | 未执行 `go build`/`go test`(会写 `go.sum`/构建缓存,且非本任务要求)。 ### 2.3 已覆盖子域 - **pub**(登录/刷新/重置密码):`login.go`、`refresh.go`、`forget.go` 全文。 - **middleware**:`rbac.go` 全文(`RequireAdmin`/`IsSuperAdmin`/`EnsureSelfOrAdmin`)。 - **models**:`impl.go`、`init_db.go`、`user.go`、`role.go`、`permission.go`、`application.go`、`department.go`、全部 8 张 `link_*.go`、`query.go`(空文件)。 - **user**:`create.go`、`del.go`、`detail.go`、`fetch.go`、`list.go`、`modify.go`、`set_role.go`、`set_pmn.go`、`fetch_app.go`、`fetch_pmn.go`、`fetch_role.go` 全文。 - **role**:`create.go`、`del.go`、`detail.go`、`fetch.go`、`fetch_other.go`、`modify.go`、`set.go` 全文。 - **permission**:`create.go`、`del.go`、`detail.go`、`fetch.go`、`fetch_other.go`、`modify.go`、`sort.go` 全文。 - **department**:`create.go`、`del.go`、`detail.go`、`fetch.go`、`fetch_pmn.go`、`fetch_pmn_tree.go`、`fetch_tree.go`、`list.go`、`modify.go`、`set_pmn.go`、`user.go` 全文。 - **application**:`create.go`、`del.go`、`detail.go`、`fetch.go`、`fetch_other.go`、`modify.go` 全文。 - **其他**:`cmd/main/main.go`、`internal/config/config.go`、`internal/impl/{impl,with}.go`、`internal/libs/validator.go`、`internal/types/{req,resp,types,vars}.go`、`internal/logic/{hello,tool}`、`service/{expose,dependencies}.go`、`etc/mgt_{dev,test,prod}.yaml`、`doc/*.md`(7 篇)、`test/*.http`(54 个)、`internal/routers/register_test.go`。 ### 2.4 未覆盖 / 无法验证 - **未做动态验证**:无可用 DB/Redis 实例,所有越权、爆破、数据损坏结论均为**静态代码推演**;GORM 零值语义除外(已 DryRun 实测)。 - **未审计 SDK 全量**:仅按需审计了 `core/middleware/{jwt,cors}.go`、`core/crypto/token/jwt.go`、`core/env/env.go`、`core/service/meta.go`、`core/types/db.go`、`core/vars/{jwt,status}.go`,未覆盖 SDK 的 DB/Redis 连接、`infra.Response` 序列化、`printer` 日志落盘等实现细节。 - **未审计跨模块调用方**:`module/all` 如何挂载 `service.Expose`(是否叠加网关级限流/鉴权)、`module/base/sender` 如何写入短信验证码键(仅确认共用前缀 `/SMS/Code/`)。 - **未逐条核对 54 个 `test/*.http`**:仅确认其为请求样例、无断言。 - **前端/网关**:`bsm-infra/gateway` 是否对 `/rest/mgt` 做额外保护、是否强依赖 `BSM_JwtSecretKey` 环境变量,未验证(无法从本仓库确认部署态环境变量)。 ### 2.5 关于「internal/server 只注册 gRPC 导致 HTTP 网关不可用」这一跨模块模板缺陷:本模块不适用 该缺陷**不适用于 mgt**,无需为本模块计入风险。依据: 1. mgt 是纯 Gin HTTP 模块,**没有任何 gRPC/网关/protobuf 代码**——全模块 `Select-String "internal/server|gwRuntime|NewServeMux|RegisterHandlerServer|Gateway"` 零命中,且 `module/base/mgt` 下**不存在任何 `.pb.go` 与 `.proto` 文件**。 2. 独立部署入口自建 `*gin.Engine` 并直接注册路由:`cmd/main/main.go:32-42`(`app := gin.Default()` → `routers.Register(ServiceKey, app)`),全程不经过 gRPC Mux。 3. `service/expose.go:17-23` 的 `func Expose(options ExposeOptions) error` 在本仓库内**无任何调用方**(全模块仅此定义处命中),属于供外部宿主使用的可选入口;它同样只是把 `options.Engine` 交给 `routers.Register`,不涉及 gRPC 网关。因此该全局模板缺陷对本模块既不影响部署可用性,也不构成独立风险。 附:本模块**未发现**「命名返回值未赋值 → 恒返回 nil」类缺陷。唯一使用命名返回值 + 裸 `return` 的位置是 `internal/models/impl.go:37`(`func New(...) (err error)`)的 `:47`,其 `err` 在 `switch` 各分支已被 `:42`/`:44` 赋值,且 `:46` 的 `log.Fatalln` 不会返回,故裸 `return` 不会吞错;其余函数均为显式返回值(`gofmt -l`/`go vet ./...` 亦无告警)。 ## 3. 问题清单 ### P0 #### 1. root 默认口令 `123456` 硬编码并在启动时自动播种(README 审计记录已证实仍存在) - **位置**:`module/base/mgt/internal/models/init_db.go:15-17`、`:60`;配置开关 `module/base/mgt/etc/mgt_dev.yaml`(`InitRootUser: true`);触发点 `module/base/mgt/internal/models/impl.go:69-71`;文档 `module/base/mgt/README.md:168-172`、`module/base/mgt/test/login.http:5-6` - **证据**: ```go // internal/models/init_db.go:14-17 salt := utils.UUID() account := "root" password := "123456" hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password+salt), bcrypt.DefaultCost) ``` ```go // internal/models/impl.go:69-71 —— AutoMigrate 后紧跟 root 播种 if config.InitRootUserEnabled() { InitRootUserData() ``` ```yaml # etc/mgt_dev.yaml InitRootUser: true ``` ```markdown - **账号**: `root` - **密码**: `123456` > ⚠️ **安全提示**: 生产环境请务必修改默认密码! ``` - **影响**:任何能访问 `/rest/mgt/login` 的人知道口令即可登录 root;而 `IsSuperAdmin` 对 `account == "root"` 直接返回 true(`internal/middleware/rbac.go:24-26`),因此**默认口令一条请求即等于全系统最高权限**(可改任意用户密码、赋任意权限、删除任意应用/部门)。`etc/mgt_dev.yaml` 已将开关打开,若该文件被用于任何可达网络的环境(README 的快速开始正是让开发者照此配置),即为可被直接利用的接管漏洞。 - **建议**:删除硬编码口令,改为「首次启动从环境变量/secret 读取初始口令,缺失则随机生成 24 位并一次性打印到 stdout 且标记强制改密」;`InitRootUserData` 增加「已初始化则跳过」之外的二次确认;把 `README.md`/`test/login.http` 中的明文口令改为占位符。 #### 2. JWT 密钥存在硬编码默认值 `Cblocksmesh2022C`,可离线伪造任意身份(含 root) - **位置**:SDK `D:\work\bsm-sdk\core\env\env.go:19`;本模块签发与校验均取该值(`module/base/mgt/internal/logic/pub/login.go:90`、`internal/logic/pub/refresh.go:28`);`go.mod:84` 将 SDK 指向本地目录 `../../../../../bsm-sdk/core` - **证据**: ```go // D:\work\bsm-sdk\core\env\env.go:17-22 Runtime = &types.RuntimeEnv{ Workspace: GetEnvDefault("BSM_Workspace", "default"), JwtSecretKey: GetEnvDefault("BSM_JwtSecretKey", "Cblocksmesh2022C"), ``` ```go // internal/logic/pub/login.go:90 resp.Token, err = token.New(env.Runtime.JwtSecretKey).GenerateJwt(uint(user.ID), user.Identity, c.ClientIP(), "", nil, extend) ``` 默认值长度恰为 16 字节,通过 `core/crypto/token/jwt.go:34-37` 的 `keyLen == 16 || 24 || 32` 校验,因此**在未设置 `BSM_JwtSecretKey` 的部署里签发与验签都会静默使用这个公开字符串**。 - **影响**:攻击者用公开默认密钥自行 HS256 签名一个 `{"id":1,...}` 的 token 即可通过 `JwtAuth(true)` 的过期校验与验签,被 `RequireAdmin` 认定为 root(ID=1)。`etc/*.yaml` 里的 `SecretKey: CHANGE_ME` 与 JWT 无关(仅用于 session key 拼接),运维极易忽略该环境变量。此外 SDK 解析器不校验 `alg` 白名单,一旦密钥泄露也无法通过轮换密钥快速止损(无 kid/版本)。 - **建议**:SDK 侧改为「未显式配置即 `log.Fatal` 退出」;`mgt` 在 `config.New` 中显式校验 `env.Runtime.JwtSecretKey` 长度 ≥32 且不等于内置默认值,否则拒绝启动;签发时使用 `jwt.WithValidMethods([]string{"HS256"})`。 ### P1 #### 3. `user/modify` 用 `Select`+结构体更新,请求中省略的字段被写成零值(禁用账号 + 擦除 account/phone/email/avatar) - **位置**:`module/base/mgt/internal/logic/user/modify.go:57-72`(路由 `POST /rest/mgt/user/modify`,`internal/routers/register.go:60`) - **证据**: ```go // user/modify.go:62-69 userModel := models.MgtUser{ Name: request.Name, Phone: request.Phone, Email: request.Email, Avatar: request.Avatar, Std_IICUDS: coreTypes.Std_IICUDS{Status: request.Status} } updateFields := []string{"name", "phone", "email", "avatar", "status"} ... impl.DBService.Model(&models.MgtUser{}).Where("id = ?", request.ID).Select(updateFields).Updates(&userModel) ``` 隔离环境对 gorm v1.31.2 的 DryRun 实测(同构结构体 + 同 `Select` 列表): ``` B with-select SQL: UPDATE t SET status=?,name=?,title=?,desc=? WHERE id = ? B with-select VARS: [0 0 t3 t3 1 1] # 未提供的列仍被写入零值 A no-select SQL: UPDATE t SET title=? WHERE id = ? # 对照组:仅更新非零字段 ``` 即 `Select` 显式列名会强制写入零值。`internal/types/req.go:9` 的 `Status int8` 无默认值,请求体不带 `status` 时写入 0(`core/types/db.go:34`:0=未定义,-1 禁止,1 正常)。 - **影响**:任何超管调用一次「只改姓名」的 `POST /user/modify`,就会把该用户的 `account`、`phone`、`email`、`avatar` 清空、`status` 置 0;由于 `login` 只在 `stat == vars.DisabledStatus(-1)` 时拒绝(`pub/login.go:118-123`),账号不会被锁死但已无法用 account 登录、无法按手机号找回密码,且 `account` 是 `uniqueIndex`,第二个被清空的用户会触发唯一冲突导致更新失败。属于确定性数据损坏。 - **建议**:改用 `map[string]interface{}` 只装请求中显式提供的字段(配合 `*int8`/指针类型区分「未提供」与「零值」),或先 `First` 取原记录再局部赋值;同时为 `modify` 增加「不允许修改 root 账号」的保护。 #### 4. `permission/modify` 同样以零值覆盖 `code` / `parent_id` / `title` / `menu_path` / `component` 等字段 - **位置**:`module/base/mgt/internal/logic/permission/modify.go:123-144`(路由 `POST /rest/mgt/pmn/modify`,`register.go:109`);重复分支 `:67-89` - **证据**: ```go // permission/modify.go:140-141 updateFields := []string{"status", "code", "title", "parent_id", "menu_path", "menu_icon", "type", "component", "description", "is_web_page", "is_new_tab", "is_full", "hide_menu", "web_url"} impl.DBService.Model(&models.MgtPermission{}).Where("id = ?", request.ID).Select(updateFields).Updates(&dataModel) ``` `request.Code` 等来自 `types.PermissionRequest`(`internal/types/req.go:57-76`),全部 `omitempty`,省略即为零值。 - **影响**:只改标题的一次调用会把该权限的 `code` 清空、`parent_id` 归 0(菜单从二级掉到顶级)、`menu_path`/`component` 清空,导致前端菜单整体错乱;`code` 是 `uniqueIndex:idx_mgt_pmn_app_code` 的联合唯一键(`internal/models/permission.go:10-12`),清空后同应用第二条修改即唯一冲突失败。与 P1-3 同类,合并修复。 - **建议**:同 P1-3,或至少对 `Code`/`Title` 做「空则沿用原值」的显式回填。注意 `role/modify.go:62`、`application/modify.go:66`、`department/modify.go:107` **未使用 `Select`,实测不会写零值**(对照组 A),无需改动——这一点已用 DryRun 验证,勿被同类代码误导。 #### 5. 赋权接口不校验权限与应用的归属,且放任权限跨应用/自引用成环(越权与树污染) - **位置**:`module/base/mgt/internal/logic/user/set_pmn.go:33-63`、`:90-125`;`internal/logic/role/set.go:33-66`、`:92-126`;反例(做对了的写法)`internal/logic/department/set_pmn.go:42-64` - **证据**: ```go // user/set_pmn.go:40-47 —— 只校验 v != 0,未校验权限是否属于该 appId for _, v := range request.List { if v == 0 { ... } pmnData = append(pmnData, models.MgtLinkUserPmn{AppId: appId, PmnId: v, UserId: request.Id}) ``` 部门侧有归属校验(可对照),用户/角色侧缺失: ```go // department/set_pmn.go:51-55 if dpt.AppID != appId { printer.Error("部门不属于当前应用: dpt_id=%d", request.Id) infra.Response.Error(c, errcode.ErrInvalidArgument) ``` 另一个环/跨应用来源:`permission/create.go:71` 与 `permission/modify.go:72,127` 直接落库 `ParentID: request.ParentID`,既不校验父权限是否属于同一 `appId`,也不做自引用/后代检查。 - **影响**:写入 `mgt_link_user_pmn{app_id=A, pmn_id=}` 这类跨应用脏关联后,`user/fetch_pmn.go:210-217` 的权限树按 `app_id = apps[i].ID AND id IN ?` 查询会查不到该权限却仍把它算作「已授权」,前端展示与后端判定长期不一致;父级成环会让 `department/fetch_pmn_tree.go:109-130` 的 `buildPermTreeFilter` 递归不收敛(栈溢出 DoS),与 P1-11 联动。 - **建议**:所有 `set_pmn`/`modify_pmn` 在写关联前用一条 `SELECT id FROM mgt_permission WHERE id IN ? AND app_id = ?` 校验集合完整性,数量不符即拒绝;权限创建/修改时校验 `parent_id` 属于同应用且不等于自身、且不在自身后代集合内(复用 `department/del.go:17` 的递归工具)。 #### 6. 重置密码接口匿名可调,无验证码尝试次数限制 / 无限流 / 无验证码,且账号手机号可枚举 - **位置**:`module/base/mgt/internal/routers/register.go:36`(`anonymous.POST("/reset", pub.ForgetPwdBySms)`);实现 `internal/logic/pub/forget.go:22-99` - **证据**: ```go // register.go:27-38 —— 整个 anonymous 组的 JwtAuth 被注释掉 func registerAnonymous(base string, engine *gin.Engine) { anonymous := engine.Group(base) { // anonymous.Use(middleware.JwtAuth()) anonymous.GET("/ping", hello.Ping) anonymous.GET("/session", hello.SessionDemo) anonymous.POST("/login", pub.Login) anonymous.POST("/refresh", pub.Refresh) anonymous.POST("/reset", pub.ForgetPwdBySms) // 重置密码 ``` ```go // pub/forget.go:57-73 —— 比对通过即改密;全程无尝试计数、无失败锁定、无图形验证码 smsKey := types.KeyPrefix + request.Phone storedCode, err := impl.RedisService.Client.Get(impl.RedisService.Ctx, smsKey).Result() ... if storedCode != request.Code { ... infra.Response.Error(c, errcode.ErrInvalidArgument); return } ``` 账号/手机号比对失败与验证码错误的响应码不同(`forget.go:51-55` 与 `:69-73`),可用于枚举。 ```go // pub/login.go:64-71 账号不存在返回 ErrRecordNotFound,密码错返回 ErrPassword —— 可直接枚举账号 ``` - **影响**:短信验证码为 6 位数字(`doc/pub.md:123-127`),本模块对同一手机号不限制尝试次数,验证码 TTL 内可爆破验证码 + 已知/枚举出的 `account`+`phone` 组合即可**匿名重置任意用户(含 root)的密码并接管账号**;`/login` 与 `/reset` 的错误码差异使攻击者可以先枚举出合法账号与绑定手机号。注意 `test/reset.http:7` 使用 `"code": "123456"` 暗示测试环境验证码固定。**边界说明**:验证码的生成、TTL 与下发不在本模块内(本模块只读取 `types.KeyPrefix = "/SMS/Code/"`,与 `module/base/sender/internal/logic/sms/const.go:5` 同前缀,写入方为 sender),因此 TTL 长度与「同一验证码可尝试次数」**无法从本模块源码确证**,属推测;可确证的是本模块侧没有任何尝试计数与限流代码。 - **建议**:`/reset` 与 `/login` 加 IP+账号维度限流(Redis 计数 + 指数退避)、验证码错误计数(如 5 次即作废该验证码)、统一「账号/验证码错误」响应码与响应耗时;为 `/reset` 加图形/滑块验证码;对 root 等高权限账号强制二次校验(邮箱/OTP)。 #### 7. 全部管理端接口仅以「root 或角色名为『超级管理员』」做粗粒度授权,无接口级权限点校验 - **位置**:`module/base/mgt/internal/middleware/rbac.go:19-54`;路由挂载 `internal/routers/register.go:42-43,56,72,86,104,118` - **证据**: ```go // rbac.go:14-16, 30-36 const ( superAdminRoleName = "超级管理员"; rootAccount = "root" ) ... for _, role := range user.Roles { if role.Name == superAdminRoleName { return true } } ``` ```go // rbac.go:47-51 —— 唯一判定;路由表里没有任何 per-route 权限点 if !IsSuperAdmin(auth.ID) { infra.Response.Error(c, errcode.ErrPermissionDenied) ``` `internal/types/vars.go:5-6` 定义了 `Admin = "SYSADMIN"`、`Staff = "STAFF"`,但全模块**无任何引用**(仅此一处定义)。 - **影响**:权限体系(`mgt_permission.code`、`mgt_link_role_pmn`、`mgt_link_user_pmn`、部门权限)只用于「界面展示哪些菜单」,**不参与任何接口鉴权**。任何被授予「超级管理员」角色的账号可越权访问全部 50 个管理端接口,包括给任意用户/角色赋任意权限(P1-5)、改任意用户密码。同时系统只支持「超管」一档权限,无法实现「管理员只能管本应用/本部门」的垂直隔离。`RequireAdmin` 判定依赖角色**名称字符串**,重命名角色即失效(`role/modify.go` 允许改名)。 - **建议**:建立路由→权限点(`permission.Code`)映射中间件(如 `RequirePmn("mgt:user:modify")`),逐个接口挂载;将「超管」判定改为不可变标识(`role.Identity` 或专用 flag)而非 `name`;补齐 `Admin`/`Staff` 或删除死常量。 #### 8. `role/del` 按 `(user_id, pmn_id)` 笛卡尔积删除用户直接权限 → 误撤销经其他角色获得的权限 - **位置**:`module/base/mgt/internal/logic/role/del.go:40-61`(路由 `POST /rest/mgt/role/del`) - **证据**: ```go // role/del.go:55-61 if len(pmnId) > 0 && len(userIds) > 0 { if err := tx.Where("user_id in ? and pmn_id in ?", userIds, pmnId). Delete(&models.MgtLinkUserPmn{}).Error; err != nil { ``` 对比 `internal/logic/user/set_role.go:120-181`(`DelRole`)——那里**做了**「该权限是否由用户其他角色提供」的差集判断(`otherPmnSet`),可证明此处是遗漏而非设计。 - **影响**:删除角色 R 时,只要某用户同时拥有另一角色 R2(R2 也含权限 P),该用户的直接授权 `(user, P)` 会被一并删除,导致「不应失去的权限」丢失;同时该操作**不清理部门维度(`mgt_link_dpt_role`)之外的应用关联**,而 `mgt_link_user_app` 本应由 `MgtLinkUserPmn.AfterDelete` 钩子在「该应用下再无任何权限」时清理——该钩子在批量删除场景下以全零接收者执行、恒为空操作(详见 P2-17,`internal/models/link_user_pmn.go:26`、`link_role_pmn.go:26`),因此用户/角色与应用的关联会残留成孤儿,前端「我的应用」出现无权限的空应用。 - **建议**:`role/del` 复刻 `user/set_role.go` 的差集逻辑,只删除「不再由任何剩余角色提供的」直接授权;把应用关联清理从 `AfterDelete` 钩子改为事务内显式 SQL(或在批量删除后统一重算)。 #### 9. `/refresh` 匿名可调且不校验过期时间与账号状态,配合超长有效期形成可无限续期会话 - **位置**:`module/base/mgt/internal/routers/register.go:35`;实现 `internal/logic/pub/refresh.go:21-34`;SDK 解析器 `D:\work\bsm-sdk\core\service\meta.go:19-49`、`core/crypto/token/jwt.go:76-98` - **证据**: ```go // pub/refresh.go:22-28 claims, err := service.ParseMetaCtx(c, nil) // opts==nil → 不做角色校验、不做 mustPrivate 校验 ... token, err := token.New(env.Runtime.JwtSecretKey).GenerateJwt(claims.ID, claims.Identity, claims.Client, claims.Role, nil, claims.Extend) ``` `ParseMetaCtx` 只调用 `ParseJwt`(签名校验),**不调用 `IsExpired`**;`JwtAuth` 里的过期校验(SDK `middleware/jwt.go:32-47`)在 `/refresh` 上因未挂载而完全不生效。 - **影响**:持有一个已过期但签名合法的 token 即可换取新的 24 小时 token,会话实际上永不过期;token 被盗后无法通过「等它过期」止损,也无法通过改密失效(无 `SaveToken`/黑名单调用,`pub/login.go:108-116` 的 `SaveToken` 是 dead code)。token 内还携带手机号之外的 `extend`(id/Identity/status/name)。**边界说明**:`ParseMetaCtx` 仍会调用 `ParseJwt` 做 HS256 验签(`core/service/meta.go:31`、`core/crypto/token/jwt.go:64-73`),因此该缺陷**只放大到「过期 token 可续期」,并不等于可伪造 token**(伪造能力来自 P0-2 的默认密钥)。 - **建议**:`/refresh` 挂 `JwtAuth(true)`(强制过期校验),并在 handler 内回落数据库复核账号状态与是否存在,再加 `pwd_version`/`token_version` 声明实现改密即失效;补齐登出接口与 Redis 黑名单。 #### 10. 登录与重置无失败计数/锁定/限流(可无限次猜口令) - **位置**:`module/base/mgt/internal/logic/pub/login.go:35-106`(`checkPwdAndStatus` `:118-145`);`internal/logic/pub/forget.go:22-99`;`cmd/main/main.go:32-42` 未挂任何限流中间件 - **证据**: ```go // login.go:125-141 —— 失败仅返回错误,无失败计数与延迟 err := bcrypt.CompareHashAndPassword([]byte(userPwd), []byte(inPwd+salt)) if err == nil { return nil } ... if userPwd == md5Hash { return nil } return errcode.ErrPassword ``` ```go // main.go:38-42 —— 全局中间件只有 Mode 与 Cors middleware.Mode(app) app.Use(middleware.Cors()) ``` - **影响**:结合 P0-1 的弱默认口令(`123456`、`min=6` 无复杂度)与 P1-6 的账号枚举,攻击者可对 root 账号做在线字典爆破;bcrypt 的 CPU 成本也构成单请求 CPU 放大,可用于资源耗尽。 - **建议**:引入 Redis 计数限流(同 IP/账号 5 次失败锁定 15 分钟 + 递增延迟),并为弱口令建立拒绝清单。 #### 11. 部门树/权限树构建无环检测,脏数据可致无限递归(进程崩溃) - **位置**:`module/base/mgt/internal/logic/department/del.go:17-37`、`internal/logic/department/list.go:71-83`、`internal/logic/department/fetch_pmn_tree.go:109-130` - **证据**: ```go // department/del.go:29-36 —— 无 visited 集合,环即无限递归 var collectChildren func(uint) collectChildren = func(deptId uint) { result = append(result, deptId) for _, childId := range childrenMap[deptId] { collectChildren(childId) } } ``` ```go // department/list.go:71-83 func buildDeptTree(list []models.MgtDepartment, parentId uint) []models.MgtDepartment { var node []models.MgtDepartment for _, d := range list { if d.ParentID != parentId { continue }; children := buildDeptTree(list, d.ID) ... ``` 环数据的来源:`department/modify.go:51-99` 虽校验了自引用与「新父节点不是自身后代」,但该检查是**先读后写、非原子**(并发两次互为父级的修改可绕过),且 `permission.modify.go:72,127` 完全没有环/同应用校验。 - **影响**:一旦 `parent_id` 成环(并发写入或直接改库/历史数据),`POST /rest/mgt/dpt/del`、`/dpt/list?tree=true`、`/dpt/pmn_tree` 会无限递归导致 goroutine 栈溢出,进程崩溃且**每次调用都必然崩溃**(持久性 DoS)。 - **建议**:所有递归遍历加 `visited map[uint]struct{}` 并在检测到环时返回错误(同时记录脏数据告警);把树操作收敛到一个带环检测的公共工具包。 ### P2 #### 12. 创建用户/角色/权限/应用时物理删除同名的软删除记录,留下多表孤儿数据 - **位置**:`internal/logic/user/create.go:57-72`、`internal/logic/role/create.go:49-56`、`internal/logic/permission/create.go:52-58`、`internal/logic/application/create.go:46-69` - **证据**: ```go // user/create.go:57-64 var deletedData models.MgtUser if err = impl.DBService.Unscoped().Where("phone = ?", request.Phone).First(&deletedData).Error; err == nil { if err = impl.DBService.Unscoped().Delete(&deletedData).Error; err != nil { ... } printer.Info("user deleted: id=%d, phone=%s", deletedData.ID, deletedData.Phone) ``` - **影响**:为绕开 `uniqueIndex` 而**物理删除**历史用户,但 `mgt_link_user_role` / `mgt_link_user_app` / `mgt_link_user_pmn` / `mgt_link_user_dpt` 中该 user_id 的关联行仍在(无外键约束,模型里没有 `constraint` 标签)。当新用户复用到同一自增 ID 时,会**继承前一个用户的角色/权限/部门/应用关联**,造成越权与数据错乱;应用被物理删除同理会让 `mgt_permission` / `mgt_link_*` 大面积悬空。 - **建议**:不要物理删除;改为「复用并覆盖」或「先事务内清理全部关联再物理删除」;为关联表补外键或定期孤儿清理任务。 #### 13. 部门/角色/权限/应用列表查询在循环内访问数据库(N+1) - **位置**:`internal/logic/department/fetch_pmn.go:74-94` 与 `internal/logic/department/fetch_pmn_tree.go:82-89`(`buildPermTreeFilter` 每层递归重扫全量 `list`)、`internal/logic/user/fetch_pmn.go:88-99`(4 层嵌套 `Preload`) - **证据**: ```go // department/fetch_pmn_tree.go:82-89 —— 每个部门都对同一份 perms 全量重扫并建树 for i := range data { ids := dptToPmn[data[i].ID] idSet := make(map[uint]struct{}) for _, id := range ids { idSet[id] = struct{}{} } data[i].Permissions = buildPermTreeFilter(perms, 0, idSet) } ``` ```go // user/fetch_pmn.go:88-99 —— 用户→应用/直接权限/角色权限/部门权限/部门角色权限 Preload("Permissions", ...).Preload("Roles", func(db){ ...Preload("Permissions") }). Preload("Departments", func(db){ ...Preload("Permissions")...Preload("Roles", ...Preload("Permissions")) }) ``` - **影响**:`O(部门数 × 权限数^深度)` 的 CPU 开销;`Preload` 链一次请求触发约 7-8 条 SQL。数据量上千后这两个接口(前端菜单渲染必调)成为明显瓶颈。 - **建议**:一次取全部权限后按 `parent_id` 建索引字典,各层 O(1) 查找并共享 `children` 缓存;`Preload` 改为一次性 join/IN 查询后在内存组装。 #### 14. 列表分页排序不确定、`size` 无上限、`Order` 缺省 - **位置**:`internal/logic/user/fetch.go:44-48`、`internal/logic/application/fetch.go:44-48`、`internal/logic/role/fetch.go:46-50`、`internal/logic/department/fetch.go:63-67`(均无 `Order`);`internal/logic/tool/tool.go:5-14`(仅默认值,无上限) - **证据**: ```go // tool/tool.go:5-14 func GetSizeAndPage(page, size int) (int, int) { if page <= 0 { page = types.DefaultPage } if size <= 0 { size = types.DefaultSize } return page, size // 没有最大值截断 } ``` ```go // user/fetch.go:45 —— 无 Order,跨页结果不稳定 if err := db.Count(&count).Limit(size).Offset((page - 1) * size).Find(&data).Error; err != nil { ``` - **影响**:`size=100000` 可被匿名/超管用于拉全表(内存与带宽放大);无 `ORDER BY` 时 PostgreSQL/MySQL 不保证行序,翻页会重复/漏数据;`role/fetch_other.go` 的 `UserFetch`/`ApplicationsFetch`/`PermissionsFetch` 与 `pmn/user`、`pmn/role`、`dpt/user` 完全无分页。 - **建议**:`size` 上限(如 200)并在超限时 400;所有列表补 `Order("id asc")` 之类确定性排序;无分页的关联查询补 `Limit/Offset`。 #### 15. 无结构化日志与审计日志:高危操作均只写标准库 `log`/`printer`,不含操作者与变更前后值 - **位置**:全模块 `printer.Info/Error` 与 `log.Printf`(示例:`internal/logic/user/set_pmn.go:70`、`internal/logic/role/del.go:94`、`internal/logic/user/del.go:76`、`internal/logic/pub/forget.go:98`) - **证据**: ```go // user/set_pmn.go:70 printer.Info("用户权限设置成功: userId=%d", request.Id) ``` ```go // pub/forget.go:98 printer.Info("密码重置成功: account=%s, phone=%s", request.Account, request.Phone) ``` - **影响**:赋权、改密、删除用户/角色/应用这些高危动作**无法追溯操作者、来源 IP、变更内容**,`RequireAdmin` 放行后也没有留下「谁给谁授权」的记录。发生权限滥用时无法定责,也无法比对变更。相反 `pub/login.go:58,80,121,133,143` 会打印失败原因(含账号),日志中还有 PII。 - **建议**:新增 `mgt_audit_log`(操作者 ID/IP/路由/请求摘要/前后值/结果)并在所有写 handler 落库;日志改为结构化(zap)并脱敏账号/手机号。 #### 16. 三处查询引用了不存在的列名(`code like ?`)或 `?` 与参数不匹配,接口直接报错 - **位置**:`internal/logic/application/fetch.go:36-39`、`internal/logic/permission/fetch.go:37-40` - **证据**: ```go // application/fetch.go:36-39 —— mgt_application 没有 code 列;两个 ? 只传一个参数 if request.Keyword != "" { likeStr := "%" + request.Keyword + "%" db = db.Where("title like ? or code like ? ", likeStr) } ``` ```go // permission/fetch.go:37-40(同样的 2 个占位符 + 1 个参数) db = db.Where("title like ? or code like ? ", likeStr) ``` - **影响**:`mgt_application` 表结构只有 `title/workspace/description`(`internal/models/application.go:6-17`),引用 `code` 列会直接 SQL 报错 → `POST /rest/mgt/app/fetch` 带 `keyword` 必然 500;`permission/fetch` 同样带 `keyword` 必然失败。属于「接口文档宣传可用、实际不可用」的功能性缺陷。 - **建议**:`application/fetch` 改为 `title like ? or workspace like ?`(或删除 `code`),`permission/fetch` 补第二个参数。 #### 17. `MgtLinkUserPmn` / `MgtLinkRolePmn` 的 `AfterDelete` 钩子在批量删除场景下以全零字段执行,清理逻辑实际失效 - **位置**:`internal/models/link_user_pmn.go:25-39`、`internal/models/link_role_pmn.go:25-39`;调用方均为**以零值空结构体为 Dest** 的批量 `Where(...).Delete(&models.MgtLinkUserPmn{})`(`user/set_pmn.go:108,165`、`role/set.go:110,114,164`、`role/del.go:56,68`、`user/del.go:59`、`department/del.go:85`、`application/del.go:67,73`) - **证据**: ```go // link_user_pmn.go:25-38 注释明确该钩子用于清理用户-应用关联 // AfterDelete钩子:仅当该用户在该应用下不再拥有任何权限时,移除用户-应用关联 func (l *MgtLinkUserPmn) AfterDelete(tx *gorm.DB) error { var count int64 if err := tx.Model(&MgtLinkUserPmn{}).Where("app_id = ? AND user_id = ?", l.AppId, l.UserId).Count(&count).Error; ... ``` ```go // user/set_pmn.go:108 —— Dest 是零值空结构体,钩子接收者 l 的 AppId/UserId 全为 0 if err := tx.Where("app_id = ? and user_id = ?", appId, request.Id).Delete(&models.MgtLinkUserPmn{}).Error; err != nil { ``` GORM v1.31.2 源码(`callbacks/delete.go:190-199` → `callbacks/callmethod.go:9-31`):`AfterDelete` 会以 `db.Statement.ReflectValue.Interface()` 调用钩子,批量删除时该值是**零值 `MgtLinkUserPmn{}`**,因此 `l.AppId == 0 && l.UserId == 0`,`Count` 与随后的 `Delete` 都作用在 `app_id=0 AND user_id=0` 上——钩子确实被调用,但**恒为空操作**(与「批删不触发钩子」的常见说法不同,此处是传参全零)。 - **影响**:`mgt_link_user_app` / `mgt_link_role_app` 中的「0 权限应用关联」长期残留,前端出现无权限的空应用;`mgt_link_dpt_pmn` 侧则完全没有等价清理机制。即模型层注释所声明的级联维护语义**从未真正生效**。 - **建议**:删除关联后在同一事务内显式执行「清理 0 权限的应用关联」SQL(不要依赖钩子);或在服务层统一封装;同时修正模型注释避免误导。 #### 18. 部门/角色/权限/应用的「先查后建」无唯一索引兜底,并发下可产生重复数据 - **位置**:`internal/logic/department/create.go:60-70`、`internal/logic/role/create.go:44-60`、`internal/logic/permission/create.go:47-62` - **证据**: ```go // department/create.go:60-70 —— 只靠 SELECT 判重,DB 层无 (app_id,name) 唯一约束 if err = impl.DBService.Model(&models.MgtDepartment{}). Where("app_id = ? and name = ?", appId, request.Name).First(&data).Error; err != nil { ... } else { infra.Response.Error(c, errcode.ErrAlreadyExists); return } ``` `internal/models/department.go:6-18` 中 `Name` 无 `uniqueIndex`;`role.Name` 有全局唯一索引(`role.go:8`),但 `role/create.go:49-56` 会在冲突时**物理删除**重建(见 P2-12),并发下仍可删掉他人正在使用的角色。 - **影响**:同一应用下可存在多个同名部门;角色创建在并发/软删除场景下会「删旧建新」,使既有 `mgt_link_user_role`/`mgt_link_dpt_role` 指向已删除角色 ID。 - **建议**:为 `mgt_department(app_id, name)` 加复合唯一索引并捕获唯一冲突错误;角色创建改为「已存在则直接报冲突」,不做物理删除。 #### 19. 自定义分页/校验/状态语义不一致,魔法数字散落 - **位置**:`internal/types/vars.go:3-9`(`DefaultPage/DefaultSize`、死常量 `Admin`/`Staff`)、`internal/types/req.go:9`(`Status int8` 无 `oneof`)、`core/types/db.go:34`(0/1/-1 语义)、`internal/types/req.go:16`(`min=6`) - **证据**: ```go // internal/types/vars.go:4-9 KeyPrefix = "/SMS/Code/" Admin = "SYSADMIN" // 全模块无引用 Staff = "STAFF" // 全模块无引用 DefaultPage = 1 DefaultSize = 10 ``` ```go // internal/types/req.go:16 Password string `json:"password,omitempty" validate:"required,min=6"` // 密码,最少6位 ``` - **影响**:状态值 0/1/-1 在代码中以字面量出现(`Std_IICUDS{Status: 1}`、`if request.Status != 0`),无统一常量;`Admin`/`Staff` 从未使用;`DefaultSize=10` 与文档中展示的示例不一致;密码策略仅 6 位且无复杂度/常见弱口令校验。 - **建议**:集中定义状态常量与权限点常量;删除或启用死常量;密码策略提升到 8-12 位 + 复杂度 + 弱口令清单(与 P1-10 一起做)。 #### 20. 登录 MD5 向后兼容分支缺迁移路径,旧哈希不升级、无标记可观测 - **位置**:`internal/logic/pub/login.go:130-141`(写于登录路径);无任何「登录成功后重哈希为 bcrypt」的代码 - **证据**: ```go // login.go:130-141 // 如果bcrypt失败,尝试MD5验证(向后兼容旧用户) isBcrypt := len(userPwd) > 4 && (userPwd[0:4] == "$2a$" || ...) if isBcrypt { ...; return errcode.ErrPassword } hash := md5.Sum([]byte(inPwd + salt)) md5Hash := hex.EncodeToString(hash[:]) if userPwd == md5Hash { return nil } ``` - **影响**:MD5+salt 的历史口令可永久继续登录(无迁移强制),一旦数据库泄露,MD5 哈希可被彩虹表/GPU 秒破;代码里也没有「旧哈希计数」指标来评估迁移进度。`test/reset.http`、`test/user_create.http` 使用 `123456` 佐证弱口令长期存在。 - **建议**:MD5 校验成功后立即在事务内把该用户口令重写为 bcrypt(透明升级);统计并告警剩余 MD5 账号数;设定下线时间表后移除分支。 #### 21. HTTP 服务与 Session cookie 安全配置缺失 - **位置**:`cmd/main/main.go:34-45` - **证据**: ```go // main.go:34-45 sessionKey := config.Spec.SecretKey + "-session" store := cookie.NewStore([]byte(sessionKey)) app.Use(sessions.Sessions("mysession", store)) ... srv := &http.Server{Addr: addr, Handler: app} // 无 Read/Write/Idle/Header 超时 ``` - **影响**:`http.Server` 无任何超时(慢速攻击/连接耗尽风险);`cookie.NewStore` 未设置 `HttpOnly`/`Secure`/`SameSite`/`MaxAge`,`sessionKey` 直接等于配置文件里的 `SecretKey`(`etc/mgt_prod.yaml:16` 为占位符 `CHANGE_ME`,弱且可预测),而 `/rest/mgt/session` 是**匿名可写**接口(`register.go:32`、`internal/logic/hello/ping.go:25-35`),任何人均可让服务端下发并签名 cookie。目前 session 未被用于鉴权(JWT 才是),因此风险为「不必要的攻击面」而非直接绕过。 - **建议**:删除 `SessionDemo` 与 session 中间件(或至少改为仅内网);`http.Server` 补 `ReadHeaderTimeout/ReadTimeout/WriteTimeout/IdleTimeout`;如保留 cookie,设置 `HttpOnly+Secure+SameSite=Lax` 并使用独立强随机 key。 #### 22. CORS 允许所有来源,且允许携带 `Authorization` - **位置**:SDK `D:\work\bsm-sdk\core\middleware\cors.go:9-17`;挂载点 `cmd/main/main.go:39` - **证据**: ```go // D:\work\bsm-sdk\core\middleware\cors.go:9-17 return cors.New(cors.Config{ AllowAllOrigins: true, AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Workspace", "Request-Id", "Authorization", "Token"}, ``` - **影响**:任意站点可对 `/rest/mgt/**` 发起跨域请求并读取响应;若前端把 token 存在 `localStorage` 并由 JS 读取(常见做法),恶意页面可借用户浏览器直接调用全部管理接口。超管在浏览器登录后访问任意恶意页面即可被借用权限。 - **建议**:改为白名单 `AllowOrigins`(按环境配置),并明确 `AllowCredentials` 策略;管理端接口建议叠加 CSRF/自定义头校验。 #### 23. `/app/user` 返回手机号、邮箱等 PII,且分页参数放在 `Preload` 内不生效 - **位置**:`internal/logic/application/fetch_other.go:46-53` - **证据**: ```go // application/fetch_other.go:47-49 if err := db.Where("workspace = ?", request.Workspace).Preload("Users", func(db *gorm.DB) *gorm.DB { return db.Select("id", "identity", "name", "account", "phone", "email", "avatar").Count(&count). Limit(size).Offset((page - 1) * size) }).Find(&data).Error; err != nil { ``` - **影响**:`POST /rest/mgt/app/user` 一次返回整个应用的全体用户手机号/邮箱,无有效分页(`Limit/Offset/Count` 作用在 preload 关联查询上,`Count` 结果被写入外层 `count` 变量但外层查询本身不受限),`total` 语义也不正确。属于 PII 批量泄露 + 分页失效。 - **建议**:拆成两次查询(先分页取 user_id 再取明细),或对手机号/邮箱做脱敏(仅返回掩码),并限制单次返回量。 #### 24. 全局可变基础设施单例 + 配置缺校验导致不安全默认值 - **位置**:`internal/impl/impl.go:13-26`、`internal/impl/with.go:13-16`、`internal/config/config.go:31-49` - **证据**: ```go // impl.go:13-18 —— 包级可变单例,被 logic 层直接读取 var ( RedisService *redis.RedisClient EtcdService *clientv3.Client MemorySerice *cache.Cache DBService *gorm.DB ) ``` ```go // config.go:49 —— 只校验 Service/Cache 非空,未校验 DB/JWT/端口冲突 conf.NotNil(Spec.Service, Spec.Cache) ``` - **影响**:`DBService/RedisService` 可在运行期被 `service.applyDependencies` 覆盖(`service/dependencies.go:19-32`),logic 层直接引用全局变量(分层违规、无法注入 mock、单测困难);`config.New` 不校验 `Databases`,缺失时由 `with.go:14` 直接 `panic`(启动期崩溃而非友好报错),也不校验 JWT 密钥(见 P0-2);`MemorySerice`/`EtcdService` 在本模块**从未被使用**(仅赋值),属无效基础设施成本。 - **建议**:改为显式依赖注入(构造函数传 `*gorm.DB`/Redis),或至少提供只读访问器;`config.New` 增加必填项与非默认密钥校验;移除未使用的 go-cache/etcd 依赖。 #### 25. 端口与时间/时区相关配置隐患 - **位置**:`internal/config/config.go:36`、`etc/mgt_prod.yaml:2,7` - **证据**: ```go // config.go:35-36 // 配置校验 服务端口如果不合规,则随机分配端口 Spec.Port = conf.CheckPort(Spec.Port) ``` ```yaml # etc/mgt_prod.yaml:1-7 Service: mgt Port: 18001 Databases: Driver: postgres Source: - host=127.0.0.1 user=ec_dev password=CHANGE_ME dbname=factor_prod port=5432 sslmode=disable TimeZone=Asia/Shanghai ``` - **影响**:非法端口被静默改为随机端口,容器/网关按固定端口探活会失败(启动「成功」但不可达);DSN 用 `TimeZone=Asia/Shanghai` 而模型用 `time.Time` + DB `TIMESTAMP`(`core/types/db.go:31-33`),跨时区部署时 `created_at/last_login_at` 语义易错;`sslmode=disable` 使 DB 连接明文(内网可接受,生产需确认)。 - **建议**:非法端口直接 Fatal 并输出明确原因;统一使用 UTC 存储、展示层转换;生产 DSN 启用 TLS 或明确豁免记录。 #### 26. 大量写操作缺少 context 传递与超时控制 - **位置**:全模块 logic 层(示例 `internal/logic/user/create.go:108`、`internal/logic/role/del.go:54`、`internal/logic/department/del.go:78`) - **证据**: ```go // user/create.go:108 —— 全部使用 impl.DBService 全局句柄,无 WithContext(c.Request.Context()) if err = impl.DBService.Transaction(func(tx *gorm.DB) error { ``` `impl.RedisService.Client.Get/Set`(`pub/forget.go:58`、`pub/login.go:110`)同样不传 context。 - **影响**:客户端断开或请求超时后,DB/Redis 调用仍会继续执行到结束(长事务、慢查询无法被取消),故障时连接池被长时间占用;也无法做链路追踪。 - **建议**:统一改为 `impl.DBService.WithContext(c.Request.Context())`,Redis 调用传 `ctx`,并为关键写操作设置语句级超时。 #### 27. 无健康检查的依赖探测、启动与迁移/播种强耦合 - **位置**:`cmd/main/main.go:41`(`app.HEAD("/", infra.Health)`)、`internal/models/impl.go:57-74` - **证据**: ```go // models/impl.go:57-74 —— 进程启动路径内串行执行 setupRegister + AutoMigrate + 播种 setupRegister() log.Println("自定义连接表注册成功") err = DBService.AutoMigrate(migrateTables...) ... if config.InitRootUserEnabled() { InitRootUserData() } ``` - **影响**:`AutoMigrate` 与 root 播种在启动路径上同步执行,DB 不可用/迁移变慢会让进程迟迟不监听端口,且 `log.Fatalln`(`impl.go:46,52,64`)在迁移失败时直接退出——配合无健康检查探活语义,滚动发布易出现「启动即崩」的雪崩;`infra.Health` 只回固定内容,不探测 DB/Redis。 - **建议**:迁移拆为独立命令/initContainer;健康检查区分 liveness/readiness 并探测 DB+Redis;启动失败给出可读错误并做有限重试。 ### P3 #### 28. 生成的校验错误信息被丢弃,接口只返回通用 `ErrInvalidArgument` - **位置**:`internal/libs/validator.go:78-92`、`:95-122` - **证据**: ```go // validator.go:79-91 func formatValidationError(err error) error { if validationErrors, ok := err.(validator.ValidationErrors); ok { var errMsg strings.Builder errMsg.WriteString("参数验证失败: ") for i, validationError := range validationErrors { ... errMsg.WriteString(getFieldErrorMsg(validationError)) } return errcode.ErrInvalidArgument // 精心拼装的 errMsg 从未被使用 } return errcode.ErrInvalidArgument } ``` - **影响**:`getFieldErrorMsg`(11 个分支)构造的字段级提示全部丢弃,前端只能看到「参数错误」,排障与体验受损,同时产生一段 dead code。 - **建议**:返回 `errcode.String(errcode.ErrInvalidArgument, errMsg.String())`,或至少把字段错误写入结构化日志。 #### 29. 无 Go 单元测试,仅有 1 个路由注册测试与 54 个无断言的 .http 样例 - **位置**:`internal/routers/register_test.go:10-35`(唯一 `_test.go`);`test/*.http`(54 个,纯请求样例) - **证据**: ```go // register_test.go:15-20 —— 只断言 4 条路由存在 expected := map[string]bool{ "GET /rest/mgt/ping": false, "POST /rest/mgt/login": false, "POST /rest/mgt/user/create": false, "POST /rest/mgt/app/fetch": false, } ``` - **影响**:登录(含 MD5 兼容分支)、`RequireAdmin` 越权、`EnsureSelfOrAdmin` 水平越权、`init_db` 播种幂等、赋权差集(`DelRole`)、部门树环检测这些**高风险逻辑全部零覆盖**,本次审计发现的问题(P1-3 零值覆盖、P1-5 越权赋权、P1-8 误删权限)都没有任何自动化防线。 - **建议**:补齐关键缺失用例(见第 5 节 TODO)。 #### 30. 文档与实现多处不一致 - **位置**:`doc/README.md:3-4`、`doc/pub.md:3,81-83`、`doc/user.md:3`、`doc/pmn.md`、`doc/dpt.md`、`README.md:217` - **证据**: ```markdown 接口基础地址:`http(s)://{host}:{port}/mgt/` 除公开接口外,需在请求头携带:`Authorization: Bearer {token}`。 ``` 实际路由前缀为 `/rest/mgt`(`register.go:21`),`register_test.go:26-28` 还专门断言不存在 `/mgt/*` 旧前缀——文档与测试相互矛盾。 ```markdown **说明**:基于当前请求中的 JWT 签发新 token。通常需在 Header 中带有效 Authorization。 ``` 实际匿名可调且不校验过期(P1-9)。全部文档都**未说明管理端接口需要「超级管理员」角色**。 - **影响**:接入方按文档用 `/mgt/login` 会 404;调用方无法预知哪些接口需要超管,容易误判 403 为 bug;权限点体系与文档完全脱节。 - **建议**:统一为 `/rest/mgt/**`,逐接口补「所需角色/权限点」,并在 CI 中用 `register_test.go` 的方式校验文档路由集合与实际路由一致。 #### 31. README/测试样例硬编码真实默认口令,死代码残留 - **位置**:`README.md:168-172`、`test/login.http:5-6`、`test/reset.http:7`、`test/user_create.http:8`;`internal/models/query.go`(仅 1 行 `package models`);`pub/login.go:108-116`(`SaveToken` 无调用方);`internal/types/types.go:1-93`(10 个结构体全无引用);`internal/types/vars.go:5-6`(`Admin`/`Staff`);`config.go:18` + `impl.go:15,25`(`SecretKey` 与 etcd 实际未用于 JWT) - **证据**: ```go // pub/login.go:108-116 —— 零调用方(全模块仅此定义处出现) func SaveToken(id int64, role, token string) error { tokenKey := fmt.Sprintf("%d-%s-%s", uint(id), role, "token") status := impl.RedisService.Client.Set(impl.RedisService.Ctx, tokenKey, token, 0) ``` - **影响**:文档与样例即攻击字典;至少 6 处 dead code 增加维护成本,并让读者误以为存在 token 存储/登出机制、类型别名约定、Admin/Staff 角色体系。 - **建议**:样例口令改占位符;删除无引用代码或补齐实现(`SaveToken` 若要用于登出需同时接 `JwtAuth` 校验)。 ## 4. 推荐优化方案 按「先止血、再补结构、后做工程化」三层推进: **第一层:止血(1-3 天内可完成,对应 P0/P1-6/P1-9/P1-10)** 1. 鉴权基础设施硬化:SDK 侧要求显式配置 JWT 密钥(缺失即 Fatal),`mgt` 启动时校验密钥强度;`/refresh` 挂 `JwtAuth(true)` 并复核账号状态;`/login`、`/reset` 加 Redis 限流(IP+账号)与验证码错误计数。 2. 口令治理:移除 `init_db.go` 的 `123456`,改为「环境变量初始口令 / 随机生成 + 强制首登改密」;保留 MD5 兼容分支但在登录成功后透明升级为 bcrypt,并加迁移进度指标。 3. 数据损坏修复:把 `user/modify.go`、`permission/modify.go` 的 `Select+结构体` 改成 `map[string]interface{}`(只装显式字段);为 `/user/modify` 增加 root 保护。 **第二层:补结构(1-2 周,对应 P1-5/P1-7/P1-8 与 P2 主体)** 4. 真正的接口级授权:建立 `路由 → permission.Code` 映射中间件,逐个接口挂载权限点;把超管判定从「角色名」改为不可变标识;清理或启用 `Admin`/`Staff`。 5. 赋权链路收敛:抽出 `grantPermissions(scope, id, appId, pmnIds)` 统一入口,强制「权限必须属于 appId」校验 + 事务内「先删后插」+ 变更审计;`role/del` 复用 `user/set_role.go` 的差集算法;应用关联清理由钩子改为事务内显式 SQL。 6. 树与递归安全:所有父子遍历加 `visited` 环检测,统一到 `internal/logic/tree` 工具包;权限/部门的 `parent_id` 写入前校验同应用 + 非自身 + 非后代。 7. 审计日志:新增 `mgt_audit_log` 表,所有写 handler 记录操作者/路由/关键参数/前后值/IP;日志脱敏并结构化(zap)。 8. 查询与索引:为 `mgt_department(app_id,name)` 加复合唯一索引;补 `Order` 确定性与 `size` 上限;把 `department/fetch_pmn*` 的 O(n²) 建树改为字典;`application/fetch_other.go` 的分页重写。 **第三层:工程化(持续,对应 P2 剩余与 P3)** 9. 依赖注入替换全局单例;`config.New` 增加必填项/非默认密钥/端口校验;移除未使用的 go-cache/etcd。 10. `http.Server` 补超时;CORS 改白名单;删除 `SessionDemo` 或迁到内网。 11. context 贯通(DB/Redis 全量 `WithContext`)+ 关键写操作语句超时。 12. 测试与文档:补齐登录/越权/初始化/赋权差集/树环检测的单元与集成测试;CI 中校验文档路由集合与实际路由一致;密码策略与错误信息改进。 ## 5. TODO 清单 - [ ] **P0-1** 移除 root 硬编码默认口令,改为环境变量或随机生成 + 强制首登改密|验收:`grep -rn "123456" module/base/mgt` 无源码命中,首次启动日志不含可复用明文口令,`InitRootUserData` 输出的仅是一次性随机口令|涉及:`internal/models/init_db.go:16`、`README.md:170`、`test/login.http:6` - [ ] **P0-2** JWT 密钥禁止默认值,启动即校验长度与黑名单|验收:未设 `BSM_JwtSecretKey` 时进程启动失败并输出可读错误;密钥长度 <32 或等于 `Cblocksmesh2022C` 时拒绝启动|涉及:`D:\work\bsm-sdk\core\env\env.go:19`、`internal/config/config.go:39` - [ ] **P1-3** `user/modify` 改为按显式字段更新,禁止零值覆盖|验收:只传 `{"id":1,"name":"x"}` 后 `account/phone/email/avatar/status` 均保持不变(集成测试断言)|涉及:`internal/logic/user/modify.go:57-72` - [ ] **P1-4** `permission/modify` 改为按显式字段更新|验收:只传 `{"id":1,"title":"x"}` 后 `code/parent_id/menu_path/component` 不变|涉及:`internal/logic/permission/modify.go:67-89,123-144` - [ ] **P1-5** 赋权前校验权限集合全部属于目标 `app_id`;权限 `parent_id` 校验同应用、非自身、非后代|验收:跨应用权限 ID 或自引用 `parent_id` 请求返回 400,库中不留脏关联|涉及:`internal/logic/user/set_pmn.go:40-47`、`internal/logic/role/set.go:40-48`、`internal/logic/permission/create.go:71`、`internal/logic/permission/modify.go:72,127` - [ ] **P1-6** `/reset` 与 `/login` 加限流、验证码错误计数、统一错误码|验收:同手机号 5 次验证码错误后验证码失效;同 IP 高频请求返回 429;账号不存在与密码错误响应一致|涉及:`internal/routers/register.go:34-36`、`internal/logic/pub/forget.go:57-73`、`internal/logic/pub/login.go:64-71` - [ ] **P1-7** 建立路由→权限点鉴权中间件并逐接口挂载,超管判定改用不可变标识|验收:非超管账号调用 `/rest/mgt/user/create` 返回 403;重命名「超级管理员」角色后 root 仍为超管|涉及:`internal/middleware/rbac.go:19-54`、`internal/routers/register.go:45-135` - [ ] **P1-8** `role/del` 按用户其他角色做权限差集,应用关联清理改为事务内显式 SQL|验收:删除角色后,用户经其他角色仍持有的权限不被删除;`mgt_link_user_app` 无 0 权限残留行|涉及:`internal/logic/role/del.go:40-88`、`internal/models/link_user_pmn.go:26`、`internal/models/link_role_pmn.go:26` - [ ] **P1-9** `/refresh` 挂 `JwtAuth(true)` 并复核账号状态,引入 token 版本/黑名单与登出接口|验收:过期 token 调用 `/refresh` 返回 401;改密后旧 token 失效|涉及:`internal/routers/register.go:35`、`internal/logic/pub/refresh.go:21-34` - [ ] **P1-10** 登录失败限流与弱口令清单,密码策略提升至 ≥8 位含复杂度|验收:连续 5 次失败锁定 15 分钟;`123456`/`password` 等被拒|涉及:`internal/logic/pub/login.go:118-145`、`internal/types/req.go:16` - [ ] **P1-11** 部门/权限树遍历加环检测,脏数据返回错误而非崩溃|验收:构造 `parent_id` 环后 `/dpt/del`、`/dpt/list?tree=true` 返回错误且进程存活|涉及:`internal/logic/department/del.go:17-37`、`internal/logic/department/list.go:71-83`、`internal/logic/department/fetch_pmn_tree.go:109-130` - [ ] **P2-12** 创建接口不再物理删除软删除记录,或事务内先清理全部关联|验收:重建同名账号后不继承任何历史角色/权限/部门关联|涉及:`internal/logic/user/create.go:57-72`、`internal/logic/role/create.go:49-56`、`internal/logic/permission/create.go:52-58`、`internal/logic/application/create.go:46-69` - [ ] **P2-13** 消除 N+1:部门权限建树改字典一次成型,`user/fetch_pmn` 预加载合并|验收:`/dpt/pmn_tree` 权限数 1000 时 SQL 数 ≤5,耗时线性|涉及:`internal/logic/department/fetch_pmn.go:74-94`、`internal/logic/department/fetch_pmn_tree.go:82-89`、`internal/logic/user/fetch_pmn.go:88-99` - [ ] **P2-14** 列表补确定性排序与 `size` 上限,无分页接口补分页|验收:`size=100000` 返回 400;同参数重复翻页结果稳定|涉及:`internal/logic/tool/tool.go:5-14`、`internal/logic/{user,application,role,department}/fetch.go` - [ ] **P2-15** 新增审计日志表并覆盖所有写 handler|验收:每次赋权/改密/删除均在 `mgt_audit_log` 留下操作者 ID、路由、参数与结果|涉及:`internal/logic/user/set_pmn.go:70`、`internal/logic/role/del.go:94`、`internal/logic/pub/forget.go:98` - [ ] **P2-16** 修正 `code like ?` 无效列与 `?` 参数不匹配|验收:`/rest/mgt/app/fetch?keyword=x`、`/rest/mgt/pmn/fetch?keyword=x` 返回 200|涉及:`internal/logic/application/fetch.go:36-39`、`internal/logic/permission/fetch.go:37-40` - [ ] **P2-17** 用事务内显式 SQL 替代以全零接收者执行、恒为空操作的 `AfterDelete` 钩子路径|验收:用户/角色权限清空后 `mgt_link_user_app`/`mgt_link_role_app` 无 0 权限残留行(集成测试直接断言关联表)|涉及:`internal/models/link_user_pmn.go:25-39`、`internal/models/link_role_pmn.go:25-39`、`internal/logic/user/set_pmn.go:108,165`、`internal/logic/role/set.go:110,114,164` - [ ] **P2-18** 为 `mgt_department(app_id,name)` 加复合唯一索引,角色创建不做物理删除|验收:并发同名部门只成功一条;角色创建冲突时返回 409 且不删除既有角色|涉及:`internal/models/department.go:9`、`internal/logic/department/create.go:60-70`、`internal/logic/role/create.go:49-60` - [ ] **P2-20** MD5 兼容分支透明升级为 bcrypt 并加迁移指标|验收:旧 MD5 用户首次登录成功后库中口令变为 `$2a$/$2b$` 前缀|涉及:`internal/logic/pub/login.go:130-141` - [ ] **P2-21** `http.Server` 补超时;session 中间件与 `/session` 移除或改内网|验收:`/rest/mgt/session` 不再对外可用;配置含 Read/Write/Idle 超时|涉及:`cmd/main/main.go:34-45`、`internal/logic/hello/ping.go:25-35` - [ ] **P2-22** CORS 改来源白名单|验收:非白名单 Origin 的预检请求被拒|涉及:`D:\work\bsm-sdk\core\middleware\cors.go:9-17` - [ ] **P2-23** `/app/user` 分页重写并对手机号/邮箱脱敏|验收:单次返回 ≤ 上限条数,`total` 正确,手机号以掩码返回|涉及:`internal/logic/application/fetch_other.go:46-53` - [ ] **P2-24** 依赖注入替换全局单例;配置增加必填与非默认密钥校验;移除未使用的 go-cache/etcd|验收:缺失 `Databases` 时启动报可读错误而非 panic;`MemorySerice/EtcdService` 引用清零|涉及:`internal/impl/impl.go:13-26`、`internal/config/config.go:31-49` - [ ] **P2-26** DB/Redis 调用贯通 `context` 与超时|验收:客户端断开后长查询可被取消(日志可见 context canceled)|涉及:全 logic 层事务与 `internal/logic/pub/forget.go:58`、`internal/logic/pub/login.go:110` - [ ] **P2-27** 迁移与播种从启动路径剥离,健康检查探测 DB/Redis|验收:DB 不可用时进程仍能启动并给出 readiness 失败;迁移由独立命令执行|涉及:`internal/models/impl.go:57-74`、`cmd/main/main.go:41` - [ ] **P3-28** 校验错误信息回传前端|验收:字段校验失败响应包含具体字段与原因|涉及:`internal/libs/validator.go:78-92` - [ ] **P3-29** 补关键单元/集成测试(登录含 MD5 分支、`RequireAdmin`/`EnsureSelfOrAdmin` 越权、`init_db` 幂等、`DelRole` 差集、树环检测)|验收:上述场景用例全绿,覆盖率覆盖 6 个 logic 子包的写路径|涉及:`internal/routers/register_test.go:10-35`、新增 `internal/logic/**/*_test.go` - [ ] **P3-30** 文档路径统一为 `/rest/mgt/**` 并补「所需角色/权限点」|验收:文档路由集合与 `engine.Routes()` 一致(CI 校验);每接口标注权限点|涉及:`doc/README.md:3-4`、`doc/pub.md:83`、`doc/*.md` - [ ] **P3-31** 清理死代码与样例口令|验收:`SaveToken`/`types/types.go` 结构体/`Admin`/`Staff`/`models/query.go` 有引用或删除;样例口令为占位符|涉及:`internal/logic/pub/login.go:108-116`、`internal/types/types.go`、`internal/types/vars.go:5-6`、`internal/models/query.go` ## 6. 审计摘要(供汇总使用) - 问题数:P0=2 P1=9 P2=15 P3=4(编号 1-31,全文连续) - 最高风险(一句话):root 默认口令 `123456` 硬编码并自动播种(`internal/models/init_db.go:16`),叠加 JWT 密钥默认值 `Cblocksmesh2022C`(SDK `env.go:19`),使未显式配置环境变量的部署可被一条登录请求或一枚伪造 token 直接接管最高权限,而全部 50 个管理端接口只校验「是否超管」、赋权无归属校验且无审计日志。 - 最优先 3 个动作:1) 移除 root 默认口令并强制 JWT 密钥显式配置(P0-1、P0-2);2) 修复 `user/modify`、`permission/modify` 的 `Select+结构体` 零值覆盖(P1-3、P1-4)并给赋权接口补 `app_id` 归属校验(P1-5);3) 为 `/reset`、`/login` 加限流与验证码尝试限制、为 `/refresh` 恢复过期校验(P1-6、P1-9、P1-10)。 - 未能覆盖/无法验证的部分:无 DB/Redis 实例,越权、爆破、数据损坏类结论均为静态推演(仅 GORM 零值更新语义经隔离环境 DryRun 实测确认);未审计 SDK 的 DB/Redis/日志/响应序列化实现细节;未验证 `module/all` 与 `gateway` 是否在 `mgt` 之外叠加了限流或鉴权;未确认部署态是否真的设置了 `BSM_JwtSecretKey`(这决定 P0-2 是否已被实际利用);54 个 `test/*.http` 未逐条核对;未运行 `go build`/`go test`(`gofmt -l`、`go vet ./...` 均通过,无输出)。