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.
58 KiB
审计报告: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(GOROOTD:\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 - 证据:
// handler.go:27,39-42 bucket 来自客户端表单,仅判空 bucket = strings.ToLower(c.PostForm("bucket")) ... if provider == "" || bucket == "" { infra.Response.Error(c, errcode.NewError(400, "参数错误")) return }// 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// 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 - 证据:
// handler.go:40 每个错误分支都在请求期构造错误码 infra.Response.Error(c, errcode.NewError(400, "参数错误"))// 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)即可让服务进程崩溃重启;在 supervisorautorestart=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 - 证据:
// handler.go:49-60 先 FormFile(解析并落盘整个 body),再比较 Size fh, err := c.FormFile(config.Spec.FtsConfig.InputKey) ... fileSize := fh.Size if fileSize > config.Spec.FtsConfig.MaxSize {依赖行为:// etc/fts_dev.yaml:33 默认上限 5 GiB MaxSize: 5368709120 # MBgin.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,GOROOTsrc/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 - 证据:
// 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 }同一错误路径共 5 处(// handler.go:88-100 调用方又写一次 case "local": err = LocalUpload(fh, &record, c, bucket) ... if err != nil { infra.Response.Error(c, err) return }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 - 证据:
// 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) }// 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 - 证据:
// uploader.go:11-14 上传路由的鉴权中间件 auth := engine.Group(v1_key) { auth.Use(middleware.JwtAuth(true)) auth.POST("/uploader", logic.Handler)// 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)# etc/fts_prod.yaml:17 这个 SecretKey 不会用于 JWT(conf.Base 字段,仅微服务模式使用) SecretKey: CHANGE_ME# 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 - 证据:
类型证据:
// 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 时为 niljwt/v5@v5.3.1/registered_claims.go:23ExpiresAt *NumericDate,types.go:32-34type 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 - 证据:
// 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 }// 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 - 证据:
// 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"`// 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 - 证据:
// 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 - 证据:
// 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"})// 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 - 证据:
// handler.go:70 先为哈希完整读一遍 fileHash, err := chksum(fh) // handler.go:119-123 hash := sha256.New() if _, err = io.Copy(hash, file); err != nil {// 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 - 证据:
// config.go:36-40 只校验端口与 Service/Cache,未校验 FtsConfig/Local/MinioOss Spec.Port = conf.CheckPort(Spec.Port) conf.NotNil(Spec.Service, Spec.Cache)// handler.go:57 / provider.go:26 直接解引用,段缺失即 nil 解引用 panic if fileSize > config.Spec.FtsConfig.MaxSize { saveDir := filepath.Join(config.Spec.Local.UploadDir, bucket, subdirpath)三个环境文件(# 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: 9jtPPB7wwJpe5R2164bSfts_dev.yaml/fts_test.yaml/fts_prod.yaml)除 Log/Prometheus 注释外逐字节相同。 - 影响:配置少写一段就是一个 500(
gin.Recovery兜住,无启动期快速失败);Local.UploadDir保持相对路径./uploader/,实际落点取决于进程 CWD(supervisordirectory=/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 - 证据:
// 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 - 证据:
// provider.go:113-116 func NewSubdir(identity string) string { ym := time.Now().Format("2006-01") return ym + "/" + identity[0:2] }// 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 - 证据:
// D:\work\bsm-sdk\core\infra\response.go:52 无论何种错误都返回 HTTP 200 ctx.JSON(200, reply)// cmd/main/main.go:42-45 无超时配置、无 signal 处理、启动失败直接 panic err := app.Run(fmt.Sprintf(":%s", config.Spec.Port)) if err != nil { panic(err) }// 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 - 证据:
// 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// 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"// response.go:44-75 这套实现才是"按 HTTP 状态码 + trace_id"返回的正确形态,但无人调用 func Error(c *gin.Context, err error) { ... c.JSON(httpStatus, response) }<!-- 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。// 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 - 证据:
<!-- README.md:56-66 这些端点与文件都不存在 --> GET /health # 详细健康检查 GET /health/simple # 简单健康检查 GET /version # 版本信息 POST /fts/upload # 文件上传 GET /fts/download # 文件下载# 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 的"项目结构"(
- 影响:调用方按 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 - 证据:
# etc/fts_dev.yaml:33 值是字节(5 GiB),注释写成 MB MaxSize: 5368709120 # MB// provider.go:87-89 注释说"年/identity/identity后3位+filename",实现是"年-月/identity前2位/ULID+ext" // 设置保存格式为: 年/identity/identity后3位+filename subdirpath := NewSubdir(record.OwnerIdentity) fileName := utils.ULID() + record.ExtNewSubdir实际返回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)// 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 - 证据:
// 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,// config.go:10 匿名暴露 provider/大小/扩展名白名单(为攻击者挑选可写扩展名提供便利,配合 P0-1) infra.Response.Success(ctx, config.Spec.FtsConfig)// 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 - 证据:
// provider.go:113-115 服务器本地时区 ym := time.Now().Format("2006-01")// handler.go:27 本地 provider 也被强制小写(S3 需要小写,本地路径不需要) bucket = strings.ToLower(c.PostForm("bucket"))# 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 天,可与上线解耦)
bucket收敛:新增sanitizeBucket()做正则白名单 + 拒绝./..///\/:,并在LocalUpload落盘前用filepath.Rel(root, target)断言未逃逸(P0-1);MinIO 分支只允许配置中登记的桶集合。- 停止在请求期构造错误码:
handler.go四处errcode.NewError换成包级预定义错误(或本模块internal/errors),彻底消除AllErrors并发写路径(P0-2)。同步给 SDK 提 issue:NewError不应写全局 map。 LocalUpload/OssUpload只返回 error,不再写响应,修掉双响应(P1-4);顺手消除对infra.Response单例的写依赖(P1-5)——统一走internal/response,或至少在 SDK 修复前把响应构造为局部变量。
第二批(P1,3~5 天)
- 上传入口加
http.MaxBytesReader(或改MultipartReader流式 + 计数中断),并为http.Server补ReadTimeout/WriteTimeout/IdleTimeout;cmd/main改signal.NotifyContext+Shutdown(P1-3、P2-16)。 - JWT 密钥显式化:
config.New校验并注入JwtSecretKey,sup 配置注入BSM_JwtSecretKey;/rest/fts/uploader保持JwtAuth(true)不变(当前确实生效,见第 5 节"已核实")(P1-6)。 - SDK 侧修
IsExpired的 nil 解引用并调整"先验签后判过期"顺序(P1-7)。
第三批(P2,1~2 周)
- 上传改为单遍流式(Tee 计算 SHA-256)+ 失败回滚(
os.Remove/RemoveObject)+ DB 失败补偿(P2-8、P2-12)。 - 响应 DTO 化,剔除
local_path/oss_path,错误信息统一映射(P2-9)。 - 内容校验(魔数/MIME 三元白名单)、扩展名大小写归一(
P2-10)。 minio.Client复用 +c.Request.Context()+ 正确ContentType+x-amz-meta-sha256(P2-11)。- 配置段级校验与三环境差异化(生产库/桶/密钥、
UseSSL: true、绝对UploadDir并启动时探测可写)(P2-13);权限位收敛到0750/0640(P2-14);NewSubdir入参校验(P2-15)。 - 测试补齐:路由断言修正 + logic 层表驱动安全用例(路径穿越、扩展名、大小、并发、回滚)+
go test -race(P2-17)。
第四批(P3,随手清理)
- 删除
internal/{response,errors}或全面启用(二选一,P3-18);清理fetch.go、fts_record.go注释代码、InitData、未使用的RedisService/MemorySerice、MemorySerice拼写(P3-19、P3-22)。 - 重写 README/通知 wiki 更新,修正
.http与集成测试 URL,明确"未实现能力清单"(P3-19、P3-20)。 - 注释/单位/时区/小写化一致性修正(
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;并推动 SDKNewError不再写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的ExpiresAtnil 解引用并改为验签后判过期|验收: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-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 个动作:
- 立即校验并规范化
bucket(正则白名单 +filepath.Rel逃逸断言 + MinIO 桶白名单),堵住任意目录写入(P0-1)。 - 移除请求期
errcode.NewError调用并推动 SDK 去掉AllErrors写入,消除"并发错误请求 →fatal error: concurrent map writes→ 进程崩溃"(P0-2)。 - 修掉本地上传失败时的双重响应、
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)未排查。
- 真实生产环境是否设置了