24 KiB
24 KiB
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/main(Gin 单进程,Port 16290)、聚合入口 pkgs/all(pkgs/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,计算 sha256,往 fts_record 写一条记录并返回可访问的 ResultUrl。它是文件传输与存储服务:不负责下载鉴权、病毒扫描、图片处理,也不提供文件列表/删除/闪传(这些接口在 internal/logic/fetch.go 中只留了注释)。
2. 代码结构与入口
| 路径 | 职责 |
|---|---|
cmd/main/main.go |
独立进程入口:config.New("fts") → impl.NewImpl → gin.Default() → routers.Register → app.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/ping、GET /rest/fts/config |
internal/routers/uploader.go |
鉴权组 POST /rest/fts/uploader(middleware.JwtAuth(true)) |
internal/routers/register_test.go |
路由断言单测,期望 /rest/fts/v1/*(与实现不一致) |
internal/logic/handler.go |
Handler:上传主流程(鉴权解析、参数校验、大小/后缀校验、sha256、分发 provider、落库) |
internal/logic/provider.go |
LocalUpload(本地写盘)、OssUpload(MinIO)、NewSubdir |
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 |
返回 FtsConfig(InputKey/MaxSize/Allows) |
无(匿名组,register.go:23) |
internal/logic/config.go:9 |
| POST | /rest/fts/uploader |
上传文件到 local / minio | JWT(middleware.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/svc、internal/types、exception)→ 文件列表/详情等接口未实现。test/fts_test.go仍在调用的历史路径全部没有注册:/fts/Transfer/Uploader(fts_test.go:26)、/fts/Get/Config(:87)、/fts/Transfer/Prepare(:105)、/fts/Transfer/FileList(:124)、/fts/Transfer/Details(:151);test/oss_provider_req.http:1用的是/fts/v1/uploader。这些接口属历史残留,当前进程只注册上表 4 条路由。
路由前缀存在实现与测试的分歧,见 6.2。
4. 数据模型与表
表 fts_record(internal/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-6的InitData()为空函数且无调用点。
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.ResultUrl(provider.go:71)同样把未校验的 bucket 拼进返回给客户端的 URL。 |
| 中 | internal/logic/provider.go:88-99 |
MinIO 分支的 bucket 同样未校验(handler.go:27 只 ToLower),直接传入 minioClient.PutObject(ctx, bucket, savePath, ...)(provider.go:99)→ 可用当前凭据写入任意桶(跨业务桶污染/覆盖),且 record.OssPath/ResultUrl(provider.go:106-108)记录的攻击者可控桶名会污染数据。 |
| 中 | internal/logic/provider.go:113-116 |
NewSubdir(identity) 直接 identity[0:2] 切片。入参是 JWT 里的 claims.Identity(handler.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 panic(LocalUpload 与 OssUpload 都会走到),请求 500 且 gin 仅记录堆栈。 |
| 中 | internal/logic/handler.go:55-60 |
文件大小限制滞后:c.FormFile(:49)已经把整个 multipart 体解析进内存/临时文件,:57 才比对 file.Size;LocalUpload 还会再次 fh.Open() 完整读一遍(provider.go:48-56)。配合 cmd/main/main.go:23 的 gin.Default() 未设置 MaxMultipartMemory、也没有 http.MaxBytesReader,攻击者可在校验生效前先打满磁盘/内存(配置上限 5GiB,fts_dev.yaml:33),MaxSize 形同虚设。 |
| 中 | internal/logic/handler.go:63-68 |
文件类型只按 filepath.Ext 后缀白名单判断,不做 MIME/魔数校验;Allows 含 .txt(fts_dev.yaml:36-37)意味着可上传任意内容的文本文件,Local.Site(fts_dev.yaml:28)指向的目录若被前置静态服务直接暴露/解析,存在内容投毒与钓鱼托管风险。同时 record.Name = fh.Filename(handler.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.Error 把 err.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:12 与 internal/routers/register_test.go:15-19 |
路由前缀与自带单测不一致:v1_key := path.Join("/rest", srvKey),srvKey = "fts"(cmd/main/main.go:15)→ 实际前缀为 /rest/fts(register.go:12-14、uploader.go:11-14);而 register_test.go:15-19 断言的是 GET /rest/fts/v1/ping、GET /rest/fts/v1/config、POST /rest/fts/v1/uploader。该测试没有 build tag,go test ./... 会以 route not registered 失败。旁证倾向于"前缀丢了 v1 段":test/oss_provider_req.http:1 写 /fts/v1/uploader,而聚合配置只放行了 /rest/fts/ping、/rest/fts/config(pkgs/all/etc/default_dev.yaml:36-37,与实现一致)。两侧必有一处需要修正,现状是测试红、用例错。 |
| 高 | internal/logic/handler.go:88-104 |
本地分支先落盘、后落库::90 的 LocalUpload 先写文件,:101 才 DBService.Create(&record)。DB 失败时文件已生成且没有任何清理(无 os.Remove、无事务)→ 孤儿文件持续堆积;反向的 OssUpload 也一样(对象已上传,DB 失败后对象残留)。 |
| 中 | internal/logic/provider.go:31,43,51,58,65 与 internal/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/Timeseq 再 ctx.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:85、internal/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/svc、internal/types、exception包)。internal/errors/errors.go(118 行)+internal/response/response.go(144 行)整体未被引用:全模块对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-6:InitData()空函数、无调用点。internal/models/fts_record.go:48-98:被注释的GetFileList/GetFileDetails/TableSql(引用不存在的DBService、types.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: 16290(fts_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-15);Local/MinioOss/FtsConfig 缺失时则在 provider.go:26(config.Spec.Local.UploadDir)、provider.go:79-81(config.Spec.MinioOss.*)、handler.go:57,128(config.Spec.FtsConfig.*)空指针 panic——都是运行期崩溃,且报错不指向配置项。 |
| 中 | internal/routers/register.go:20-25 与 uploader.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 /health、GET /health/simple、GET /version、POST /fts/upload、GET /fts/download 全部未注册;目录结构里列出的 internal/health/、scripts/、build/、Dockerfile、docker-compose.yml、Makefile 也都不存在 → 文档与实现严重脱节(README.md:210 还写着 MIT 许可证,与仓库实际内部许可不符)。 |
| 低 | internal/routers/register_test.go(34 行) |
唯一无 build tag 的单测,且当前必然失败(见 6.2),说明 CI 未执行 go test ./...(或已长期忽略)。 |
| 低 | test/fts_test.go:52 |
表单字段名硬编码为 "file",与 FtsConfig.InputKey(fts_dev.yaml:32)耦合,配置改名后用例静默失效。 |
7. 风险汇总
| 编号 | 级别 | 问题 | 影响面 |
|---|---|---|---|
| W1 | 高 | bucket 未校验 → 本地存储路径穿越(任意路径写入) |
服务器文件系统完整性 |
| W2 | 高 | 路由前缀 /rest/fts 与单测期望 /rest/fts/v1 矛盾,测试必失败 |
交付质量、路由契约不确定 |
| W3 | 高 | 先落盘后落库、失败不清理 | 磁盘/对象存储泄漏 |
| W4 | 中 | NewSubdir 对 claims.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. 修复建议(务实项)
- 封死路径穿越(最高优先):
handler.go:26-27对bucket加白名单(如^[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)。 - 修掉
NewSubdir的 panic:handler.go:32-37解析出claims后补一条claims.Identity == ""的校验并返回 401;provider.go:113-116对len(identity) < 2用固定占位(如"00")兜底。 - 统一路由前缀:确定
/rest/fts与/rest/fts/v1哪个是目标值,然后同时修正register.go:12(或register_test.go:15-19)、test/oss_provider_req.http:1、pkgs/all/etc/default_dev.yaml:36-37的匿名路径,避免三处各写一套。 - 上传体前置限流:
cmd/main/main.go:23之后设置app.MaxMultipartMemory;handler.go:49之前用http.MaxBytesReader(c.Writer, c.Request.Body, config.Spec.FtsConfig.MaxSize)包裹请求体,再调用FormFile。 - 响应只写一次:删除
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的全局变量改为按请求构造),消除并发覆盖。 - 落盘与落库顺序:把
handler.go:90与:101调换(先建记录再用其identity落盘),或保留现顺序但在Create失败分支os.Remove(record.LocalPath)/minioClient.RemoveObject(...)回滚;顺手去掉provider.go:63多余的os.Chmod(OpenFile已用 0644)。 - 文件类型加固:在
handler.go:63-68之后用http.DetectContentType读前 512 字节做魔数校验,与后缀白名单取交集;同时确认Local.Site指向的目录不参与脚本解析。 - 配置校验:
internal/config/config.go:40的conf.NotNil补上Databases,并在New中对MinioOss/Local/FtsConfig做非空判断(缺失时直接 Fatal 并打印配置项名,优于运行期 panic)。 - 清理死代码与文档:删除
internal/errors与internal/response两个死包(或只把internal/response的 HTTP 状态码语义用于handler.go的输出)、internal/logic/fetch.go、internal/models/fts_record.go:48-98的注释块、internal/impl/impl.go未使用的 Redis/Memory 初始化、internal/models/query.go的空InitData、provider.go:102-109的fmt.Println;修正README.md:56-67,154-156,181-210的接口清单、目录结构与许可证描述;把test/fts_test.go的 URL/端口更新为/rest/fts/uploader与 16290(或直接删除过期用例)。 - 补测试:为"
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/*.proto与pb/*.go,因此少数需要新增接口字段才能完整实现的项目(已在处理结果中标注)做了安全降级。