2026-09-22 18:53:53 +08:00
|
|
|
|
# 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. 核心流程
|
|
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
|
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. 修复建议(务实项)
|
|
|
|
|
|
|
|
|
|
|
|
1. **封死路径穿越(最高优先)**:`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`)。
|
|
|
|
|
|
2. **修掉 `NewSubdir` 的 panic**:`handler.go:32-37` 解析出 `claims` 后补一条 `claims.Identity == ""` 的校验并返回 401;`provider.go:113-116` 对 `len(identity) < 2` 用固定占位(如 `"00"`)兜底。
|
|
|
|
|
|
3. **统一路由前缀**:确定 `/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` 的匿名路径,避免三处各写一套。
|
|
|
|
|
|
4. **上传体前置限流**:`cmd/main/main.go:23` 之后设置 `app.MaxMultipartMemory`;`handler.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.Chmod`(`OpenFile` 已用 0644)。
|
|
|
|
|
|
7. **文件类型加固**:在 `handler.go:63-68` 之后用 `http.DetectContentType` 读前 512 字节做魔数校验,与后缀白名单取交集;同时确认 `Local.Site` 指向的目录不参与脚本解析。
|
|
|
|
|
|
8. **配置校验**:`internal/config/config.go:40` 的 `conf.NotNil` 补上 `Databases`,并在 `New` 中对 `MinioOss`/`Local`/`FtsConfig` 做非空判断(缺失时直接 Fatal 并打印配置项名,优于运行期 panic)。
|
|
|
|
|
|
9. **清理死代码与文档**:删除 `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(或直接删除过期用例)。
|
|
|
|
|
|
10. **补测试**:为"`bucket=../../etc` 穿越被拒"、"JWT 无 identity 时上传被拒"、"超过 `MaxSize` 的请求体被拒"、"DB 失败不留下孤儿文件"各写一条用例;并在 CI 中真正执行 `go test ./...`(当前 `register_test.go` 是红的)。
|
|
|
|
|
|
|
|
|
|
|
|
> 本报告只列出与现有实现直接相关的修复项,不引入新的分层、抽象封装或 DTO/VO 改造。
|
2026-09-22 21:15:34 +08:00
|
|
|
|
|
|
|
|
|
|
## 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`,因此少数需要新增接口字段才能完整实现的项目(已在处理结果中标注)做了安全降级。
|