Files
full/docs/fts.md
2026-09-22 21:15:34 +08:00

24 KiB
Raw Blame History

fts文件传输与上传存储代码审计报告

内容
审计对象 module/base/fts
服务域 基础与平台服务
审计日期 2026-09-22
代码规模 手写 Go 文件 19 个、1101 行(含 test/fts_test.go 167 行、internal/routers/register_test.go 34 行、internal/errors+internal/response 262 行死包);proto/pb(本模块不提供 gRPC
入口 cmd/mainGin 单进程Port 16290、聚合入口 pkgs/allpkgs/all/internal/service/fts.go,注入 Engine: srv.HTTP
对外协议 仅原生 REST前缀 /rest/fts
结论摘要 上传路由确实挂了 middleware.JwtAuth(true)internal/routers/uploader.go:13鉴权本身已配置;真正的高危点是本地存储路径穿越bucket 来自表单、仅做 ToLower,直接参与 filepath.Join,可写出 UploadDir 之外)、NewSubdir 对 JWT identity 直接切片导致的 panic、以及上传体大小校验晚于 FormFile 造成的资源耗尽;此外路由前缀 /rest/fts 与自带单测期望的 /rest/fts/v1 不一致(go test ./... 必失败),本地分支存在同一响应写两遍,且 262 行的 internal/errors+internal/response 包整体未被引用。

1. 服务定位与职责

接收客户端上传的文件,按 provider 落到本地磁盘或 MinIO计算 sha256fts_record 写一条记录并返回可访问的 ResultUrl。它是文件传输与存储服务:不负责下载鉴权、病毒扫描、图片处理,也不提供文件列表/删除/闪传(这些接口在 internal/logic/fetch.go 中只留了注释)。

2. 代码结构与入口

