# 审计报告: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 不会用于 JWT(conf.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..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//2026-01/ab/.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:文件被完整读取 2~3 遍(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 仓库内提交了真实形态的 AK(Secret 为占位符) AccessKeyId: 9jtPPB7wwJpe5R2164bS ``` 三个环境文件(`fts_dev.yaml`/`fts_test.yaml`/`fts_prod.yaml`)除 Log/Prometheus 注释外逐字节相同。 - **影响**:配置少写一段就是一个 500(`gin.Recovery` 兜住,无启动期快速失败);`Local.UploadDir` 保持相对路径 `./uploader/`,实际落点取决于进程 CWD(supervisor `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` 出错也是 200,body 里 code=400/500),告警与 SLI 统计会失真;日志混用 `log.Println`/`fmt.Println`,没有 trace_id、没有请求级字段(provider、bucket、size、耗时),排障只能靠文本搜索;`savePath`/`ResultUrl` 被无条件打到 stdout(MinIO 分支日志噪音与路径泄漏);进程没有 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 { "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 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 对接必然 404;README 的"健康检查/容器化/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()`(v7,36 位,`D:\work\bsm-sdk\core\utils\identity.go:8-10`),`SaveName` 却是 ULID(26 位),两套 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.Code(codes.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. 推荐优化方案 按"先堵安全 -> 再修稳定性 -> 后重构"的顺序,建议分四批: **第一批(P0,1~2 天,可与上线解耦)** 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 修复前把响应构造为局部变量。 **第二批(P1,3~5 天)** 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`)。 **第三批(P2,1~2 周)** 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..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-256(Tee)并引入 `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`)未排查。