Files
full/docs/audit/module-base-fts.md
yanweidong 63aeedc2fe docs: add per-module audit reports (18 modules)
Add static security/quality audit reports for all 18 Go service modules under module/, plus a consolidated index (docs/audit/README.md) with per-module statistics, top risks, cross-module systemic defects and a phased TODO list (T1-T19). No production code is modified.
2026-09-14 22:16:27 +08:00

636 lines
58 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 审计报告module/base/fts
## 1. 模块概览
`module/base/fts` 是 BSM 平台的**文件上传/存储服务File Transfer Service**,对外只提供 3 个 Gin REST 接口:`GET /rest/fts/ping``GET /rest/fts/config`(匿名)、`POST /rest/fts/uploader`(模块自带 `middleware.JwtAuth(true)`)。上传支持两种 provider本地目录配置 `Local.UploadDir`)与 MinIO 对象存储,落库表 `fts_record`。绝对路径 `D:\work\bsm-infra\full\module\base\fts`,模块名 `bsm/full/module/base/fts``module/base/fts/go.mod:1`Go 1.26.5`git.apinb.com/bsm-sdk/core``replace` 指向本地 `D:\work\bsm-sdk\core``module/base/fts/go.mod:97`,工作区 `go.work:7`)。
| 项目 | 内容 |
| --- | --- |
| 服务形态 | 纯 Gin REST无 proto / 无 gRPC |
| 路由前缀 | `/rest/{srvKey}`,即 `/rest/fts/*``internal/routers/register.go:12` |
| 端口 | 16290`cmd/main/main.go:15` + `etc/fts_dev.yaml:3`);聚合模式下由宿主服务器决定 |
| 上传入口 | `internal/logic/handler.go``internal/logic/provider.go``LocalUpload` / `OssUpload` |
| 存储 | 本地 `Local.UploadDir``etc/fts_dev.yaml:29` = `./uploader/`+ MinIO `MinioOss``etc/fts_dev.yaml:20-25` |
| 数据表 | `fts_record``internal/models/fts_record.go:39-46``database.MigrateTables` 自注册) |
| 非 pb 源码量 | 19 个 `.go` 文件(`internal/` 13、`service/` 2、`cmd/` 2、`test/` 1、根 0共约 900 行,其中约 130 行为整段注释掉的死代码 |
| 调用方 | 独立进程 `cmd/main/main.go`supervisor 部署为 `bsm-apps-fts`),或由 `pkgs/all``pkgs/ecmall` 通过 `service.Expose` 内嵌(`pkgs/all/internal/service/fts.go:10-16` |
| 测试 | 单元测试 1 个且**当前失败**`internal/routers/register_test.go``test/fts_test.go``//go:build integration` 且 URL 指向已不存在的路由 |
关键调用链:`config.New("fts")``impl.NewImpl()`Redis/DB/Etcd/内存缓存,均用不上)→ `gin.Default()` + `Cors` + `Recovery``routers.Register("fts", app)``POST /rest/fts/uploader``JwtAuth(true)``ParseAuth``FormFile` → 大小/扩展名校验 → SHA-256 → `LocalUpload`/`OssUpload``impl.DBService.Create(record)``infra.Response.Success(c, record)`
**一句话结论**`provider` 选择与扩展名白名单外,`bucket` 完全未校验并被直接拼进本地路径(`filepath.Join(UploadDir, bucket, ...)`),构成**认证用户可任意目录写文件**的路径穿越;叠加 SDK 层两处缺陷(`errcode.NewError` 写全局 map、`infra.Response` 全局单例被并发写)与"先落盘后限流"的上传体积校验,本模块的安全与稳定性基线明显不足。
## 2. 审计范围与方法
**已完整读取(逐行)**
- 全部非 pb 生产代码:`internal/config/config.go``internal/errors/errors.go``internal/impl/impl.go``internal/logic/{handler,provider,config,ping,fetch}.go``internal/models/{fts_record,query}.go``internal/response/response.go``internal/routers/{register,uploader,register_test}.go``service/{expose,dependencies}.go``cmd/main/main.go``cmd/cli/main.go``test/fts_test.go``test/oss_provider_req.http`
- 配置与部署:`etc/fts_{dev,test,prod}.yaml``etc/supervisor.bsm-apps-fts.conf``go.mod``README.md``wiki/api/16-fts-rest.md`
- 依赖实现(决定本模块真实运行时行为,结论中已标注为外部证据):`D:\work\bsm-sdk\core\{middleware/jwt.go,middleware/cors.go,infra/response.go,errcode/errcode.go,env/env.go,conf/new.go,crypto/token/jwt.go,utils/identity.go}``github.com/golang-jwt/jwt/v5@v5.3.1/{types.go,registered_claims.go}``github.com/gin-gonic/gin@v1.12.0/{gin.go,context.go}``net/http/server.go`GOROOT `D:\devapps\Go`)。
- 聚合上下文:`pkgs/all/internal/{service/fts.go,config/config.go,server/authorization.go}``pkgs/all/etc/default_dev.yaml``pkgs/ecmall/AGENT.md`(既有结论交叉核对)。
**方法**:以"路由 → 中间件 → handler → provider → 落库/响应 → SDK"为数据流逐段取证;每个结论给出 `文件:行号` + 原始代码片段。仓库内无法验证的部分真实生产环境变量、MinIO 桶策略、`files.apinb.com` 静态托管行为、真实 JWT 签发方)明确标注「推测」或「无法验证」。
**静态检查**(在 `D:\work\bsm-infra\full\module\base\fts` 执行,`GOPROXY=off` 走本地模块缓存):
- `go vet ./...` → 无输出,退出码 0无 vet 报告)。
- `gofmt -l .``internal\routers\register_test.go`(该文件 34/34 行均为 CRLF被 gofmt 判为未格式化);退出码 0。
- `go test ./internal/...`**FAIL**`route not registered: GET /rest/fts/v1/ping`3 条断言全失败),退出码 1。
**未覆盖**:真实数据库表结构与线上数据、真实 MinIO 桶策略/AK-SK、生产 `BSM_JwtSecretKey` 是否设置、`files.apinb.com` 的静态服务实现、`tmp` 目录所在磁盘容量。未阅读 `go.sum``cmd/cli` 之外的构建脚本(模块内不存在 Makefile/Dockerfile/docker-compose见 P3-20
## 3. 问题清单
### P0
#### 1. `bucket` 表单参数未做任何校验即拼入本地路径构成路径穿越任意目录写文件MinIO 侧可写入任意桶
- **位置**`module/base/fts/internal/logic/handler.go:27``handler.go:39-42``module/base/fts/internal/logic/provider.go:26-27``provider.go:37``provider.go:90`
- **证据**
```go
// handler.go:27,39-42 bucket 来自客户端表单,仅判空
bucket = strings.ToLower(c.PostForm("bucket"))
...
if provider == "" || bucket == "" {
infra.Response.Error(c, errcode.NewError(400, "参数错误"))
return
}
```
```go
// provider.go:25-27 bucket 直接成为路径分量filepath.Join 会 Clean 掉 ".."
subdirpath := NewSubdir(record.OwnerIdentity)
saveDir := filepath.Join(config.Spec.Local.UploadDir, bucket, subdirpath)
...
savePath := filepath.Join(saveDir, fileName) // provider.go:37
```
```go
// provider.go:89-99 MinIO 路径同样信任 bucket
savePath := filepath.Join(subdirpath, fileName)
_, err = minioClient.PutObject(context.Background(), bucket, savePath, src, file.Size, ...)
```
`filepath.Join("./uploader/", "../../../../etc", "2026-01/ab")` 经 `Clean` 后得到 `"../../etc/2026-01/ab"`,即**跳出 `UploadDir`**。文件名由服务端 ULID 生成,但扩展名来自白名单(`.txt/.png/.mp4/...`),内容完全由攻击者控制。
- **影响**:任何持有效 JWT 的用户可向进程可写的**任意目录**写入内容可控、扩展名受限的文件(例如写入被静态服务托管或被定时任务扫描的目录,造成存储型 XSS / 二次利用;也可在任意路径批量创建目录与文件耗尽 inode/磁盘。MinIO 分支同理可向**任意已存在的桶**写对象,形成跨租户写入。因文件名不可预测,覆盖既有文件难度较大,但"任意目录写入 + 无配额 + 无删除接口"本身已足够造成数据面污染与持久化。`/rest/fts/config` 匿名暴露白名单(`internal/logic/config.go:10`),攻击者可先取白名单再挑选扩展名。
- **建议**`bucket` 必须做白名单/正则校验(如 `^[a-z0-9][a-z0-9._-]{0,62}$`)并拒绝 `.`、`..`、含 `/`、`\`、`:` 的值;落盘前对最终路径做 `filepath.Clean` + `strings.HasPrefix(cleanPath, cleanRoot+string(os.PathSeparator))` 的前缀断言(`filepath.Rel` 校验MinIO 侧另用 `BucketExists` 校验桶存在且属于本服务、对象名前缀固定。参考 `wiki/api/16-fts-rest.md:24``bucket` 的描述("存储桶或本地目录分类")本就没有约束约定,应同步补文档。
#### 2. 每个错误分支都调用 `errcode.NewError`,其内部写全局 map并发请求会触发 `fatal error: concurrent map writes` 直接打死进程
- **位置**`module/base/fts/internal/logic/handler.go:40``handler.go:58``handler.go:66``handler.go:94`(调用点);根因 `D:\work\bsm-sdk\core\errcode\errcode.go:12``errcode.go:86-88`
- **证据**
```go
// handler.go:40 每个错误分支都在请求期构造错误码
infra.Response.Error(c, errcode.NewError(400, "参数错误"))
```
```go
// D:\work\bsm-sdk\core\errcode\errcode.go:12
AllErrors = make(map[int]string)
// :86-88
func NewError(code int, msg string) error {
AllErrors[code] = msg
return status.New(codes.Code(code), msg).Err()
}
```
`AllErrors` 是普通 map`NewError` 在**请求路径上**写入Go 运行时的并发 map 写检测是 `throw`(不可被 `gin.Recovery()` 捕获),整进程退出。
- **影响**:一个已认证用户只需并发发送 N 个会走错误分支的请求(如 `provider`/`bucket` 留空、扩展名不在白名单、超过 `MaxSize`)即可让服务进程崩溃重启;在 supervisor `autorestart=true``etc/supervisor.bsm-apps-fts.conf:5`)下表现为反复重启的可用性事故。触发条件只是"两个写同时发生",在正常并发下概率很高(静态推断:无法在本环境构造并发请求验证,但 map 写语义确定)。
- **建议**短期——fts 侧不要在请求期调用 `errcode.NewError`,改用预定义错误变量或本模块已有的 `internal/errors`(当前完全未被使用,见 P3-18根治——SDK 的 `NewError` 去掉 `AllErrors` 写入(该 map 除写入外无任何读取点)或改用 `sync.Map`/`sync.RWMutex`。**此问题属 SDK 根因,需与 `bsm-sdk` 维护方同步修复。**
### P1
#### 3. 上传体积上限形同虚设:`MaxSize` 校验发生在整个 multipart 请求体已落盘之后,且无 `MaxBytesReader`、无服务端读写超时
- **位置**`module/base/fts/internal/logic/handler.go:49-60`、`etc/fts_dev.yaml:33`、`cmd/main/main.go:42`
- **证据**
```go
// handler.go:49-60 先 FormFile解析并落盘整个 body再比较 Size
fh, err := c.FormFile(config.Spec.FtsConfig.InputKey)
...
fileSize := fh.Size
if fileSize > config.Spec.FtsConfig.MaxSize {
```
```go
// etc/fts_dev.yaml:33 默认上限 5 GiB
MaxSize: 5368709120 # MB
```
依赖行为:`gin.Context.FormFile` → `c.Request.ParseMultipartForm(c.engine.MaxMultipartMemory)``gin.Default()` 的 `MaxMultipartMemory` 为 32 MiB`gin@v1.12.0/gin.go:26,221`、`context.go:700`),超出部分**写入临时文件**`net/http` 在请求结束才 `RemoveAll`GOROOT `src/net/http/server.go:1683`)。`cmd/main/main.go:42` 直接用 `app.Run`,没有 `ReadTimeout`/`WriteTimeout`/`MaxHeaderBytes`;聚合模式下的宿主服务器只设了 `ReadHeaderTimeout`/`IdleTimeout``pkgs/all/internal/server/server.go:75-76`)。
- **影响**:攻击者可发送远大于 5 GiB 的 body例如 500 GiB 的 chunked 请求),服务端会先把它全部写进 `/tmp` 才返回"文件大小超过限制";并发若干次即可打满临时盘与磁盘 IO且连接可能长期占用。认证用户可重复上传 5 GiB 文件(无配额、无速率限制、且没有删除/下载接口,见 P2-8/P3-19持续吃满存储。
- **建议**:入口先 `c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+overhead)`,或使用 `c.Request.MultipartReader()` 流式读取并边读边计数、超限立即中断;`MaxSize` 之外增加文件数/字段数限制(`ParseMultipartForm` 的 `maxMemory` 与 `MaxBytesReader` 配合);`http.Server` 显式设置 `ReadTimeout`/`WriteTimeout`/`IdleTimeout`;为单用户加配额与并发上传数限制。
#### 4. 本地上传失败时响应被写两次(双 JSON 体),客户端拿到非法 JSON
- **位置**`module/base/fts/internal/logic/provider.go:29-33`、`provider.go:40-45`、`provider.go:48-53`、`provider.go:56-60`、`provider.go:63-67`、`module/base/fts/internal/logic/handler.go:88-100`
- **证据**
```go
// provider.go:40-45 LocalUpload 内部自己写响应并返回 err
file, err := os.OpenFile(savePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
log.Println("文件创建失败:", err)
infra.Response.Error(ctx, err)
return
}
```
```go
// handler.go:88-100 调用方又写一次
case "local":
err = LocalUpload(fh, &record, c, bucket)
...
if err != nil {
infra.Response.Error(c, err)
return
}
```
同一错误路径共 5 处(`provider.go:31,43,51,58,65`)都会先由 `LocalUpload` 写响应,再被 handler 写第二次。
- **影响**`infra.Response.Error` 用 `ctx.JSON(200, reply)``D:\work\bsm-sdk\core\infra\response.go:52`),第二次写会触发 `http: superfluous response.WriteHeader call` 并把第二段 JSON 追加到 body响应体形如 `{...}{...}` —— 任何严格 JSON 解析的客户端都会失败。磁盘满、上传目录不可写、文件被删除等任何本地写失败都会命中5 GiB 上传场景下磁盘满是现实风险),且失败原因被第一段响应"占位"后第二段的真实错误信息对客户端变得不可预测。
- **建议**存储层provider只返回 error绝不写响应响应统一由 handler 决定(`handler.go:97-100`)。同时把 `infra.Response.Error(c, err)` 换成不会重复写的统一出口,并改用本模块 `internal/errors`P3-18以保留 HTTP 语义。
#### 5. `infra.Response` 是包级单例且被并发写:响应结构体数据竞争,可能把 A 用户的数据/错误写进 B 用户的响应
- **位置**`module/base/fts/internal/logic/handler.go:106`、`handler.go:35/40/51/58/66/73/94/98/102`、`internal/logic/config.go:10`、`internal/logic/ping.go:10`;根因 `D:\work\bsm-sdk\core\infra\response.go:12-52`
- **证据**
```go
// D:\work\bsm-sdk\core\infra\response.go:12 包级共享单例
var Response Reply
// :25-33 指针接收者方法直接改写共享字段
func (reply *Reply) Success(ctx *gin.Context, data any) {
reply.Code = 0
reply.Details = data
reply.Message = ""
...
ctx.JSON(200, reply)
}
```
```go
// handler.go:106 每次上传都走这条路径
infra.Response.Success(c, record)
```
- **影响**:所有并发请求共享同一个 `Reply` 实例,`Success/Error` 先写字段再 JSON 序列化,两个请求交错时会互相覆盖 `Code/Message/Details``Details` 里放的是含用户文件路径、OwnerID、OwnerIdentity 的 `record`,最坏情况是把一个用户的上传记录(含 `local_path`)返回给另一个用户,或让成功的请求收到错误码。这是确定存在的数据竞争(`go test -race` 必然命中静态推断本环境未构造并发压测。fts 的每个接口都命中它。
- **建议**SDK 侧改为 `func (Reply) Success(...)`(值接收者)或每次 `Response := Reply{}` 局部构造;模块侧在 SDK 修复前可改为自己构造响应结构(本模块已有 `internal/response` 的正确实现,见 P3-18。**需与 `bsm-sdk` 维护方同步。**
#### 6. JWT 校验密钥可回退到硬编码默认值,而模块自身配置里的 `SecretKey` 与 JWT 无关(独立部署下等于鉴权可伪造)
- **位置**`module/base/fts/internal/routers/uploader.go:13`、`etc/fts_prod.yaml:17`、`etc/supervisor.bsm-apps-fts.conf:1-8`;根因 `D:\work\bsm-sdk\core\env\env.go:19`、`D:\work\bsm-sdk\core\middleware\jwt.go:34,50`
- **证据**
```go
// uploader.go:11-14 上传路由的鉴权中间件
auth := engine.Group(v1_key)
{
auth.Use(middleware.JwtAuth(true))
auth.POST("/uploader", logic.Handler)
```
```go
// D:\work\bsm-sdk\core\env\env.go:19 未设置环境变量时使用硬编码默认密钥
JwtSecretKey: GetEnvDefault("BSM_JwtSecretKey", "Cblocksmesh2022C"),
// middleware/jwt.go:50
claims, err := token.New(env.Runtime.JwtSecretKey).ParseJwt(authHeader)
```
```yaml
# etc/fts_prod.yaml:17 这个 SecretKey 不会用于 JWTconf.Base 字段,仅微服务模式使用)
SecretKey: CHANGE_ME
```
```ini
# etc/supervisor.bsm-apps-fts.conf:1-8 未注入 environment=,即未设置 BSM_JwtSecretKey
[program:bsm-apps-fts]
command=/data/app/bsm-apps-fts
```
- **影响**:若独立部署(`cmd/main` + 上述 supervisor + `etc/fts_*.yaml`)未在进程环境里设置 `BSM_JwtSecretKey`,则 JWT 的 HMAC 密钥就是公开在 SDK 源码里的 `Cblocksmesh2022C`,任何人都能离线签发任意 `identity/id` 的 token → 直接绕过上传鉴权,并与 P0-1 组合成"未认证任意目录写文件"。聚合部署(`pkgs/all`/`pkgs/ecmall`)不受此影响:宿主会在 `pkgs/all/internal/config/config.go:73` 用 `Authorization.Key` 覆盖 `env.Runtime.JwtSecretKey`,且对空值/长度有 panic 校验(`config.go:63-69`)。
- **推测部分**:生产是否真的通过容器/systemd 环境注入 `BSM_JwtSecretKey` 无法在仓库内验证若已注入≥16 字节强随机)则本条降为 P3。但"模块自带配置项 `SecretKey` 与 JWT 密钥无关、且默认值可回退"这一设计问题本身确定存在。
- **建议**:在 `internal/config/config.go:New` 中显式要求并校验 JWT 密钥(或直接 `env.NewEnv().JwtSecretKey = Spec.SecretKey` 并做长度校验),移除对 SDK 默认值的隐式依赖sup 配置文件补 `environment=BSM_JwtSecretKey="..."`;文档(`README.md:11`、`wiki/api/16-fts-rest.md:11`)注明密钥来源。
#### 7. 鉴权中间件在验签之前解析 JWT payload缺失 `exp` 声明时 nil 解引用 panic未认证可触发
- **位置**`module/base/fts/internal/routers/uploader.go:13`(触发点);根因 `D:\work\bsm-sdk\core\middleware\jwt.go:34`、`D:\work\bsm-sdk\core\crypto\token\jwt.go:76-97`
- **证据**
```go
// D:\work\bsm-sdk\core\middleware\jwt.go:32-34 先查过期,再验签
if time_verify {
isExpire, err := token.New(env.Runtime.JwtSecretKey).IsExpired(authHeader)
// D:\work\bsm-sdk\core\crypto\token\jwt.go:90-97
var claims jwt.RegisteredClaims
if err := json.Unmarshal(payload, &claims); err != nil { ... }
currentTime := time.Now().Unix()
return claims.ExpiresAt.Unix() < currentTime, nil // ExpiresAt 为 *NumericDate未提供 exp 时为 nil
```
类型证据:`jwt/v5@v5.3.1/registered_claims.go:23` `ExpiresAt *NumericDate``types.go:32-34` `type NumericDate struct { time.Time }`(值内嵌 → 对 nil 指针调用 `Unix()` 必然解引用空指针)。
- **影响**:攻击者无需有效 token只要发送 `Authorization: a.<base64({"sub":"x"})>.c`payload 无 `exp`)即可在**通过 JWT 中间件之前**触发 panic`gin.Recovery()``cmd/main/main.go:27`)会兜住并返回 500但会产生完整堆栈日志与额外的 panic/recover 开销,可被用于日志洪水与 CPU 消耗。若未来移除 Recovery 或换用其他宿主,则直接断连/崩溃。此外 `JwtAuth(true)` 的过期判断只看 payload不校验签名`ParseJwt` 才验签),属于"先解析后验签"的顺序缺陷。
- **建议**SDK 侧在 `IsExpired` 中判空(`if claims.ExpiresAt == nil { return true, errcode.ErrTokenDataInvalid }`),并把过期判断挪到 `ParseJwt` 之后(`jwt.WithExpirationRequired()``ParseJwt` 补 `jwt.WithValidMethods([]string{"HS256"})`。**需与 `bsm-sdk` 维护方同步。**
### P2
#### 8. 无失败回滚/清理:写文件成功后任一失败都会留下孤儿文件,`Chmod` 失败还会把成功写成失败
- **位置**`module/base/fts/internal/logic/provider.go:29-33`、`provider.go:56-60`、`provider.go:62-67`、`module/base/fts/internal/logic/handler.go:101-104`
- **证据**
```go
// provider.go:56-67 先写入文件,之后任何失败都不删除已写入内容
if _, err = io.Copy(file, src); err != nil {
log.Println("文件保存失败:", err)
infra.Response.Error(ctx, err)
return
}
// 再次确认文件权限
if err = os.Chmod(savePath, 0644); err != nil {
log.Printf("警告: 设置文件权限失败: %v", err)
infra.Response.Error(ctx, err)
return
}
```
```go
// handler.go:101-104 文件已落盘DB 写入失败时无补偿
if err := impl.DBService.Create(&record).Error; err != nil {
infra.Response.Error(c, err)
return
}
```
- **影响**DB 插入失败(例如 `Name` 超过 `varchar(255)`、连接抖动)或 `Chmod` 失败时,文件/对象已经写入存储但库里没有记录,形成**永久无法通过接口追溯、也没有删除接口可清理的孤儿文件**`fts_record` 是唯一索引来源,`internal/models/fts_record.go:18`);攻击者甚至可以反复触发 DB 失败来纯粹地消耗存储。`Chmod` 失败更严重:文件已完整写入,却被当成上传失败返回(叠加 P1-4 的双响应)。
- **建议**`LocalUpload`/`OssUpload` 在返回错误前 `os.Remove(savePath)` / `RemoveObject` 回滚handler 在 DB 失败时同样回滚(或改为"先写库status=待处理)再落盘,失败置 status=-1"的两阶段方案);`Chmod` 失败降级为告警不返回错误(创建时 `OpenFile` 的 mode 已经足够)。
#### 9. 敏感信息外泄:响应把服务器绝对路径回传客户端,`infra.Response.Error` 把原始错误文本透传给调用方
- **位置**`module/base/fts/internal/logic/handler.go:106`、`module/base/fts/internal/models/fts_record.go:31-34`、`module/base/fts/internal/logic/provider.go:69-71`;根因 `D:\work\bsm-sdk\core\infra\response.go:47-49`
- **证据**
```go
// provider.go:69-71 把绝对路径写进会被序列化返回的结构体
record.LocalPath = savePath
record.SaveName = fileName
record.ResultUrl = config.Spec.Local.Site + "/" + bucket + "/" + subdirpath + "/" + fileName
// fts_record.go:32 该字段带 json tag随响应返回
LocalPath string `gorm:"column:local_path;type:varchar(500);default:'';" json:"local_path"`
```
```go
// D:\work\bsm-sdk\core\infra\response.go:47-49 非 status 错误时把 err.Error() 直接回传
} else {
reply.Message = err.Error()
}
```
- **影响**:上传响应泄漏 `local_path`(如 `/data/app/uploader/<bucket>/2026-01/ab/<ULID>.txt`)、`oss_path`、`owner_id`、`owner_identity`、`hash` 等内部字段,为攻击者摸清目录结构、后续结合 P0-1 选择写入目标提供便利;错误分支(`handler.go:51,73,98,102` 传的是原始 `*os.PathError`、minio 错误、GORM 错误会把文件路径、MinIO endpoint/桶名、SQL 片段以 `message` 形式返回给客户端。
- **建议**:响应改用专门的 DTO只暴露 `identity/name/ext/size/hash/result_url/status`),去掉 `local_path`/`oss_path`;错误统一映射为业务错误码 + 通用文案,细节只进服务端日志(本模块 `internal/errors` 已有此设计意图)。
#### 10. 文件类型校验仅比对扩展名、大小写敏感、无 MIME/魔数校验
- **位置**`module/base/fts/internal/logic/handler.go:62-68`、`handler.go:127-134`、`etc/fts_dev.yaml:34-54`
- **证据**
```go
// handler.go:62-68
fileExt := filepath.Ext(fh.Filename)
if !isAllow(fileExt) {
// handler.go:127-133 逐项字符串全等比较(大小写敏感)
func isAllow(extName string) bool {
for _, allowExt := range config.Spec.FtsConfig.Allows {
if extName == allowExt {
```
- **影响**a只看后缀不校验内容与 `Content-Type`——把任意二进制/脚本/HTML 内容命名为 `.txt`、`.png` 即可入库并获得一个可预测域名下的 URL`Local.Site`/`MinioOss.Site`),若下游静态服务未设置 `X-Content-Type-Options: nosniff` 且按内容嗅探,则可能形成存储型 XSS**推测**`files.apinb.com` / `oss.make-w.com` 的服务配置不在本仓库内无法验证b大小写敏感导致 `A.PNG` 被拒误报白名单里全小写但客户端来源多样。c未做文件头magic bytes校验模块已间接依赖 `github.com/gabriel-vasile/mimetype``go.mod:47`)却未使用。
- **建议**:扩展名归一小写后比对;读取前 512 字节做 `http.DetectContentType`/`mimetype.Detect` 并维护"扩展名 ↔ MIME ↔ magic"三元白名单;对图片类强制解码校验;在 `Local.Site`/OSS 侧补 `Content-Type` 与 `Content-Disposition: attachment`、`X-Content-Type-Options: nosniff`(跨仓库约定,需与运维确认)。
#### 11. MinIO 客户端每请求新建、用 `context.Background()`、Content-Type 固定为 `application/octet-stream`
- **位置**`module/base/fts/internal/logic/provider.go:77-85`、`provider.go:99`、`provider.go:77`
- **证据**
```go
// provider.go:79-82 每次上传都 new 一个 client内部新建 http.Transport
minioClient, err := minio.New(config.Spec.MinioOss.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(config.Spec.MinioOss.AccessKeyID, config.Spec.MinioOss.AccessKeySecret, ""),
Secure: config.Spec.MinioOss.UseSSL,
})
// provider.go:99 背景 context无超时、不随客户端断连取消ContentType 恒定
_, err = minioClient.PutObject(context.Background(), bucket, savePath, src, file.Size, minio.PutObjectOptions{ContentType: "application/octet-stream"})
```
```go
// provider.go:77 参数 c 在函数体内从未使用(死参数)
func OssUpload(file *multipart.FileHeader, record *models.FtsRecord, c *gin.Context, bucket string) (err error) {
```
- **影响**:每个请求新建 client/transport/TLS 连接,没有连接复用与限流,高并发时连接数与内存占用线性上升(性能瓶颈);`context.Background()` 使客户端断开后上传仍继续跑完MinIO 卡住时 handler 永久挂起(无超时),占住 goroutine 与临时文件;固定 `octet-stream` 导致图片/视频/PDF 在浏览器中无法内联预览、下载文件名缺失。
- **建议**`minio.Client` 在 `impl` 层初始化一次并复用(或注入依赖);改用 `c.Request.Context()` 并叠加 `context.WithTimeout``ContentType` 由检测结果决定、附加 `minio.PutObjectOptions{UserMetadata: {"x-amz-meta-sha256": record.Hash}}`;删除未使用的 `c` 参数。
#### 12. 重复全量 IO文件被完整读取 23 遍multipart 落盘 + SHA-256 + 写存储),且 hash 计算后无任何用途
- **位置**`module/base/fts/internal/logic/handler.go:70`、`handler.go:109-125`、`module/base/fts/internal/logic/provider.go:48-60`、`provider.go:93-99`
- **证据**
```go
// handler.go:70 先为哈希完整读一遍
fileHash, err := chksum(fh)
// handler.go:119-123
hash := sha256.New()
if _, err = io.Copy(hash, file); err != nil {
```
```go
// provider.go:48-56 再打开同一个上传文件完整读一遍写本地
src, err := fh.Open()
...
if _, err = io.Copy(file, src); err != nil {
```
- **影响**5 GiB 文件在本地 provider 下至少 2 次完整读 + 1 次完整写MinIO 下 PutObject 还会再流式读 1 次,共 3 次),上传耗时与 IO 放大明显;`record.Hash` 落库后既不用于去重(`test/fts_test.go:97-112` 的"闪传/Prepare"用例对应接口根本不存在),也不用于校验写入内容是否完整,属于纯开销。
- **建议**:改为单遍流式:`io.TeeReader`/`io.MultiWriter` 在写入存储的同时计算 SHA-256或 MinIO 上传后读 ETag/`StatObject` 校验);引入以 `hash+size` 为键的去重(秒传)与幂等;若暂时不打算用 hash就不要为它多读一遍。
#### 13. 配置校验缺失与不安全默认:`FtsConfig`/`Local`/`MinioOss` 缺失即 panic`UploadDir` 不校验可写prod 配置与 dev 完全相同
- **位置**`module/base/fts/internal/config/config.go:32-41`、`module/base/fts/internal/logic/handler.go:57`、`module/base/fts/internal/logic/provider.go:26`、`etc/fts_prod.yaml:8`、`etc/fts_prod.yaml:20-29`
- **证据**
```go
// config.go:36-40 只校验端口与 Service/Cache未校验 FtsConfig/Local/MinioOss
Spec.Port = conf.CheckPort(Spec.Port)
conf.NotNil(Spec.Service, Spec.Cache)
```
```go
// handler.go:57 / provider.go:26 直接解引用,段缺失即 nil 解引用 panic
if fileSize > config.Spec.FtsConfig.MaxSize {
saveDir := filepath.Join(config.Spec.Local.UploadDir, bucket, subdirpath)
```
```yaml
# etc/fts_prod.yaml:8 生产配置指向开发库,且与 dev/test 完全相同
- host=127.0.0.1 user=postgres password=CHANGE_ME dbname=bsm_dev port=5432 sslmode=disable TimeZone=Asia/Shanghai
# etc/fts_prod.yaml:23 仓库内提交了真实形态的 AKSecret 为占位符)
AccessKeyId: 9jtPPB7wwJpe5R2164bS
```
三个环境文件(`fts_dev.yaml`/`fts_test.yaml`/`fts_prod.yaml`)除 Log/Prometheus 注释外逐字节相同。
- **影响**:配置少写一段就是一个 500`gin.Recovery` 兜住,无启动期快速失败);`Local.UploadDir` 保持相对路径 `./uploader/`,实际落点取决于进程 CWDsupervisor `directory=/data/app`),目录不存在/不可写时只能在首个上传请求上暴露prod 直连 `bsm_dev` 且 `MinioOss.UseSSL: false`(明文 S3 + 明文 AK/SK 走公网),`Site`/`Endpoint` 也是内网测试域名——按现状部署会同时造成数据写错库与凭证明文传输。`MaxSize` 也没有"必须 ≤ 某上限"的约束。
- **建议**`config.New` 中做段级非空与取值校验(`FtsConfig.MaxSize > 0`、`Allows` 非空且均为小写扩展名、`Local.UploadDir` 绝对路径且启动时 `MkdirAll`+可写探测、`MinioOss.UseSSL` 生产强制 true缺失/非法直接 `log.Fatal`;为三个环境编写真实差异化配置(生产独立库、独立桶、独立密钥),`AccessKeySecret` 用环境变量注入(`conf.New` 支持 `os.ExpandEnv``D:\work\bsm-sdk\core\conf\new.go:55`)。
#### 14. 落盘权限世界可读、目录世界可执行,与代码注释"确保权限控制"矛盾
- **位置**`module/base/fts/internal/logic/provider.go:28-29`、`provider.go:39-40`、`provider.go:62-63`
- **证据**
```go
// provider.go:28-29
// 创建目录并确保权限正确
if err = os.MkdirAll(saveDir, 0755); err != nil {
// provider.go:39-40
// 使用自定义方式保存文件,确保权限控制
file, err := os.OpenFile(savePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
```
- **影响**`0755` 目录 + `0644` 文件意味着同主机任何用户都可读取所有上传文件(合同、证件、发票等),并可进入目录列举文件;`0644` 对所有者的"可写"还允许同 uid 进程篡改。注释声称的"权限控制"并未实现。
- **建议**:改 `os.MkdirAll(dir, 0750)` + `OpenFile(..., 0640)`(或按 `LocalConf` 增加可配置 `DirMode/FileMode`),上传根目录归属专用系统用户;如需对外可读,交由前置静态服务(`files.apinb.com`)以只读方式暴露,而不是靠文件位。
#### 15. `NewSubdir` 对 `claims.Identity` 取前 2 字节,无长度校验,空 identity 直接 panic
- **位置**`module/base/fts/internal/logic/provider.go:113-116`、`module/base/fts/internal/logic/handler.go:80`
- **证据**
```go
// provider.go:113-116
func NewSubdir(identity string) string {
ym := time.Now().Format("2006-01")
return ym + "/" + identity[0:2]
}
```
```go
// handler.go:80 identity 直接来自 JWT claims未做长度/字符校验
OwnerIdentity: claims.Identity,
```
- **影响**:任何 `len(identity) < 2` 的合法 token例如平台给某些账号签发 identity 为空/为 1 位时,参见 `D:\work\bsm-sdk\core\crypto\token\jwt.go:33-60` 的 `GenerateJwt(id, identity, ...)` 允许传空串,`module/base/mgt/internal/logic/pub/login.go:90` 就是直接透传 `user.Identity`)都会让上传接口 panic → 500被 gin.Recovery 捕获)。推测部分:无法确认生产库里是否存在 identity 为空/过短的账号。
- **建议**`NewSubdir` 先校验 `len(identity) >= 2` 并对非 `[0-9a-zA-Z]` 字符做过滤(同时避免 identity 里混入 `../` 之类的字符污染路径——虽然当前只取前 2 位非法时返回明确业务错误400而不是 panic。
#### 16. 可观测性与契约缺陷:所有响应一律 HTTP 200、全程 `log.Println` 无结构化日志、无优雅退出与服务端超时
- **位置**`module/base/fts/internal/logic/provider.go:30,42,50,57,64`、`handler.go:34,65,72,112,121`、`cmd/main/main.go:26-28,42-45`;根因 `D:\work\bsm-sdk\core\infra\response.go:33,52`
- **证据**
```go
// D:\work\bsm-sdk\core\infra\response.go:52 无论何种错误都返回 HTTP 200
ctx.JSON(200, reply)
```
```go
// cmd/main/main.go:42-45 无超时配置、无 signal 处理、启动失败直接 panic
err := app.Run(fmt.Sprintf(":%s", config.Spec.Port))
if err != nil {
panic(err)
}
```
```go
// provider.go:102,107,109 minio 分支直接用 fmt.Println 打调试信息
fmt.Println("err = ", err)
fmt.Println("savePath = ", savePath)
fmt.Println("record.ResultUrl = ", record.ResultUrl)
```
- **影响**:网关/监控无法通过状态码区分成功与失败(`/rest/fts/uploader` 出错也是 200body 里 code=400/500告警与 SLI 统计会失真;日志混用 `log.Println`/`fmt.Println`,没有 trace_id、没有请求级字段provider、bucket、size、耗时排障只能靠文本搜索`savePath`/`ResultUrl` 被无条件打到 stdoutMinIO 分支日志噪音与路径泄漏);进程没有 graceful shutdown转发中上传会被硬切。
- **建议**:错误响应使用真实 HTTP 状态码(模块已有 `internal/errors.BusinessError.HTTPStatus()`,见 P3-18统一接入 SDK 的 `logger``D:\work\bsm-sdk\core\logger`)并携带 `trace_id`/`ftd_id`/`user identity`/耗时;删除 `fmt.Println``cmd/main` 用 `http.Server` + `signal.NotifyContext` + `Shutdown(ctx)`,并配置 `ReadTimeout/WriteTimeout/IdleTimeout`。
#### 17. 测试现状不可用:唯一单元测试失败,且没有任何上传安全/边界用例
- **位置**`module/base/fts/internal/routers/register_test.go:15-19`、`module/base/fts/test/fts_test.go:26,87,105,124,151`
- **证据**
```go
// register_test.go:15-19 断言 v1 前缀,实现里没有
expected := map[string]bool{
"GET /rest/fts/v1/ping": false,
"GET /rest/fts/v1/config": false,
"POST /rest/fts/v1/uploader": false,
}
```
```
$ go test ./internal/...
--- FAIL: TestRoutesUseRESTModulePrefix (0.00s)
register_test.go:31: route not registered: GET /rest/fts/v1/ping
FAIL bsm/full/module/base/fts/internal/routers 0.136s
```
```go
// test/fts_test.go:26,105,124,151 集成用例打的是早已不存在的旧路由
uploaderUrl: "http://127.0.0.1:12214/fts/Transfer/Uploader",
resp, err := http.Post("http://127.0.0.1:12214/fts/Transfer/Prepare", "application/json", body)
```
- **影响**:模块 CI 恒红(`pkgs/ecmall/AGENT.md:471` 已记录该既有失败);`test/fts_test.go` 即使手工执行也只会拿到 404`/fts/Transfer/*`、`/fts/Get/Config` 从未注册),且断言为空(只 `fmt.Printf`,从不 `t.Error`),等于没有回归保护。**上传安全最关键的用例全部缺失**(见下)。
- **建议**:修正 `register_test.go` 的期望路由为 `/rest/fts/{ping,config,uploader}`(顺带修 CRLF/gofmt为 `internal/logic` 增加表驱动单测:`bucket` 含 `../`/绝对路径/`..%2f` 必须被拒、`provider` 非法值、扩展名大小写、超过 `MaxSize`、`identity` 为空、同名并发上传、本地写失败回滚、DB 失败回滚、响应体为合法单段 JSON用 `httptest` 覆盖"无 Authorization → 401 / 伪造签名 → 401"。
### P3
#### 18. 死代码:`internal/response`144 行)与 `internal/errors`118 行)从未被引用,且 README 的错误格式描述指向的正是这套死代码
- **位置**`module/base/fts/internal/response/response.go:1-144`、`module/base/fts/internal/errors/errors.go:1-118`、`module/base/fts/README.md:164-172`
- **证据**
```
$ grep -rn "internal/response\|internal/errors" module/base/fts # 唯一命中是 response.go 自己 import errors
module\base\fts\internal\response\response.go:11: "bsm/full/module/base/fts/internal/errors"
```
```go
// response.go:44-75 这套实现才是"按 HTTP 状态码 + trace_id"返回的正确形态,但无人调用
func Error(c *gin.Context, err error) { ... c.JSON(httpStatus, response) }
```
```markdown
<!-- README.md:166-171 文档描述的错误结构与实现执行路径infra.Reply: details/timeseq不符 -->
{ "code": 10001, "message": "错误描述", "trace_id": "request-trace-id" }
```
- **影响**:模块维护者会误以为错误码/HTTP 状态/trace_id 已生效;实际走的是 SDK 的 `infra.Reply`(字段 `details`/`timeseq`,恒 200。同时大量未被使用的业务错误码20001-40005制造了"已完成错误治理"的假象。
- **建议**:二选一并保持一致——要么让 `logic` 全面改用 `internal/response`+`internal/errors`(可同时解决 P1-4/P1-5/P2-9/P2-16要么删除这两个包并订正文档。
#### 19. 逐条 TODO/未实现与残留死代码(配置字段、注释代码、空实现)
- **位置与证据**
- `internal/logic/fetch.go:1-29`**整文件被注释**(原计划实现文件列表查询),编译后只剩 `package logic`。
```go
// func (l *ListLogic) List(in *types.Paginate, r *http.Request) (resp *types.Base, err error) {
```
- `internal/models/fts_record.go:48-98``GetFileList`/`GetFileDetails`/`TableSql` 全部注释掉。
- `internal/models/query.go:4-6``func InitData() {}` 空实现,且没有任何调用点。
- `internal/logic/handler.go:43-47`:被注释掉的 `provider` 白名单校验(只允许 local 的历史逻辑)。
- `internal/models/fts_record.go:28-29,36``HandleCmd`/`HandleArgs`/`Status` 三个字段("文件上传成功后操作命令"/处理状态机)**从未被写入**——`Status` 恒为 `0``handler.go:85``HandleCmd`/`HandleArgs` 永远为空串;没有任何 worker 消费 `fts_record`。
- `internal/impl/impl.go:13-16`、`service/dependencies.go:19-31``RedisService`、`MemorySerice` 只被赋值从未被读取(`EtcdService` 仅用于 `cmd/main/main.go:32` 的服务注册)。
- `test/fts_test.go:51`、`handler.go:26`:测试传入的 `purpose` 字段在服务端已被忽略。
- **影响**`fts` 只实现了"上传"这一半能力:没有列表、详情、下载、删除、秒传(`test/fts_test.go:97` 的 `TestPrepare`)、也没有 `HandleCmd` 后处理,但模型与配置仍保留完整状态机字段,后续维护者容易误判已有能力;`Status` 永远是 0 意味着即便将来加了 worker 也无法区分"待处理"与"已处理"。
- **建议**:按"要么实现、要么删除"原则收敛:删除注释代码与空实现,或在 README/`wiki/api/16-fts-rest.md` 明确标注为"未实现Roadmap";为 `fts_record` 补状态机流转(或用 `Status` 表达失败/成功),并明确 `HandleCmd/HandleArgs` 的消费方。
#### 20. 文档与实现不一致README/wiki/`.http`/集成测试四处口径不同,且描述了不存在的文件)
- **位置**`module/base/fts/README.md:56-67`、`README.md:174-198`、`module/base/fts/test/oss_provider_req.http:1`、`module/base/fts/test/fts_test.go:26`、`module/base/fts/internal/routers/register_test.go:16-18`
- **证据**
```markdown
<!-- README.md:56-66 这些端点与文件都不存在 -->
GET /health # 详细健康检查
GET /health/simple # 简单健康检查
GET /version # 版本信息
POST /fts/upload # 文件上传
GET /fts/download # 文件下载
```
```http
# test/oss_provider_req.http:1 实际路由是 POST /rest/fts/uploader
POST http://127.0.0.1:16290/fts/v1/uploader
```
- README 的"项目结构"`README.md:176-198`)列出 `internal/health/`、`scripts/`、`build/`、`Dockerfile`、`docker-compose.yml`、`Makefile`、`LICENSE` —— 模块内**均不存在**`Get-ChildItem` 实测仅 22 个文件,见第 1 节表)。
- 实际正确契约见 `wiki/api/16-fts-rest.md:9-11` 与根 `README.md:91``/rest/fts/{ping,config,uploader}`),与本模块实现一致。
- **影响**:调用方按 README/.http 对接必然 404README 的"健康检查/容器化/Makefile 工具链/可观测性"等特性描述均为模板残留,误导排障与运维。
- **建议**:重写 README只留真实端点 `/rest/fts/*`、真实文件清单与 `Fts.*` 配置说明),删除 `.http`/集成测试里的旧 URL或把 `.http` 改成真实请求样例(`multipart/form-data` + `provider`/`bucket`/`file`)。
#### 21. 注释与实现不符(`MaxSize` 单位、`NewSubdir` 目录格式、权限说明)
- **位置**`etc/fts_dev.yaml:33`、`internal/logic/provider.go:87`、`provider.go:28,39`、`internal/models/fts_record.go:18`
- **证据**
```yaml
# etc/fts_dev.yaml:33 值是字节5 GiB注释写成 MB
MaxSize: 5368709120 # MB
```
```go
// provider.go:87-89 注释说"年/identity/identity后3位+filename",实现是"年-月/identity前2位/ULID+ext"
// 设置保存格式为: 年/identity/identity后3位+filename
subdirpath := NewSubdir(record.OwnerIdentity)
fileName := utils.ULID() + record.Ext
```
`NewSubdir` 实际返回 `time.Now().Format("2006-01") + "/" + identity[0:2]``provider.go:113-116`)。另 `fts_record.go:18` 注释称 identity 为 24/36 位,实现用 `utils.UUID()`v736 位,`D:\work\bsm-sdk\core\utils\identity.go:8-10``SaveName` 却是 ULID26 位),两套 ID 体系混用且注释未同步。
- **影响**:单位误读会直接把上限配错 10^6 倍;目录/ID 规则注释误导后续按路径做清理或数据修复的运维脚本。
- **建议**:注释改为 `MaxSize: 5368709120 # bytes (5 GiB)`,同步 `NewSubdir` 与 ID 体系说明;为 `MaxSize` 增加 `MaxSize > 0 && MaxSize <= 硬上限` 的校验P2-13
#### 22. 代码风格/命名:`register_test.go` 全文 CRLF 被 gofmt 判为未格式化;`MemorySerice` 拼写错误
- **位置**`internal/routers/register_test.go:1-34`、`internal/impl/impl.go:16,21`、`service/dependencies.go:30`
- **证据**
```
$ gofmt -l .
internal\routers\register_test.go
# 该文件 34 行换行全部为 CRLF实测 CRLF pairs: 34 / LF total: 34
```
```go
// impl.go:16 拼写错误,且该变量从未被业务读取
MemorySerice *cache.Cache
```
- **影响**:仓库 `gofmt -l` 不是空输出(`pkgs/ecmall/AGENT.md:732` 已把该文件列为已知历史问题),统一格式化脚本/CI 会失败;拼写错误影响检索与后续引用。
- **建议**`register_test.go` 转 LF 并保持 gofmt 通过(同时修 P2-17 的路由断言);重命名为 `MemoryService``impl.go:16,21` 与 `dependencies.go:30` 同步)。
#### 23. 加固项:`ParseJwt` 未限定签名算法、CORS 全开、匿名 `/config` 暴露上传策略、用 gRPC 状态码当业务码
- **位置**`D:\work\bsm-sdk\core\middleware\jwt.go:50`、`D:\work\bsm-sdk\core\crypto\token\jwt.go:64-73`、`D:\work\bsm-sdk\core\middleware\cors.go:10`、`internal/logic/config.go:9-11`、`internal/routers/register.go:22-24`、`handler.go:40,58,66,94`
- **证据**
```go
// crypto/token/jwt.go:65-67 未传 jwt.WithValidMethods仅靠库默认行为兜底
token, err := jwt.ParseWithClaims(tokenstring, &Claims{}, func(token *jwt.Token) (any, error) {
return []byte(t.SecretKey), nil
})
// middleware/cors.go:10
AllowAllOrigins: true,
```
```go
// config.go:10 匿名暴露 provider/大小/扩展名白名单(为攻击者挑选可写扩展名提供便利,配合 P0-1
infra.Response.Success(ctx, config.Spec.FtsConfig)
```
```go
// handler.go:40 400/501 是 HTTP 语义,被塞进 gRPC codes.Codecodes.Code(400) 非法值)
infra.Response.Error(c, errcode.NewError(400, "参数错误"))
```
- **影响**`alg` 混淆HS256/RS256在 jwt/v5 下默认已被密钥类型检查挡住(`rsa.Verify` 要求 `*rsa.PublicKey`),因此当前**不构成可利用漏洞**,但缺少显式 `WithValidMethods`/`WithIssuer`/`WithAudience` 属加固缺口(聚合宿主已实现正确写法,可对比 `pkgs/all/internal/server/authorization.go:74-79`)。`AllowAllOrigins: true` 在 Bearer-in-header 模式下不直接导致凭证泄露,但会让任意站点直接消耗上传接口(配 P1-3 的资源问题)。匿名 `/config` 把可写扩展名清单交给未认证者。`codes.Code(400)/501` 是非注册 gRPC 码,`Code.String()` 退化为 `Code(400)`,跨服务错误映射会失真。
- **建议**SDK `ParseJwt` 增加 `jwt.WithValidMethods([]string{"HS256"})` 与签发者/受众校验CORS 收敛为白名单域;评估 `/config` 是否需要匿名(聚合配置 `pkgs/all/etc/default_dev.yaml:37` 也把它列为匿名);错误码改用模块自己的 HTTP 错误码体系(`internal/errors`)而非 gRPC 码。
#### 24. 命名/时区一致性:目录按月用服务器本地时区,`bucket` 被强制转小写改变本地路径语义
- **位置**`internal/logic/provider.go:113-115`、`internal/logic/handler.go:27`、`etc/fts_dev.yaml:8`
- **证据**
```go
// provider.go:113-115 服务器本地时区
ym := time.Now().Format("2006-01")
```
```go
// handler.go:27 本地 provider 也被强制小写S3 需要小写,本地路径不需要)
bucket = strings.ToLower(c.PostForm("bucket"))
```
```yaml
# etc/fts_dev.yaml:8 数据库连接串显式 Asia/Shanghai进程时区未设定
- host=127.0.0.1 user=postgres ... TimeZone=Asia/Shanghai
```
- **影响**:跨月边界的文件目录归属依赖容器 TZ与 `created_at`DB 按 Asia/Shanghai 解释)可能不在同一个月,给按月归档/清理脚本埋坑;`ToLower` 使本地路径与客户端传入的 `Bucket` 大小写不一致S3 桶名合法、本地目录名不合法),造成"同一个值在不同 provider 下落点不同"的隐性行为。
- **建议**:统一时区来源(显式 `TZ`/`time.LoadLocation` 配置或用 UTC并把小写化限制在 `provider == "minio"` 分支。
## 4. 推荐优化方案
按"先堵安全 -> 再修稳定性 -> 后重构"的顺序,建议分四批:
**第一批P012 天,可与上线解耦)**
1. `bucket` 收敛:新增 `sanitizeBucket()` 做正则白名单 + 拒绝 `.`/`..`/`/`/`\`/`:`,并在 `LocalUpload` 落盘前用 `filepath.Rel(root, target)` 断言未逃逸(`P0-1`MinIO 分支只允许配置中登记的桶集合。
2. 停止在请求期构造错误码:`handler.go` 四处 `errcode.NewError` 换成包级预定义错误(或本模块 `internal/errors`),彻底消除 `AllErrors` 并发写路径(`P0-2`)。同步给 SDK 提 issue`NewError` 不应写全局 map。
3. `LocalUpload`/`OssUpload` 只返回 error不再写响应修掉双响应`P1-4`);顺手消除对 `infra.Response` 单例的写依赖(`P1-5`)——统一走 `internal/response`,或至少在 SDK 修复前把响应构造为局部变量。
**第二批P135 天)**
4. 上传入口加 `http.MaxBytesReader`(或改 `MultipartReader` 流式 + 计数中断),并为 `http.Server``ReadTimeout/WriteTimeout/IdleTimeout``cmd/main``signal.NotifyContext` + `Shutdown``P1-3``P2-16`)。
5. JWT 密钥显式化:`config.New` 校验并注入 `JwtSecretKey`sup 配置注入 `BSM_JwtSecretKey``/rest/fts/uploader` 保持 `JwtAuth(true)` 不变(当前确实生效,见第 5 节"已核实"`P1-6`)。
6. SDK 侧修 `IsExpired` 的 nil 解引用并调整"先验签后判过期"顺序(`P1-7`)。
**第三批P212 周)**
7. 上传改为单遍流式Tee 计算 SHA-256+ 失败回滚(`os.Remove`/`RemoveObject`+ DB 失败补偿(`P2-8``P2-12`)。
8. 响应 DTO 化,剔除 `local_path`/`oss_path`,错误信息统一映射(`P2-9`)。
9. 内容校验(魔数/MIME 三元白名单)、扩展名大小写归一(`P2-10`)。
10. `minio.Client` 复用 + `c.Request.Context()` + 正确 `ContentType` + `x-amz-meta-sha256``P2-11`)。
11. 配置段级校验与三环境差异化(生产库/桶/密钥、`UseSSL: true`、绝对 `UploadDir` 并启动时探测可写)(`P2-13`);权限位收敛到 `0750/0640``P2-14``NewSubdir` 入参校验(`P2-15`)。
12. 测试补齐:路由断言修正 + logic 层表驱动安全用例(路径穿越、扩展名、大小、并发、回滚)+ `go test -race``P2-17`)。
**第四批P3随手清理**
13. 删除 `internal/{response,errors}` 或全面启用(二选一,`P3-18`);清理 `fetch.go``fts_record.go` 注释代码、`InitData`、未使用的 `RedisService/MemorySerice``MemorySerice` 拼写(`P3-19``P3-22`)。
14. 重写 README/通知 wiki 更新,修正 `.http` 与集成测试 URL明确"未实现能力清单"`P3-19``P3-20`)。
15. 注释/单位/时区/小写化一致性修正(`P3-21``P3-24`SDK 加固项(`P3-23`)。
## 5. TODO 清单
- [ ] **P0-1**`bucket` 增加白名单校验并在落盘前做路径逃逸断言(`filepath.Rel`MinIO 侧限定可写桶集合|验收:单测覆盖 `bucket=../../x``/etc``..%2f..``a/../../b`,请求返回 400 且磁盘无越权写入|涉及:`module/base/fts/internal/logic/handler.go:27``module/base/fts/internal/logic/provider.go:26`
- [ ] **P0-2** 移除请求期 `errcode.NewError` 调用,改用预定义错误/`internal/errors`;并推动 SDK `NewError` 不再写 `AllErrors`|验收:并发 100 个错误请求(`provider` 为空)无 `concurrent map writes``go test -race` 通过|涉及:`module/base/fts/internal/logic/handler.go:40``D:\work\bsm-sdk\core\errcode\errcode.go:87`
- [ ] **P1-3** 入口加 `http.MaxBytesReader` 或改流式计数中断,并显式设置 `Read/Write/IdleTimeout`|验收:发送 10×`MaxSize` 的 body 在读取到上限时即断连,`/tmp` 不产生超大临时文件|涉及:`module/base/fts/internal/logic/handler.go:49``module/base/fts/cmd/main/main.go:42`
- [ ] **P1-4** provider 不再写响应,失败响应唯一出口|验收:模拟上传目录不可写,响应体为**单段**合法 JSON`superfluous WriteHeader` 日志|涉及:`module/base/fts/internal/logic/provider.go:31``module/base/fts/internal/logic/handler.go:97`
- [ ] **P1-5** 消除 `infra.Response` 共享单例写;改用局部响应对象或模块 `internal/response`|验收:并发压测下每个响应只含自己请求的 `identity``-race` 无告警|涉及:`module/base/fts/internal/logic/handler.go:106``D:\work\bsm-sdk\core\infra\response.go:12`
- [ ] **P1-6** JWT 密钥显式配置与校验(禁止回退 SDK 默认值sup 注入 `BSM_JwtSecretKey`|验收:未配置密钥时启动即失败;用默认密钥 `Cblocksmesh2022C` 签发的 token 被拒401涉及`module/base/fts/internal/config/config.go:32``etc/supervisor.bsm-apps-fts.conf:1`
- [ ] **P1-7** 修复 SDK `IsExpired``ExpiresAt` nil 解引用并改为验签后判过期|验收:`Authorization: a.<payload 无 exp>.c` 返回 401 且服务端无 panic 堆栈|涉及:`D:\work\bsm-sdk\core\crypto\token\jwt.go:97``module/base/fts/internal/routers/uploader.go:13`
- [ ] **P2-8** 上传/落库失败回滚(删文件/删对象),`Chmod` 失败不再导致整体失败|验收:注入 DB 失败后上传目录无残留文件|涉及:`module/base/fts/internal/logic/provider.go:56``module/base/fts/internal/logic/handler.go:101`
- [ ] **P2-9** 响应改 DTO去掉 `local_path`/`oss_path` 等内部字段;错误只回业务码+通用文案|验收:上传成功响应不含服务器路径;非法扩展名响应 `message` 不含文件系统细节|涉及:`module/base/fts/internal/logic/handler.go:106``module/base/fts/internal/models/fts_record.go:32`
- [ ] **P2-10** 扩展名归一小写比对并增加魔数/MIME 校验|验收:`A.PNG` 可上传;把 ELF/HTML 内容命名为 `.png` 被拒|涉及:`module/base/fts/internal/logic/handler.go:63``module/base/fts/internal/logic/handler.go:127`
- [ ] **P2-11** `minio.Client` 复用、使用请求 context 并加超时、ContentType 按检测结果设置|验收:压测中 MinIO 连接数不随请求线性增长;客户端断连后上传及时取消|涉及:`module/base/fts/internal/logic/provider.go:79``module/base/fts/internal/logic/provider.go:99`
- [ ] **P2-12** 单遍流式计算 SHA-256Tee并引入 `hash+size` 去重/秒传|验收:上传 1 GiB 文件时对源文件的读取次数为 1本地 provider重复文件命中秒传涉及`module/base/fts/internal/logic/handler.go:70``module/base/fts/internal/logic/provider.go:56`
- [ ] **P2-13** 配置段级校验 + 三环境差异化 + 生产 `UseSSL: true``UploadDir` 绝对路径与可写探测|验收:删除 YAML 中 `FtsConfig` 段后启动即报错退出;`fts_prod.yaml` 不再指向 `bsm_dev`|涉及:`module/base/fts/internal/config/config.go:36``etc/fts_prod.yaml:8`
- [ ] **P2-14** 目录 0750 / 文件 0640或可配置验收同主机非属主用户无法读取上传文件涉及`module/base/fts/internal/logic/provider.go:29``module/base/fts/internal/logic/provider.go:40`
- [ ] **P2-15** `NewSubdir` 校验 `len(identity) >= 2` 并过滤非法字符,非法返回 400验收identity 为空/1 位的 token 上传返回 400 而非 panic涉及`module/base/fts/internal/logic/provider.go:115`
- [ ] **P2-16** 统一结构化日志(含 trace_id/耗时/身份)并去掉 `fmt.Println``cmd/main` 增加优雅退出与超时|验收:日志可检索到单次上传的 provider/bucket/size/耗时SIGTERM 下在途上传完成后再退出|涉及:`module/base/fts/internal/logic/provider.go:102``module/base/fts/cmd/main/main.go:42`
- [ ] **P2-17**`register_test.go` 路由断言 + 新增上传安全/边界单测(含 `-race`)|验收:`go test ./...` 全绿,新增用例覆盖路径穿越/扩展名/超限/并发/回滚/401涉及`module/base/fts/internal/routers/register_test.go:16``module/base/fts/internal/logic/handler.go:24`
- [ ] **P3-18** `internal/{response,errors}` 二选一:启用或删除,并订正 README 错误格式|验收:`grep -rn "internal/response"` 命中数 > 1启用或为 0删除README 描述与实际响应一致|涉及:`module/base/fts/internal/response/response.go:1``module/base/fts/README.md:166`
- [ ] **P3-19** 清理死代码与未实现项并在文档标注|验收:`fetch.go` 注释块、`fts_record.go:48-98``InitData`、未使用的 `RedisService/MemorySerice` 全部删除或明确标注 Roadmap涉及`module/base/fts/internal/logic/fetch.go:1``module/base/fts/internal/models/query.go:4`
- [ ] **P3-20** 重写 README真实端点/文件清单/配置项),修正 `.http` 与集成测试 URL验收README 中每个端点均可用 `curl` 命中;`.http` 指向 `/rest/fts/uploader`|涉及:`module/base/fts/README.md:56``module/base/fts/test/oss_provider_req.http:1`
- [ ] **P3-21** 修正 `MaxSize`/`NewSubdir`/权限注释与 ID 体系说明|验收:注释与实现逐条一致(`MaxSize` 标注 bytes涉及`etc/fts_dev.yaml:33``module/base/fts/internal/logic/provider.go:87`
- [ ] **P3-22** `register_test.go` 转 LF 并通过 gofmt`MemorySerice` 更名 `MemoryService`|验收:`gofmt -l .` 无输出;`rg MemorySerice` 无命中|涉及:`module/base/fts/internal/routers/register_test.go:1``module/base/fts/internal/impl/impl.go:16`
- [ ] **P3-23** SDK 加固:`ParseJwt` 限定 HS256 并校验签发者CORS 收白名单;评估 `/config` 匿名暴露;错误码改用 HTTP 语义体系|验收:`jwt.WithValidMethods` 生效(非 HS256 token 被拒CORS 仅允许登记域|涉及:`D:\work\bsm-sdk\core\crypto\token\jwt.go:65``D:\work\bsm-sdk\core\middleware\cors.go:10`
- [ ] **P3-24** 统一目录命名时区,并仅对 MinIO 分支做 `ToLower`|验收:跨月边界上传的目录与 `created_at` 同月(同一时区);本地 provider 保留原始大小写桶名|涉及:`module/base/fts/internal/logic/provider.go:114``module/base/fts/internal/logic/handler.go:27`
## 6. 审计摘要(供汇总使用)
- 问题数:**P0=2 P1=5 P2=10 P3=7**(合计 24
- 最高风险(一句话):上传接口唯一被校验的只有 `provider` 与扩展名,客户端完全可控的 `bucket` 被直接拼进本地落盘路径(`filepath.Join(UploadDir, bucket, ...)` 可被 `..` 穿越),持任一有效 JWT 的用户可向服务器任意可写目录写入内容可控的文件,且 MinIO 分支同样可写任意桶。
- 最优先 3 个动作:
1. 立即校验并规范化 `bucket`(正则白名单 + `filepath.Rel` 逃逸断言 + MinIO 桶白名单),堵住任意目录写入(`P0-1`)。
2. 移除请求期 `errcode.NewError` 调用并推动 SDK 去掉 `AllErrors` 写入,消除"并发错误请求 → `fatal error: concurrent map writes` → 进程崩溃"`P0-2`)。
3. 修掉本地上传失败时的双重响应、`infra.Response` 单例并发写、以及"先落盘后限流"的 `MaxSize` 校验,恢复响应正确性与上传资源上限(`P1-3/4/5`)。
- 未能覆盖/无法验证的部分:
- 真实生产环境是否设置了 `BSM_JwtSecretKey``P1-6` 的最终定级依赖它;仓库内 supervisor 配置未注入,`etc/fts_*.yaml``SecretKey` 与 JWT 无关)。
- 真实 MinIO 桶策略、`AccessKeySecret` 实际值、桶是否允许匿名读,以及 `files.apinb.com` / `oss.make-w.com` 静态服务的 Content-Type/嗅探配置(`P2-10` 的存储型 XSS 影响为推测)。
- 完整并发压测与 `go test -race` 未执行(本审计只读,不新增文件;`P0-2`/`P1-5` 的并发后果为基于 map/单例写语义的静态推断)。
- 真实数据库表结构(`fts_record` 由 GORM AutoMigrate 生成,未见 `.sql`/seed与线上既有数据`identity` 是否可能为空/过短(`P2-15`)。
- `cmd/cli/main.go`licence 生成工具,硬编码 `/data/app/etc/licence.key``"slcor"``20250101-20350101`)未纳入本模块运行时链路,其安全影响未评估。
- `pkgs/all``pkgs/ecmall` 之外的其他宿主(是否还有第三方调用方直连 `/rest/fts/uploader`)未排查。