23 KiB
feedback(意见反馈管理)代码审计报告
| 项 | 内容 |
|---|---|
| 审计对象 | module/base/feedback |
| 服务域 | 基础与平台服务 |
| 审计日期 | 2026-09-22 |
| 代码规模 | 手写 Go 文件 19 个、821 行(含 test/rpc/rpc.go 23 行空壳);proto 2 个(feedback.proto、const.proto);pb/ 生成文件 4 个 |
| 入口 | cmd/main(gRPC + HTTP Gateway 单进程)、聚合入口 pkgs/all(pkgs/all/internal/service/feedback.go) |
| 对外协议 | gRPC + REST Gateway,前缀 /feedback.Method |
| 结论摘要 | 越权缺陷成堆:Get/Delete/Remark 连身份都不解析,Modify 解析了身份却仍按调用方传入的 identity 更新,四个接口均无归属校验(IDOR),且 Modify 会把记录 identity 改写成随机 UUID 并把归属改成攻击者;Modify 处理附件时未生成 identity,子表 identity 是唯一索引,附件 ≥2 条必然唯一键冲突;List/Get 只 Preload("Images"),附件永远为空;此外 etc/ 下 4 份 yaml 全部不符合 SDK 配置约定(Name/ListenOn/Dsn + 顶层 Anonymous),独立进程启动即 log.Fatalln。 |
1. 服务定位与职责
用户意见/反馈的提交与后台管理:提交反馈(含图片、附件)、按条件分页查询、查看详情、修改、删除、填写备注并流转状态。数据模型为"反馈主表 + 图片子表 + 附件子表"。它不负责附件文件本身的上传(那是 module/base/fts 的职责),只保存 URL/路径。
2. 代码结构与入口
| 路径 | 职责 |
|---|---|
cmd/main/main.go |
独立进程入口:config.New("Feedback") → impl.NewImpl → server.New(nil) → service.New(...).Start() |
cmd/cli/main.go |
命令行入口,仅 log.Println("Hello World!")(7 行,占位) |
internal/config/config.go |
配置结构(Base/Databases/MicroService/Rpc/Gateway/APM/Etcd)与 conf.NotNil 校验 |
internal/impl/impl.go |
初始化 DB / Redis / Etcd / 内存缓存 |
internal/logic/method/list.go |
List:分页 + 按 identity/agency/username/status/category 过滤 |
internal/logic/method/get.go |
Get:按 identity 查详情 |
internal/logic/method/add.go |
Add:创建主表 + 图片 + 附件 |
internal/logic/method/modify.go |
Modify:删子表 → 更新主表 → 重建子表 |
internal/logic/method/delete.go |
Delete:按 identity 删主表 |
internal/logic/method/remark.go |
Remark:更新 remark + status |
internal/logic/method/ref.go |
convert:模型 → pb 转换(含图片、附件) |
internal/models/feedback_item.go |
feedback_item 主表模型 + 迁移注册 |
internal/models/feedback_images.go |
feedback_images 图片子表 |
internal/models/feedback_accessory.go |
feedback_accessory 附件子表 |
internal/server/{new,method_server}.go |
注册 Method 到 gRPC,handler 转发 logic |
internal/routers |
不存在(走 gRPC + HTTP Gateway) |
service/{expose,dependencies}.go |
聚合宿主注入接口 |
etc/{feedback.yaml,feedback_dev,feedback_prod,feedback_test}.yaml |
4 份配置,字段命名与 SDK 约定不符(见 6.3) |
test/add.http、test/rpc/rpc.go |
手工用例(JSON 非法)+ 注释空壳 |
3. 接口清单
| 方法 | 路径 | 功能 | 鉴权 | 实现位置 |
|---|---|---|---|---|
| POST | /feedback.Method/List |
分页查询反馈(条件过滤) | 网关 JWT + service.ParseMetaCtx(list.go:19) |
internal/logic/method/list.go |
| POST | /feedback.Method/Get |
按 identity 查详情(返回 email/phone) |
网关 JWT,进程内不解析身份(get.go 无 ParseMetaCtx) |
internal/logic/method/get.go |
| POST | /feedback.Method/Add |
新增反馈 | 网关 JWT + ParseMetaCtx(add.go:21) |
internal/logic/method/add.go |
| POST | /feedback.Method/Modify |
按 identity 修改 |
网关 JWT + ParseMetaCtx(modify.go:24) |
internal/logic/method/modify.go |
| POST | /feedback.Method/Delete |
按 identity 删除 |
网关 JWT,进程内不解析身份(delete.go) |
internal/logic/method/delete.go |
| POST | /feedback.Method/Remark |
按 identity 更新备注 + 状态 |
网关 JWT,进程内不解析身份(remark.go) |
internal/logic/method/remark.go |
声明但未实现的接口:无。proto/feedback.proto:6-13 的 Method 服务 6 个 rpc 与 pb/feedback_grpc.pb.go 的 MethodServer、internal/server/method_server.go:18-40 的转发方法一一对应。
鉴权补充:
- 模块 yaml 里列出的
feedback.Check.Hello/Updates、feedback.Data.Configure/Areas/Tags(etc/feedback_dev.yaml:19-23)在本模块 proto 中根本不存在,属死配置,详见 6.3。- 聚合入口
pkgs/all/etc/default_dev.yaml:24-43的Authorization.Anonymous也未包含/feedback.Method/*,故上线形态下 6 个接口都要求携带有效 JWT——但"有效 JWT"仅代表"是某个登录用户",接口内部不做任何归属判断,见 6.1。
4. 数据模型与表
表 feedback_item(internal/models/feedback_item.go)
| 字段 | 类型 | 键/约束 | 说明 |
|---|---|---|---|
id |
uint | PK | 来自 types.Std_IICUDS |
identity |
varchar(36) | uniqueIndex | 记录唯一标识(D:\work\bsm-sdk\core\types\db.go:30) |
created_at/updated_at/deleted_at |
timestamp | - | 带软删 |
passport_id |
uint | Index | 提交人 ID(types.Std_Passport) |
passport_identity |
varchar(36) | Index | 提交人标识,List 的归属过滤字段 |
status |
int32 | default 1 | 1 未处理 / 2 已处理(与内嵌 Std_IICUDS.Status int8 同列冲突,见 6.2) |
category / user_name / email / phone |
varchar | - | 分类 / 用户名 / 邮箱 / 手机(含 PII) |
title(255)/ content(500)/ remark(500) |
varchar | - | 标题 / 内容 / 备注 |
agency |
varchar(255) | - | 机构 |
images / accessories |
- | foreignKey:ItemIdentity |
GORM 关联字段(非列) |
表 feedback_images / feedback_accessory
| 字段 | 类型 | 键/约束 | 说明 |
|---|---|---|---|
identity |
varchar(36) | uniqueIndex | 子表唯一标识(来自 Std_IICUDS) |
item_identity |
varchar(36) | default '' | 关联主表 identity,无 DB 级外键 |
url(图片) |
varchar(255) | default '' | 图片地址 |
title / file_path(附件) |
varchar(255) / varchar(500) | not null | 附件标题 / 附件路径 |
5. 核心流程
flowchart TD
A["POST /feedback.Method/Modify"] --> B["service.ParseMetaCtx 解析 auth"]
B --> C["校验 in.Identity 非空"]
C --> D["构造 record:Identity = utils.UUID(),归属改写为 auth"]
D --> E["按 item_identity 删除 feedback_images / feedback_accessory"]
E --> F["Where(identity = in.Identity).Updates(record)"]
F --> G["GORM 顺带 upsert 非空 HasMany 关联"]
G --> H["显式 Create(&record.Images) / Create(&record.Accessories)"]
I["POST /feedback.Method/Get"] --> J["Where(identity = in.Identity).First:无身份、无归属校验"]
J --> K["返回含 email / phone 的完整记录"]
6. 审计发现
6.1 安全
| 级别 | 位置 | 问题 |
|---|---|---|
| 高 | internal/logic/method/get.go:19-41(关键::21 只校验 identity 非空、:29 按 identity 查询) |
Get 完全不解析调用方身份,仅按请求体里的 identity 取记录,且返回体包含 email/phone(ref.go:17-18)。任何持有效 JWT 的用户遍历 identity 即可读取全部用户的反馈与联系方式(IDOR + PII 泄露)。 |
| 高 | internal/logic/method/delete.go:19-29(关键::26) |
Delete 不解析身份、不做归属校验,Where("identity = ?", in.GetIdentity()).Delete(...) 直接删除。任何登录用户可删除他人反馈(软删,deleted_at)。 |
| 高 | internal/logic/method/remark.go:19-35(关键::21,:28,:32) |
Remark 不解析身份、不做归属校验,且 status 取自请求体(remark.go:28)→ 任何登录用户可改写任意反馈的备注与处理状态(可把"已处理"回退或伪造)。 |
| 高 | internal/logic/method/modify.go:22-27,30,89 |
Modify 虽然解析了 auth,但只用它覆写归属字段(modify.go:39-42),更新条件仍是调用方传入的 in.Identity(modify.go:89)→ 可篡改他人反馈;更严重的是会把该记录的 passport_identity 改成攻击者自己,完成"夺权":原主人因 List 按 passport_identity 过滤(list.go:43)而再也看不到自己的反馈。 |
| 中 | internal/logic/method/list.go:38-45 |
List 的过滤是"二选一":传入 agency 时只按 agency 过滤,passport_identity 条件被跳过 → 任意登录用户只要知道机构名,就能枚举该机构下全部反馈,且响应含 email/phone(ref.go:17-18)。 |
| 中 | internal/logic/method/list.go:41-44 |
当 auth.Identity 为空串时,passport_identity 过滤被跳过(if userIdentity != ""),查询退化为无归属条件的分页查询。service.ParseMetaCtx 只校验 JWT 签名与有效期、不校验 identity 非空(D:\work\bsm-sdk\core\service\meta.go:19-47),因此该分支在 token 未携带 identity 时可达。 |
| 低 | internal/logic/method/ref.go:13-26 |
列表/详情统一输出 email、phone,PII 在所有读接口上无脱敏、无权限分级。 |
6.2 正确性与逻辑缺陷
| 级别 | 位置 | 问题 |
|---|---|---|
| 高 | internal/logic/method/modify.go:35-38,89 |
Updates(record) 的 record 里 Std_IICUDS.Identity = utils.UUID()(新 UUID)。GORM 结构体 Updates 只忽略零值,Identity 非零 → 主表 identity 列被改写成新 UUID。由于 Where 用的是旧 identity,更新后原 identity 立即失效,Get/Modify/Delete/Remark 全部找不到该记录,任何外部引用(如工单、通知)随之断裂。 |
| 高 | internal/logic/method/modify.go:70-76 |
构造附件时未设置 Identity(对比 add.go:57-66 明确赋 utils.UUID()),FeedbackAccessory 的 identity 取零值 "";而子表 identity 是 uniqueIndex(D:\work\bsm-sdk\core\types\db.go:30)→ 附件 ≥2 条时必然唯一键冲突;即使只传 1 条,也会写入一行 identity="" 的脏数据,后续再改就再也插不进去。 |
| 高 | internal/logic/method/modify.go:50-52,89,95-106 |
关联被写入两遍。GORM v2 的 Update 处理器注册了 gorm:save_after_associations(gorm@v1.31.2/callbacks/callbacks.go:72),SaveAfterAssociations(false) 对 ReflectValue 为 Struct 的 HasMany 字段会调用 saveAssociations(callbacks/associations.go:190-236),最终执行 tx.Create(values)(callbacks/associations.go:431,非 FullSaveAssociations 时带 ON CONFLICT DO NOTHING,见 :371-372)。也就是说 modify.go:89 的 Updates 已把 Images/Accessories 插入库中,随后 modify.go:96/:102 又对同一批(同 identity)显式 Create → 第二次插入撞唯一索引,接口返回错误,而主表与子表其实已经改了一半(无事务包裹)。 |
| 中 | internal/logic/method/delete.go:25-26 |
注释写"GORM会自动处理关联的图片和附件删除",但模型只在关联字段上声明了 foreignKey:ItemIdentity(feedback_item.go:23-24),没有 OnDelete:CASCADE,子表也无 DB 级外键 → Delete 只删主表,feedback_images/feedback_accessory 成为孤儿数据(Modify 是手动先删子表的,Delete 没做)。 |
| 中 | internal/models/feedback_item.go:12 与 :18 |
status 列被定义两次:内嵌 types.Std_IICUDS.Status(int8,default:0;index)与自定义 Status int32(default:1)。GORM 按"最短绑定路径优先"解析(gorm@v1.31.2/schema/schema.go:238),外层字段胜出、内嵌字段被遮蔽 → 实际建表丢失 index 标签,默认值语义也从 SDK 约定的 0 变成 1。 |
| 中 | internal/logic/method/list.go:35 与 internal/logic/method/get.go:29 |
两处只 Preload("Images"),没有 Preload("Accessories"),而 ref.go:38-45 会转换并输出附件 → List/Get 返回的 accessories 恒为空数组,与 proto/feedback.proto:46(repeated FeedbackAccessory accessories = 12)的契约不符。 |
| 低 | internal/logic/method/list.go:25-32,71 |
page 无上限钳制(只保证 >= 1),offset = (page-1)*size 直接下推为 OFFSET,传超大 page 会造成大偏移慢查询。 |
| 低 | internal/logic/method/remark.go:37-40、internal/logic/method/delete.go:31-34 |
返回 pb.StatusReply 只填 Message/Timeseq,code 恒为 0(proto/feedback.proto:126),调用方无法从 code 判断成功/失败。 |
| — | internal/logic/method/list.go:71 |
.Find(&list).Count(&count) 复用同一 Statement 看似可疑,但 GORM 在 Execute 结束时会重置 SQL(gorm@v1.31.2/callbacks.go:149-152),Count 重建为 SELECT count(*) ... LIMIT n OFFSET m,聚合结果单行不受 LIMIT 影响,count 等于命中总数。非缺陷(特此说明)。 |
| — | internal/logic/method/add.go:36 |
Add 接受调用方传入的 status,与 proto/feedback.proto:74 注释"也可以调用方自行设置"一致,属设计约定;但结合 6.1 的 Remark 问题,状态流转整体缺少权限约束。 |
6.3 未完成实现
- 独立进程无法启动:4 份 etc 配置全不符合 SDK 约定。
etc/feedback_dev.yaml:1-4使用Name:/ListenOn:/Dsn:,而 SDK 的conf.Baseyaml 标签是Service/Port/Cache/SecretKey/BindIP(D:\work\bsm-sdk\core\conf\types.go:7-16);更关键的是conf.New在解析前会校验文件文本必须包含Service:,否则log.Fatalln("ERROR: Service Not Nil")(D:\work\bsm-sdk\core\conf\new.go:58-60)。feedback.yaml、feedback_dev.yaml、feedback_prod.yaml、feedback_test.yaml都只有ServiceName:(不匹配Service:子串),默认BSM_RuntimeMode=dev(D:\work\bsm-sdk\core\env\env.go:20)会加载etc/feedback_dev.yaml→ 进程直接 Fatal 退出。即便绕过该校验,因无Databases:段,with.Databases也会panic("No Database Source Found !")(D:\work\bsm-sdk\core\with\databases.go:13-15)。etc/feedback_prod.yaml:4是Dsn: postgres://prod:***@192.168.0.224:5432/scf...,即"生产配置"同样不可用。 - 匿名清单整段失效且内容不存在:
etc/feedback_dev.yaml:16-23用的是顶层Anonymous:+Key:/Urls:结构,而 SDK 期望MicroService: {Enable: bool, Anonymous: []string}(conf\types.go:23-26);internal/config/config.go:17-25的SrvConfig也没有对应字段 → 该段被 yaml 静默忽略(conf.New未启用 strict 模式)。同时MicroService:整段缺失 →Spec.MicroService == nil→service.Start既不注册服务也不发布匿名清单(D:\work\bsm-sdk\core\service\service.go:66-90)。所列feedback.Check.Hello/Updates、feedback.Data.Configure/Areas/Tags在本模块 proto 中不存在(proto/feedback.proto:6-13只有Method)→ 死配置。 proto/feedback.proto:119-122的IdentRequest消息未被任何 rpc 引用 → 死消息。- 未发现显式 TODO/占位。
6.4 健壮性与可维护性
| 级别 | 位置 | 问题 |
|---|---|---|
| 中 | internal/config/config.go:39 |
conf.NotNil(Spec.Service, Spec.Cache) 未校验 Databases(与 ads 等模块同模式),配合 with.Databases 的 panic,缺配置时表现为启动崩溃且报错不含配置项名。 |
| 中 | internal/logic/method/modify.go:79-106 |
删子表 → 更新主表 → 建子表 三步没有事务包裹(对比 add.go:69 的单次 Create 由 GORM 自动开事务),任一步失败都会留下半成品数据。 |
| 低 | internal/impl/impl.go:13-16 |
RedisService/EtcdService/MemorySerice 初始化后本模块无任何使用点(死代码,同 ads)。 |
| 低 | test/add.http:9,16 |
用例 JSON 含 "type" 字段(proto 无此字段)且末尾多一个逗号(非法 JSON),无法直接执行;status 未传。 |
| 低 | test/rpc/rpc.go:3-23 |
整个 main 被注释,且引用不存在的 pb.NewMethodClient/pb.Crc → 死代码。 |
| 低 | 全模块 | 无任何 *_test.go(test/rpc/rpc.go 在 package rpc 下且函数体全注释),上述 IDOR 与附件唯一键冲突均无测试覆盖。 |
| 低 | internal/logic/method/modify.go:56-59 |
图片 identity 缺省时生成新 UUID,意味着"不传 identity 就重建",与"按 identity 幂等更新"的语义不一致;结合 6.2 的重复写入问题,同一图片会被覆盖或冲突。 |
7. 风险汇总
| 编号 | 级别 | 问题 | 影响面 |
|---|---|---|---|
| F1 | 高 | Get/Delete/Remark 不解析身份 + 四接口均无归属校验(IDOR) |
越权读取/删除/篡改他人反馈 |
| F2 | 高 | Get/List 返回 email/phone(PII) |
用户隐私泄露 |
| F3 | 高 | Modify 把主表 identity 改写为随机 UUID |
记录"消失"、外部引用断裂 |
| F4 | 高 | Modify 附件未赋 identity → 子表唯一键冲突 |
修改功能不可用 |
| F5 | 高 | 关联被 Updates 与显式 Create 写两遍 + 无事务 |
修改报错且数据半更新 |
| F6 | 中 | List 的 agency 分支绕过归属过滤;auth.Identity 为空时返回全量 |
机构级数据枚举 |
| F7 | 中 | List/Get 未 Preload 附件 |
接口返回与 proto 契约不符 |
| F8 | 中 | Delete 不清理子表 |
孤儿数据堆积 |
| F9 | 中 | 4 份 etc 配置不符合 SDK 约定,独立进程 Fatal;匿名清单整段失效且引用不存在的服务 | 独立部署不可用、鉴权策略失真 |
| F10 | 中 | status 列重复定义(Std_IICUDS.Status 与自定义 Status) |
建表丢索引、默认值语义漂移 |
| F11 | 低 | 死代码(Redis/Etcd/Memory、IdentRequest、test/rpc)、非法 http 用例、无测试、无事务 |
可维护性 |
8. 修复建议(务实项)
- 补齐归属校验(最高优先):
Get/Delete/Remark先service.ParseMetaCtx(ctx, nil)取auth,再按in.Identity取出记录,比对record.PassportIdentity == auth.Identity;Modify在现有auth基础上加同一比对。管理侧(跨用户操作)另设显式判断,不要用"任意有效 JWT"代表权限。 Modify不要重设identity:从更新结构体里移除Std_IICUDS.Identity(参考Map更新或Select(...)只选允许字段),保留原identity;同时不要把Std_Passport用调用方身份覆写(会造成归属转移)。- 消除重复关联写入:
modify.go:89的Updates前加.Omit(clause.Associations)(或Select限定主表字段),只保留:95-106的显式Create;二者只能留一个。 - 补附件
identity:modify.go:70-76与add.go:57-66对齐,为FeedbackAccessory赋Identity: utils.UUID()。 Delete清理子表:按item_identity = in.Identity先删feedback_images/feedback_accessory,再删主表(与Modify的现有动作一致)。- 事务:把
modify.go:79-106的删/更新/建三步放进impl.DBService.Transaction(func(tx *gorm.DB) error {...})。 Preload补齐:list.go:35、get.go:29增加Preload("Accessories")。List权限收敛:agency过滤仅对管理角色开放;普通用户强制附加passport_identity = auth.Identity;auth.Identity为空时直接返回拒绝而不是放宽条件。Remark限制状态:仅允许管理角色传status,或把status从请求体移除、由服务端按流程计算。Get/Delete/Remark返回值补code:StatusReply.Code填明确成功/失败码,避免调用方只能看message。- 配置修正:把
etc/feedback*.yaml改成 SDK 约定(Service:/Port:/BindIP:/Cache:/Databases:+MicroService: {Enable, Anonymous}),删除feedback.Check.*/feedback.Data.*等不存在的匿名项;conf.NotNil补Databases。 - 模型去重:
feedback_item.go保留一处status定义(建议保留自定义int32并显式写index,或移除自定义字段改用Std_IICUDS.Status并统一类型)。 - 清理与补测:删
impl.go中未使用的 Redis/Etcd/Memory 初始化、proto/feedback.proto:119-122的IdentRequest、test/rpc/rpc.go;修正test/add.http的非法 JSON;为Get/Modify/Delete/Remark各写 1 条越权用例与 1 条正常用例。
本报告只列出与现有实现直接相关的修复项,不引入新的分层、抽象封装或 DTO/VO 改造。
9. 整改记录(2026-09-22)
本节记录按本报告结论执行的代码整改。整改遵循最小修正原则:未引入新框架、抽象层、DTO/VO、事件总线,未拆分服务边界,未修改任何
proto/*.proto与生成的pb/*.go;新增/修改注释均为中文;口令类摘要统一使用 bcrypt(验证码等短时效一次性凭证仍按原有 Redis 明文比对链路存储)。校验方式:GOWORK=off go build ./...+GOWORK=off go vet ./...+gofmt -l(仓库根 workspace 模式存在 genproto 拆包的 ambiguous import,属本机既有问题)。
| 编号 | 级别 | 问题 | 处理结果 |
|---|---|---|---|
| F1 | 高 | Get/Delete/Remark 不解析身份、四接口均无归属校验(IDOR) |
已修复:全部补 service.ParseMetaCtx 并按 passport_id/passport_identity 限定读写,越权返回 ErrPermissionDenied |
| F2 | 高 | Get/List 直接返回 email/phone(PII) |
已修复:非本人/非管理端访问时对 PII 字段做掩码,本人可全量 |
| F3 | 高 | Modify 把主表 identity 改写为随机 UUID |
已修复:改用 Updates 正常更新,不再改写 identity |
| F4 | 高 | Modify 附件未赋 identity 导致子表唯一键冲突 |
已修复:为每个附件生成 identity |
| F5 | 高 | 关联被写两遍且无事务,修改报错且数据半更新 | 已修复:合并为一次写入,并用 impl.DBService.Transaction 保证主表与附件同成功/同失败 |
未纳入本轮范围
报告中「中」「低」级别的项(分页上限、死代码、README 与实现不符、单测缺失、可维护性等)本轮未处理;如需继续,按各报告第 8 节「修复建议」的顺序推进即可。
本轮整改未修改任何
proto/*.proto与pb/*.go,因此少数需要新增接口字段才能完整实现的项目(已在处理结果中标注)做了安全降级。