路径 职责
cmd/main/main.go 独立进程入口:config.New("fts")impl.NewImplgin.Default()routers.Registerapp.Run(:Port)
cmd/cli/main.go CLI实际是生成 licence 文件的构建辅助(硬编码 /data/app/etc/licence.key),与 fts 业务无关
internal/config/config.go 配置结构:Base/Databases/Rpc/APM/Etcd/MinioOss/Local/FtsConfig,含 conf.NotNil 校验
internal/impl/impl.go 初始化 Memory / Redis / DB / Etcd
internal/routers/register.go 路由注册:匿名组 GET /rest/fts/pingGET /rest/fts/config
internal/routers/uploader.go 鉴权组 POST /rest/fts/uploadermiddleware.JwtAuth(true)
internal/routers/register_test.go 路由断言单测,期望 /rest/fts/v1/*(与实现不一致)
internal/logic/handler.go Handler:上传主流程(鉴权解析、参数校验、大小/后缀校验、sha256、分发 provider、落库
internal/logic/provider.go LocalUpload(本地写盘)、OssUploadMinIONewSubdir
internal/logic/config.go GET /config:直接返回 FtsConfig
internal/logic/ping.go GET /ping
internal/logic/fetch.go 整文件被注释,历史 List 逻辑
internal/models/fts_record.go fts_record 表模型 + 迁移注册48-98 行为注释掉的旧实现
internal/models/query.go InitData() 空函数6 行)
internal/errors/errors.go 118 行业务错误码定义,仅被下方 response 包引用
internal/response/response.go 144 行统一响应封装,全模块无调用点
service/{expose,dependencies}.go 聚合宿主注入接口(ExposeOptions.Engine/Config
etc/{fts_dev,fts_prod,fts_test}.yaml 三份内容一致;MinioOss/Local/FtsConfig 三段配置
test/fts_test.go //go:build integration 集成用例URL 为历史路径
test/oss_provider_req.http 手工上传用例(/fts/v1/uploader

3. 接口清单

方法 路径 功能 鉴权 实现位置
GET /rest/fts/ping 健康探测,返回 {"message":"Pong"} 无(匿名组,register.go:20-22 internal/logic/ping.go:9
GET /rest/fts/config 返回 FtsConfigInputKey/MaxSize/Allows 无(匿名组,register.go:23 internal/logic/config.go:9
POST /rest/fts/uploader 上传文件到 local / minio JWTmiddleware.JwtAuth(true)routers/uploader.go:13 internal/logic/handler.go:24
HEAD / 健康检查(infra.Health cmd/main/main.go:36

声明但未实现/占位的接口

  • internal/logic/fetch.go:1-29 整个文件被注释(且引用不存在的 internal/svcinternal/typesexception)→ 文件列表/详情等接口未实现
  • test/fts_test.go 仍在调用的历史路径全部没有注册/fts/Transfer/Uploaderfts_test.go:26)、/fts/Get/Config:87)、/fts/Transfer/Prepare:105)、/fts/Transfer/FileList:124)、/fts/Transfer/Details:151test/oss_provider_req.http:1 用的是 /fts/v1/uploader。这些接口属历史残留,当前进程只注册上表 4 条路由。

路由前缀存在实现与测试的分歧,见 6.2。

4. 数据模型与表

fts_recordinternal/models/fts_record.go

字段 类型 键/约束 说明
id uint PK 自增主键
identity varchar(36) uniqueIndex 记录标识,上传时由 utils.UUID()UUID v7生成
created_at/updated_at/deleted_at TIMESTAMP - 带软删
owner_id uint Index 来自 JWT claims.ID
owner_identity varchar(36) Index 来自 JWT claims.Identity,用于生成子目录
hash varchar(255) not null 文件 sha256仅本地/OSS 上传前计算)
name varchar(255) not null 用户上传的原始文件名(未做字符过滤)
ext varchar(255) not null 文件后缀(含点,如 .png
size uint64 default 0 文件字节数
handle_cmd / handle_args varchar(255) default '' 预留"上传后处理命令/参数"无任何写入点
save_name / local_path / oss_path / result_url varchar(500) default '' 落盘文件名 / 本地绝对路径 / OSS 对象键 / 可访问 URL
status int8 default 0, index 注释定义 -1…5 七种状态,无状态流转逻辑

internal/models/query.go:4-6InitData() 为空函数且无调用点。

5. 核心流程

flowchart TD
    A["POST /rest/fts/uploader"] --> B["middleware.JwtAuth(true) 校验签名与过期"]
    B --> C["middleware.ParseAuth 取 claims"]
    C --> D["读取表单 provider / bucket 并 ToLower"]
    D --> E["c.FormFile(FtsConfig.InputKey) 解析上传体"]
    E --> F["校验 fh.Size <= FtsConfig.MaxSize"]
    F --> G["校验 filepath.Ext 在 FtsConfig.Allows 内"]
    G --> H["sha256 计算 fileHash"]
    H --> I["switch provider"]
    I --> J["local: NewSubdir + Join(UploadDir, bucket, subdir) 写盘"]
    I --> K["minio: PutObject(bucket, subdir/ULID+ext)"]
    J --> L["DBService.Create(fts_record)"]
    K --> L
    L --> M["infra.Response.Success(record)"]

6. 审计发现

6.1 安全

级别 位置 问题
internal/logic/provider.go:25-33(关键::26 本地存储路径穿越saveDir := filepath.Join(config.Spec.Local.UploadDir, bucket, subdirpath),其中 bucket 来自请求表单 c.PostForm("bucket")handler.go:27只做了 strings.ToLower,没有字符集/白名单/长度校验,也没有校验拼接结果仍位于 UploadDir 之下。filepath.Join 会对 .. 做 Clean因此 bucket=../../../../etc/cron.d 之类即可把文件写到 UploadDir 之外(文件名是 utils.ULID()+ext,扩展名受 Allows 约束),属任意路径写入。record.ResultUrlprovider.go:71)同样把未校验的 bucket 拼进返回给客户端的 URL。
internal/logic/provider.go:88-99 MinIO 分支的 bucket 同样未校验(handler.go:27ToLower),直接传入 minioClient.PutObject(ctx, bucket, savePath, ...)provider.go:99)→ 可用当前凭据写入任意桶(跨业务桶污染/覆盖),且 record.OssPath/ResultUrlprovider.go:106-108)记录的攻击者可控桶名会污染数据。
internal/logic/provider.go:113-116 NewSubdir(identity) 直接 identity[0:2] 切片。入参是 JWT 里的 claims.Identityhandler.go:80),而 middleware.JwtAuth(true) 只校验签名与过期时间、不校验 identity 非空或长度D:\work\bsm-sdk\core\middleware\jwt.go:20-63)→ 当 token 的 identity 为空串或长度 <2 时,identity[0:2] 触发 slice bounds out of range panicLocalUploadOssUpload 都会走到),请求 500 且 gin 仅记录堆栈。
internal/logic/handler.go:55-60 文件大小限制滞后c.FormFile:49)已经把整个 multipart 体解析进内存/临时文件,:57 才比对 file.SizeLocalUpload 还会再次 fh.Open() 完整读一遍(provider.go:48-56)。配合 cmd/main/main.go:23gin.Default() 未设置 MaxMultipartMemory、也没有 http.MaxBytesReader,攻击者可在校验生效前先打满磁盘/内存(配置上限 5GiBfts_dev.yaml:33MaxSize 形同虚设。
internal/logic/handler.go:63-68 文件类型只按 filepath.Ext 后缀白名单判断,不做 MIME/魔数校验Allows.txtfts_dev.yaml:36-37)意味着可上传任意内容的文本文件,Local.Sitefts_dev.yaml:28)指向的目录若被前置静态服务直接暴露/解析,存在内容投毒与钓鱼托管风险。同时 record.Name = fh.Filenamehandler.go:81)原样保存用户文件名(含控制字符/超长),无长度与字符过滤。
internal/logic/config.go:10 GET /rest/fts/config 匿名返回完整 FtsConfig(上传字段名、大小上限、全部允许后缀),并在聚合配置中被显式放行(pkgs/all/etc/default_dev.yaml:37)→ 上传策略信息匿名可枚举,便于攻击者按白名单精准构造。
D:\work\bsm-sdk\core\infra\response.go:39-52 infra.Response.Errorerr.Error() 原样回给客户端,未做错误分级/脱敏fts 的错误多为 MinIO、SQL、文件系统错误provider.go:83-85,101-104)→ 内部实现细节外泄。且该方法恒以 HTTP 200 返回(response.go:52),状态码层面无法区分成败。
internal/logic/handler.go:43-47 provider 的白名单校验被整段注释掉(原意图是只允许 local)。当前由 switch provider:88-96+ default 分支兜底,功能上等价,属冗余注释;但注释与代码并存说明该校验曾被有意放开,需确认取舍。

6.2 正确性与逻辑缺陷

级别 位置 问题
internal/routers/register.go:12internal/routers/register_test.go:15-19 路由前缀与自带单测不一致v1_key := path.Join("/rest", srvKey)srvKey = "fts"cmd/main/main.go:15)→ 实际前缀为 /rest/ftsregister.go:12-14uploader.go:11-14);而 register_test.go:15-19 断言的是 GET /rest/fts/v1/pingGET /rest/fts/v1/configPOST /rest/fts/v1/uploader。该测试没有 build taggo test ./... 会以 route not registered 失败。旁证倾向于"前缀丢了 v1 段"test/oss_provider_req.http:1/fts/v1/uploader,而聚合配置只放行了 /rest/fts/ping/rest/fts/configpkgs/all/etc/default_dev.yaml:36-37,与实现一致)。两侧必有一处需要修正,现状是测试红、用例错
internal/logic/handler.go:88-104 本地分支先落盘、后落库::90LocalUpload 先写文件,:101DBService.Create(&record)。DB 失败时文件已生成且没有任何清理(无 os.Remove、无事务)→ 孤儿文件持续堆积;反向的 OssUpload 也一样对象已上传DB 失败后对象残留)。
internal/logic/provider.go:31,43,51,58,65internal/logic/handler.go:97-100 同一响应被写两次LocalUpload 内部每个失败分支都调用了 infra.Response.Error(ctx, err)5 处),随后 handler.go:97-100 在拿到 error 后又调用一次 → 一个请求写出两份 JSON 响应体gin 会打印 headers were already written 警告,客户端可能读到被截断/拼接的响应。
D:\work\bsm-sdk\core\infra\response.go:12,25-34,39-53 所有 fts handler 通过包级共享变量 var Response Reply 输出响应,Success/Error 直接改写该全局结构体的 Code/Message/Details/Timeseqctx.JSON。并发上传时两个请求会相互覆盖字段(数据竞争,go test -race 可复现),响应内容可能张冠李戴。缺陷在 SDK但 fts 是本仓库唯一使用方,需在 handler 侧规避或修 SDK。
internal/logic/provider.go:71,108 ResultUrl 由字符串拼接生成(Site + "/" + bucket + "/" + subdir + "/" + fileName),未做 URL 转义,也未校验 bucket 是否含 /..;配合 6.1 的桶名未校验问题,可产出畸形或可跳转到其它路径的下载地址。
internal/logic/provider.go:99 MinIO 上传固定 ContentType: "application/octet-stream",与按图片/视频内联展示的常见需求不符(浏览器会触发下载而不是渲染)。
internal/logic/provider.go:102,107,109 残留调试输出 fmt.Println("err = ", err)fmt.Println("savePath = ", savePath)fmt.Println("record.ResultUrl = ", record.ResultUrl),会写入 stdout 被 supervisor 收集(etc/supervisor.bsm-apps-fts.conf:8)。
internal/logic/handler.go:85internal/models/fts_record.go:36 record.Status = 0 写死;fts_record.status 注释定义了 -1…5 共 7 种状态,handle_cmd/handle_args 也无写入点 → 状态机与"上传后处理"能力未实现,字段仅为预留。
internal/logic/provider.go:36,89 落盘/对象键用 utils.ULID() + record.Ext,扩展名直接取自用户文件名。风险有限(filepath.Ext 只返回最后一个 . 之后部分),但未做字符过滤。

6.3 未完成实现

  • internal/logic/fetch.go:1-29:整文件被注释,文件列表等接口未实现(引用不存在的 internal/svcinternal/typesexception 包)。
  • internal/errors/errors.go118 行)+ internal/response/response.go144 行)整体未被引用:全模块对 internal/errors 的唯一引用来自 internal/response/response.go:11而后者本身无任何调用点handler/provider 全部走 SDK 的 infra.Response。值得注意的是被弃用的那份实现反而更正确——internal/response/response.go:44-85 会按错误码返回真实 HTTP 状态404/401/403/400/429/500而 SDK 版本恒返回 200。
  • internal/models/query.go:4-6InitData() 空函数、无调用点。
  • internal/models/fts_record.go:48-98:被注释的 GetFileList/GetFileDetails/TableSql(引用不存在的 DBServicetypes.Record)→ 死代码。
  • cmd/cli/main.go:12-18:与 fts 无关,是生成 licence 文件的构建脚本,硬编码写入 /data/app/etc/licence.key
  • test/fts_test.go//go:build integration):调用 /fts/Transfer/*/fts/Get/Config已不存在的接口,且端口 12214 与当前 Port: 16290fts_dev.yaml:3)不一致 → 已过期。
  • 未发现显式 TODO 注释。

6.4 健壮性与可维护性

级别 位置 问题
internal/config/config.go:40 conf.NotNil(Spec.Service, Spec.Cache) 未校验 Databases/MinioOss/Local/FtsConfig。其中 Databases 缺失时 with.Databases 直接 panic("No Database Source Found !")D:\work\bsm-sdk\core\with\databases.go:13-15Local/MinioOss/FtsConfig 缺失时则在 provider.go:26config.Spec.Local.UploadDir)、provider.go:79-81config.Spec.MinioOss.*)、handler.go:57,128config.Spec.FtsConfig.*)空指针 panic——都是运行期崩溃,且报错不指向配置项
internal/routers/register.go:20-25uploader.go:11-14 匿名组与鉴权组用同一个前缀在两个文件分别 engine.Group(v1_key),鉴权边界被拆散维护。本次 /rest/fts 与测试期望 /rest/fts/v1 的分歧正是这种写法的直接后果。
internal/impl/impl.go:13-24 MemorySerice(拼写错误,应为 MemoryService)与 RedisService 初始化后本模块无任何使用点 → 死代码。
internal/logic/handler.go:32-37,39-42 错误码用 errcode.NewError(400, ...)/errcode.NewError(501, ...)handler.go:40,66),但 infra.Response.Error 恒以 HTTP 200 返回 → 网关/监控无法按 HTTP 状态识别失败,客户端的 4xx 重试逻辑失效。
README.md:56-67,154-156,181-189 README 声明的 GET /healthGET /health/simpleGET /versionPOST /fts/uploadGET /fts/download 全部未注册;目录结构里列出的 internal/health/scripts/build/Dockerfiledocker-compose.ymlMakefile 也都不存在 → 文档与实现严重脱节README.md:210 还写着 MIT 许可证,与仓库实际内部许可不符)。
internal/routers/register_test.go34 行) 唯一无 build tag 的单测,且当前必然失败(见 6.2),说明 CI 未执行 go test ./...(或已长期忽略)。
test/fts_test.go:52 表单字段名硬编码为 "file",与 FtsConfig.InputKeyfts_dev.yaml:32)耦合,配置改名后用例静默失效。

7. 风险汇总

编号 级别 问题 影响面
W1 bucket 未校验 → 本地存储路径穿越(任意路径写入) 服务器文件系统完整性
W2 路由前缀 /rest/fts 与单测期望 /rest/fts/v1 矛盾,测试必失败 交付质量、路由契约不确定
W3 先落盘后落库、失败不清理 磁盘/对象存储泄漏
W4 NewSubdirclaims.Identity 切片 → 空 identity 触发 panic 接口可用性500
W5 大小限制晚于 FormFile,且无请求体上限 磁盘/内存耗尽DoS
W6 MinIO bucket 未校验 → 可写任意桶 跨业务数据污染
W7 LocalUpload 与 handler 重复写响应 + SDK 全局 infra.Response 并发覆盖 响应错乱、数据竞争
W8 仅后缀白名单、无 MIME 校验,Local.Site 目录或可托管任意文本内容 内容投毒
W9 配置项未校验,缺失即运行期 panic 部署健壮性
W10 262 行死包(internal/errors+internal/response)、logic/fetch.go、注释块、fmt.Println、README 失真、过期集成用例 可维护性

8. 修复建议(务实项)

  1. 封死路径穿越(最高优先)handler.go:26-27bucket 加白名单(如 ^[a-zA-Z0-9_-]{1,64}$),或改为服务端配置的枚举映射(不接收任意字符串);在 provider.go:26 拼出 saveDir 后追加一次校验——filepath.Clean(saveDir) 必须以 filepath.Clean(config.Spec.Local.UploadDir) 为前缀否则直接报错返回。MinIO 侧同样把 bucket 限制在允许列表内(provider.go:92-99)。
  2. 修掉 NewSubdir 的 panichandler.go:32-37 解析出 claims 后补一条 claims.Identity == "" 的校验并返回 401provider.go:113-116len(identity) < 2 用固定占位(如 "00")兜底。
  3. 统一路由前缀:确定 /rest/fts/rest/fts/v1 哪个是目标值,然后同时修正 register.go:12(或 register_test.go:15-19)、test/oss_provider_req.http:1pkgs/all/etc/default_dev.yaml:36-37 的匿名路径,避免三处各写一套。
  4. 上传体前置限流cmd/main/main.go:23 之后设置 app.MaxMultipartMemoryhandler.go:49 之前用 http.MaxBytesReader(c.Writer, c.Request.Body, config.Spec.FtsConfig.MaxSize) 包裹请求体,再调用 FormFile
  5. 响应只写一次:删除 provider.go:31,43,51,58,65 内部的 infra.Response.Error 调用,只 return err,由 handler.go:97-100 统一输出;并把 infra.Response 换成 handler 内的局部响应结构(或在 SDK 侧把 core/infra/response.go:12 的全局变量改为按请求构造),消除并发覆盖。
  6. 落盘与落库顺序:把 handler.go:90:101 调换(先建记录再用其 identity 落盘),或保留现顺序但在 Create 失败分支 os.Remove(record.LocalPath) / minioClient.RemoveObject(...) 回滚;顺手去掉 provider.go:63 多余的 os.ChmodOpenFile 已用 0644
  7. 文件类型加固:在 handler.go:63-68 之后用 http.DetectContentType 读前 512 字节做魔数校验,与后缀白名单取交集;同时确认 Local.Site 指向的目录不参与脚本解析。
  8. 配置校验internal/config/config.go:40conf.NotNil 补上 Databases,并在 New 中对 MinioOss/Local/FtsConfig 做非空判断(缺失时直接 Fatal 并打印配置项名,优于运行期 panic
  9. 清理死代码与文档:删除 internal/errorsinternal/response 两个死包(或只把 internal/response 的 HTTP 状态码语义用于 handler.go 的输出)、internal/logic/fetch.gointernal/models/fts_record.go:48-98 的注释块、internal/impl/impl.go 未使用的 Redis/Memory 初始化、internal/models/query.go 的空 InitDataprovider.go:102-109fmt.Println;修正 README.md:56-67,154-156,181-210 的接口清单、目录结构与许可证描述;把 test/fts_test.go 的 URL/端口更新为 /rest/fts/uploader 与 16290或直接删除过期用例
  10. 补测试:为"bucket=../../etc 穿越被拒"、"JWT 无 identity 时上传被拒"、"超过 MaxSize 的请求体被拒"、"DB 失败不留下孤儿文件"各写一条用例;并在 CI 中真正执行 go test ./...(当前 register_test.go 是红的)。

本报告只列出与现有实现直接相关的修复项,不引入新的分层、抽象封装或 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属本机既有问题

编号 级别 问题 处理结果
W1 bucket 未校验导致本地存储路径穿越 已修复:对 bucket/文件名做净化与白名单校验(拒绝 ..、绝对路径、路径分隔符),并用 filepath.Clean + 根目录前缀校验兜底;新增 internal/logic/guard_test.go 覆盖路径穿越用例
W2 路由前缀 /rest/fts 与单测期望 /rest/fts/v1 矛盾,测试必失败 已修复:统一为同一前缀,GOWORK=off go test ./... 通过
W3 先落盘后落库、失败不清理 已修复:落库失败时删除已写入的本地/对象存储文件

未纳入本轮范围

报告中「中」「低」级别的项分页上限、死代码、README 与实现不符、单测缺失、可维护性等)本轮未处理;如需继续,按各报告第 8 节「修复建议」的顺序推进即可。

本轮整改未修改任何 proto/*.protopb/*.go,因此少数需要新增接口字段才能完整实现的项目(已在处理结果中标注)做了安全降级。