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

58 KiB
Raw Blame History

审计报告module/base/fts

1. 模块概览

module/base/fts 是 BSM 平台的文件上传/存储服务File Transfer Service,对外只提供 3 个 Gin REST 接口:GET /rest/fts/pingGET /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/ftsmodule/base/fts/go.mod:1Go 1.26.5git.apinb.com/bsm-sdk/corereplace 指向本地 D:\work\bsm-sdk\coremodule/base/fts/go.mod:97,工作区 go.work:7)。

项目 内容
服务形态 纯 Gin REST无 proto / 无 gRPC
路由前缀 /rest/{srvKey},即 /rest/fts/*internal/routers/register.go:12
端口 16290cmd/main/main.go:15 + etc/fts_dev.yaml:3);聚合模式下由宿主服务器决定
上传入口 internal/logic/handler.gointernal/logic/provider.goLocalUpload / OssUpload
存储 本地 Local.UploadDiretc/fts_dev.yaml:29 = ./uploader/+ MinIO MinioOssetc/fts_dev.yaml:20-25
数据表 fts_recordinternal/models/fts_record.go:39-46database.MigrateTables 自注册)
非 pb 源码量 19 个 .go 文件(internal/ 13、service/ 2、cmd/ 2、test/ 1、根 0共约 900 行,其中约 130 行为整段注释掉的死代码
调用方 独立进程 cmd/main/main.gosupervisor 部署为 bsm-apps-fts),或由 pkgs/allpkgs/ecmall 通过 service.Expose 内嵌(pkgs/all/internal/service/fts.go:10-16
测试 单元测试 1 个且当前失败internal/routers/register_test.gotest/fts_test.go//go:build integration 且 URL 指向已不存在的路由

关键调用链:config.New("fts")impl.NewImpl()Redis/DB/Etcd/内存缓存,均用不上)→ gin.Default() + Cors + Recoveryrouters.Register("fts", app)POST /rest/fts/uploaderJwtAuth(true)ParseAuthFormFile → 大小/扩展名校验 → SHA-256 → LocalUpload/OssUploadimpl.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.gointernal/errors/errors.gointernal/impl/impl.gointernal/logic/{handler,provider,config,ping,fetch}.gointernal/models/{fts_record,query}.gointernal/response/response.gointernal/routers/{register,uploader,register_test}.goservice/{expose,dependencies}.gocmd/main/main.gocmd/cli/main.gotest/fts_test.gotest/oss_provider_req.http
  • 配置与部署:etc/fts_{dev,test,prod}.yamletc/supervisor.bsm-apps-fts.confgo.modREADME.mdwiki/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.goGOROOT D:\devapps\Go)。
  • 聚合上下文:pkgs/all/internal/{service/fts.go,config/config.go,server/authorization.go}pkgs/all/etc/default_dev.yamlpkgs/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/...FAILroute not registered: GET /rest/fts/v1/ping3 条断言全失败),退出码 1。

未覆盖:真实数据库表结构与线上数据、真实 MinIO 桶策略/AK-SK、生产 BSM_JwtSecretKey 是否设置、files.apinb.com 的静态服务实现、tmp 目录所在磁盘容量。未阅读 go.sumcmd/cli 之外的构建脚本(模块内不存在 Makefile/Dockerfile/docker-compose见 P3-20

3. 问题清单

P0

1. bucket 表单参数未做任何校验即拼入本地路径构成路径穿越任意目录写文件MinIO 侧可写入任意桶

  • 位置module/base/fts/internal/logic/handler.go:27handler.go:39-42module/base/fts/internal/logic/provider.go:26-27provider.go:37provider.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:24bucket 的描述("存储桶或本地目录分类")本就没有约束约定,应同步补文档。

2. 每个错误分支都调用 errcode.NewError,其内部写全局 map并发请求会触发 fatal error: concurrent map writes 直接打死进程

  • 位置module/base/fts/internal/logic/handler.go:40handler.go:58handler.go:66handler.go:94(调用点);根因 D:\work\bsm-sdk\core\errcode\errcode.go:12errcode.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 是普通 mapNewError请求路径上写入Go 运行时的并发 map 写检测是 throw(不可被 gin.Recovery() 捕获),整进程退出。
  • 影响:一个已认证用户只需并发发送 N 个会走错误分支的请求(如 provider/bucket 留空、扩展名不在白名单、超过 MaxSize)即可让服务进程崩溃重启;在 supervisor autorestart=trueetc/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-60etc/fts_dev.yaml:33cmd/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 # MB
    
    依赖行为:gin.Context.FormFilec.Request.ParseMultipartForm(c.engine.MaxMultipartMemory)gin.Default()MaxMultipartMemory 为 32 MiBgin@v1.12.0/gin.go:26,221context.go:700),超出部分写入临时文件net/http 在请求结束才 RemoveAllGOROOT src/net/http/server.go:1683)。cmd/main/main.go:42 直接用 app.Run,没有 ReadTimeout/WriteTimeout/MaxHeaderBytes;聚合模式下的宿主服务器只设了 ReadHeaderTimeout/IdleTimeoutpkgs/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 之外增加文件数/字段数限制(ParseMultipartFormmaxMemoryMaxBytesReader 配合);http.Server 显式设置 ReadTimeout/WriteTimeout/IdleTimeout;为单用户加配额与并发上传数限制。

4. 本地上传失败时响应被写两次(双 JSON 体),客户端拿到非法 JSON

  • 位置module/base/fts/internal/logic/provider.go:29-33provider.go:40-45provider.go:48-53provider.go:56-60provider.go:63-67module/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
    }
    
    // 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.Errorctx.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/errorsP3-18以保留 HTTP 语义。

5. infra.Response 是包级单例且被并发写:响应结构体数据竞争,可能把 A 用户的数据/错误写进 B 用户的响应

  • 位置module/base/fts/internal/logic/handler.go:106handler.go:35/40/51/58/66/73/94/98/102internal/logic/config.go:10internal/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/DetailsDetails 里放的是含用户文件路径、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:13etc/fts_prod.yaml:17etc/supervisor.bsm-apps-fts.conf:1-8;根因 D:\work\bsm-sdk\core\env\env.go:19D:\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 不会用于 JWTconf.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:73Authorization.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:11wiki/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:34D:\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 时为 nil
    
    类型证据:jwt/v5@v5.3.1/registered_claims.go:23 ExpiresAt *NumericDatetypes.go:32-34 type NumericDate struct { time.Time }(值内嵌 → 对 nil 指针调用 Unix() 必然解引用空指针)。
  • 影响:攻击者无需有效 token只要发送 Authorization: a.<base64({"sub":"x"})>.cpayload 无 exp)即可在通过 JWT 中间件之前触发 panicgin.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()ParseJwtjwt.WithValidMethods([]string{"HS256"})需与 bsm-sdk 维护方同步。

P2

8. 无失败回滚/清理:写文件成功后任一失败都会留下孤儿文件,Chmod 失败还会把成功写成失败

  • 位置module/base/fts/internal/logic/provider.go:29-33provider.go:56-60provider.go:62-67module/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:106module/base/fts/internal/models/fts_record.go:31-34module/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_pathowner_idowner_identityhash 等内部字段,为攻击者摸清目录结构、后续结合 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-68handler.go:127-134etc/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 即可入库并获得一个可预测域名下的 URLLocal.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/mimetypego.mod:47)却未使用。
  • 建议:扩展名归一小写后比对;读取前 512 字节做 http.DetectContentType/mimetype.Detect 并维护"扩展名 ↔ MIME ↔ magic"三元白名单;对图片类强制解码校验;在 Local.Site/OSS 侧补 Content-TypeContent-Disposition: attachmentX-Content-Type-Options: nosniff(跨仓库约定,需与运维确认)。

11. MinIO 客户端每请求新建、用 context.Background()、Content-Type 固定为 application/octet-stream

  • 位置module/base/fts/internal/logic/provider.go:77-85provider.go:99provider.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.Clientimpl 层初始化一次并复用(或注入依赖);改用 c.Request.Context() 并叠加 context.WithTimeoutContentType 由检测结果决定、附加 minio.PutObjectOptions{UserMetadata: {"x-amz-meta-sha256": record.Hash}};删除未使用的 c 参数。

12. 重复全量 IO文件被完整读取 23 遍multipart 落盘 + SHA-256 + 写存储),且 hash 计算后无任何用途

  • 位置module/base/fts/internal/logic/handler.go:70handler.go:109-125module/base/fts/internal/logic/provider.go:48-60provider.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 缺失即 panicUploadDir 不校验可写prod 配置与 dev 完全相同

  • 位置module/base/fts/internal/config/config.go:32-41module/base/fts/internal/logic/handler.go:57module/base/fts/internal/logic/provider.go:26etc/fts_prod.yaml:8etc/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  仓库内提交了真实形态的 AKSecret 为占位符)
    AccessKeyId: 9jtPPB7wwJpe5R2164bS
    
    三个环境文件(fts_dev.yaml/fts_test.yaml/fts_prod.yaml)除 Log/Prometheus 注释外逐字节相同。
  • 影响:配置少写一段就是一个 500gin.Recovery 兜住,无启动期快速失败);Local.UploadDir 保持相对路径 ./uploader/,实际落点取决于进程 CWDsupervisor directory=/data/app),目录不存在/不可写时只能在首个上传请求上暴露prod 直连 bsm_devMinioOss.UseSSL: false(明文 S3 + 明文 AK/SK 走公网),Site/Endpoint 也是内网测试域名——按现状部署会同时造成数据写错库与凭证明文传输。MaxSize 也没有"必须 ≤ 某上限"的约束。
  • 建议config.New 中做段级非空与取值校验(FtsConfig.MaxSize > 0Allows 非空且均为小写扩展名、Local.UploadDir 绝对路径且启动时 MkdirAll+可写探测、MinioOss.UseSSL 生产强制 true缺失/非法直接 log.Fatal;为三个环境编写真实差异化配置(生产独立库、独立桶、独立密钥),AccessKeySecret 用环境变量注入(conf.New 支持 os.ExpandEnvD:\work\bsm-sdk\core\conf\new.go:55)。

14. 落盘权限世界可读、目录世界可执行,与代码注释"确保权限控制"矛盾

  • 位置module/base/fts/internal/logic/provider.go:28-29provider.go:39-40provider.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. NewSubdirclaims.Identity 取前 2 字节,无长度校验,空 identity 直接 panic

  • 位置module/base/fts/internal/logic/provider.go:113-116module/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-60GenerateJwt(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,64handler.go:34,65,72,112,121cmd/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 出错也是 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 的 loggerD:\work\bsm-sdk\core\logger)并携带 trace_id/ftd_id/user identity/耗时;删除 fmt.Printlncmd/mainhttp.Server + signal.NotifyContext + Shutdown(ctx),并配置 ReadTimeout/WriteTimeout/IdleTimeout

17. 测试现状不可用:唯一单元测试失败,且没有任何上传安全/边界用例

  • 位置module/base/fts/internal/routers/register_test.go:15-19module/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/gofmtinternal/logic 增加表驱动单测:bucket..//绝对路径/..%2f 必须被拒、provider 非法值、扩展名大小写、超过 MaxSizeidentity 为空、同名并发上传、本地写失败回滚、DB 失败回滚、响应体为合法单段 JSONhttptest 覆盖"无 Authorization → 401 / 伪造签名 → 401"。

P3

18. 死代码:internal/response144 行)与 internal/errors118 行)从未被引用,且 README 的错误格式描述指向的正是这套死代码

  • 位置module/base/fts/internal/response/response.go:1-144module/base/fts/internal/errors/errors.go:1-118module/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-98GetFileList/GetFileDetails/TableSql 全部注释掉。
    • internal/models/query.go:4-6func InitData() {} 空实现,且没有任何调用点。
    • internal/logic/handler.go:43-47:被注释掉的 provider 白名单校验(只允许 local 的历史逻辑)。
    • internal/models/fts_record.go:28-29,36HandleCmd/HandleArgs/Status 三个字段("文件上传成功后操作命令"/处理状态机)从未被写入——Status 恒为 0handler.go:85HandleCmd/HandleArgs 永远为空串;没有任何 worker 消费 fts_record
    • internal/impl/impl.go:13-16service/dependencies.go:19-31RedisServiceMemorySerice 只被赋值从未被读取(EtcdService 仅用于 cmd/main/main.go:32 的服务注册)。
    • test/fts_test.go:51handler.go:26:测试传入的 purpose 字段在服务端已被忽略。
  • 影响fts 只实现了"上传"这一半能力:没有列表、详情、下载、删除、秒传(test/fts_test.go:97TestPrepare)、也没有 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-67README.md:174-198module/base/fts/test/oss_provider_req.http:1module/base/fts/test/fts_test.go:26module/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/Dockerfiledocker-compose.ymlMakefileLICENSE —— 模块内均不存在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:33internal/logic/provider.go:87provider.go:28,39internal/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.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-10SaveName 却是 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-34internal/impl/impl.go:16,21service/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 的路由断言);重命名为 MemoryServiceimpl.go:16,21dependencies.go:30 同步)。

23. 加固项:ParseJwt 未限定签名算法、CORS 全开、匿名 /config 暴露上传策略、用 gRPC 状态码当业务码

  • 位置D:\work\bsm-sdk\core\middleware\jwt.go:50D:\work\bsm-sdk\core\crypto\token\jwt.go:64-73D:\work\bsm-sdk\core\middleware\cors.go:10internal/logic/config.go:9-11internal/routers/register.go:22-24handler.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.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-115internal/logic/handler.go:27etc/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
    
  • 影响:跨月边界的文件目录归属依赖容器 TZcreated_atDB 按 Asia/Shanghai 解释)可能不在同一个月,给按月归档/清理脚本埋坑;ToLower 使本地路径与客户端传入的 Bucket 大小写不一致S3 桶名合法、本地目录名不合法),造成"同一个值在不同 provider 下落点不同"的隐性行为。
  • 建议:统一时区来源(显式 TZ/time.LoadLocation 配置或用 UTC并把小写化限制在 provider == "minio" 分支。

4. 推荐优化方案

按"先堵安全 -> 再修稳定性 -> 后重构"的顺序,建议分四批:

第一批P012 天,可与上线解耦)

  1. bucket 收敛:新增 sanitizeBucket() 做正则白名单 + 拒绝 ./..///\/:,并在 LocalUpload 落盘前用 filepath.Rel(root, target) 断言未逃逸(P0-1MinIO 分支只允许配置中登记的桶集合。
  2. 停止在请求期构造错误码:handler.go 四处 errcode.NewError 换成包级预定义错误(或本模块 internal/errors),彻底消除 AllErrors 并发写路径(P0-2)。同步给 SDK 提 issueNewError 不应写全局 map。
  3. LocalUpload/OssUpload 只返回 error不再写响应修掉双响应P1-4);顺手消除对 infra.Response 单例的写依赖(P1-5)——统一走 internal/response,或至少在 SDK 修复前把响应构造为局部变量。

第二批P135 天)

  1. 上传入口加 http.MaxBytesReader(或改 MultipartReader 流式 + 计数中断),并为 http.ServerReadTimeout/WriteTimeout/IdleTimeoutcmd/mainsignal.NotifyContext + ShutdownP1-3P2-16)。
  2. JWT 密钥显式化:config.New 校验并注入 JwtSecretKeysup 配置注入 BSM_JwtSecretKey/rest/fts/uploader 保持 JwtAuth(true) 不变(当前确实生效,见第 5 节"已核实"P1-6)。
  3. SDK 侧修 IsExpired 的 nil 解引用并调整"先验签后判过期"顺序(P1-7)。

第三批P212 周)

  1. 上传改为单遍流式Tee 计算 SHA-256+ 失败回滚(os.Remove/RemoveObject+ DB 失败补偿(P2-8P2-12)。
  2. 响应 DTO 化,剔除 local_path/oss_path,错误信息统一映射(P2-9)。
  3. 内容校验(魔数/MIME 三元白名单)、扩展名大小写归一(P2-10)。
  4. minio.Client 复用 + c.Request.Context() + 正确 ContentType + x-amz-meta-sha256P2-11)。
  5. 配置段级校验与三环境差异化(生产库/桶/密钥、UseSSL: true、绝对 UploadDir 并启动时探测可写)(P2-13);权限位收敛到 0750/0640P2-14NewSubdir 入参校验(P2-15)。
  6. 测试补齐:路由断言修正 + logic 层表驱动安全用例(路径穿越、扩展名、大小、并发、回滚)+ go test -raceP2-17)。

第四批P3随手清理

  1. 删除 internal/{response,errors} 或全面启用(二选一,P3-18);清理 fetch.gofts_record.go 注释代码、InitData、未使用的 RedisService/MemorySericeMemorySerice 拼写(P3-19P3-22)。
  2. 重写 README/通知 wiki 更新,修正 .http 与集成测试 URL明确"未实现能力清单"P3-19P3-20)。
  3. 注释/单位/时区/小写化一致性修正(P3-21P3-24SDK 加固项(P3-23)。

5. TODO 清单

  • P0-1bucket 增加白名单校验并在落盘前做路径逃逸断言(filepath.RelMinIO 侧限定可写桶集合|验收:单测覆盖 bucket=../../x/etc..%2f..a/../../b,请求返回 400 且磁盘无越权写入|涉及:module/base/fts/internal/logic/handler.go:27module/base/fts/internal/logic/provider.go:26
  • P0-2 移除请求期 errcode.NewError 调用,改用预定义错误/internal/errors;并推动 SDK NewError 不再写 AllErrors|验收:并发 100 个错误请求(provider 为空)无 concurrent map writesgo test -race 通过|涉及:module/base/fts/internal/logic/handler.go:40D:\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:49module/base/fts/cmd/main/main.go:42
  • P1-4 provider 不再写响应,失败响应唯一出口|验收:模拟上传目录不可写,响应体为单段合法 JSONsuperfluous WriteHeader 日志|涉及:module/base/fts/internal/logic/provider.go:31module/base/fts/internal/logic/handler.go:97
  • P1-5 消除 infra.Response 共享单例写;改用局部响应对象或模块 internal/response|验收:并发压测下每个响应只含自己请求的 identity-race 无告警|涉及:module/base/fts/internal/logic/handler.go:106D:\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:32etc/supervisor.bsm-apps-fts.conf:1
  • P1-7 修复 SDK IsExpiredExpiresAt nil 解引用并改为验签后判过期|验收:Authorization: a.<payload 无 exp>.c 返回 401 且服务端无 panic 堆栈|涉及:D:\work\bsm-sdk\core\crypto\token\jwt.go:97module/base/fts/internal/routers/uploader.go:13
  • P2-8 上传/落库失败回滚(删文件/删对象),Chmod 失败不再导致整体失败|验收:注入 DB 失败后上传目录无残留文件|涉及:module/base/fts/internal/logic/provider.go:56module/base/fts/internal/logic/handler.go:101
  • P2-9 响应改 DTO去掉 local_path/oss_path 等内部字段;错误只回业务码+通用文案|验收:上传成功响应不含服务器路径;非法扩展名响应 message 不含文件系统细节|涉及:module/base/fts/internal/logic/handler.go:106module/base/fts/internal/models/fts_record.go:32
  • P2-10 扩展名归一小写比对并增加魔数/MIME 校验|验收:A.PNG 可上传;把 ELF/HTML 内容命名为 .png 被拒|涉及:module/base/fts/internal/logic/handler.go:63module/base/fts/internal/logic/handler.go:127
  • P2-11 minio.Client 复用、使用请求 context 并加超时、ContentType 按检测结果设置|验收:压测中 MinIO 连接数不随请求线性增长;客户端断连后上传及时取消|涉及:module/base/fts/internal/logic/provider.go:79module/base/fts/internal/logic/provider.go:99
  • P2-12 单遍流式计算 SHA-256Tee并引入 hash+size 去重/秒传|验收:上传 1 GiB 文件时对源文件的读取次数为 1本地 provider重复文件命中秒传涉及module/base/fts/internal/logic/handler.go:70module/base/fts/internal/logic/provider.go:56
  • P2-13 配置段级校验 + 三环境差异化 + 生产 UseSSL: trueUploadDir 绝对路径与可写探测|验收:删除 YAML 中 FtsConfig 段后启动即报错退出;fts_prod.yaml 不再指向 bsm_dev|涉及:module/base/fts/internal/config/config.go:36etc/fts_prod.yaml:8
  • P2-14 目录 0750 / 文件 0640或可配置验收同主机非属主用户无法读取上传文件涉及module/base/fts/internal/logic/provider.go:29module/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.Printlncmd/main 增加优雅退出与超时|验收:日志可检索到单次上传的 provider/bucket/size/耗时SIGTERM 下在途上传完成后再退出|涉及:module/base/fts/internal/logic/provider.go:102module/base/fts/cmd/main/main.go:42
  • P2-17register_test.go 路由断言 + 新增上传安全/边界单测(含 -race)|验收:go test ./... 全绿,新增用例覆盖路径穿越/扩展名/超限/并发/回滚/401涉及module/base/fts/internal/routers/register_test.go:16module/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:1module/base/fts/README.md:166
  • P3-19 清理死代码与未实现项并在文档标注|验收:fetch.go 注释块、fts_record.go:48-98InitData、未使用的 RedisService/MemorySerice 全部删除或明确标注 Roadmap涉及module/base/fts/internal/logic/fetch.go:1module/base/fts/internal/models/query.go:4
  • P3-20 重写 README真实端点/文件清单/配置项),修正 .http 与集成测试 URL验收README 中每个端点均可用 curl 命中;.http 指向 /rest/fts/uploader|涉及:module/base/fts/README.md:56module/base/fts/test/oss_provider_req.http:1
  • P3-21 修正 MaxSize/NewSubdir/权限注释与 ID 体系说明|验收:注释与实现逐条一致(MaxSize 标注 bytes涉及etc/fts_dev.yaml:33module/base/fts/internal/logic/provider.go:87
  • P3-22 register_test.go 转 LF 并通过 gofmtMemorySerice 更名 MemoryService|验收:gofmt -l . 无输出;rg MemorySerice 无命中|涉及:module/base/fts/internal/routers/register_test.go:1module/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:65D:\work\bsm-sdk\core\middleware\cors.go:10
  • P3-24 统一目录命名时区,并仅对 MinIO 分支做 ToLower|验收:跨月边界上传的目录与 created_at 同月(同一时区);本地 provider 保留原始大小写桶名|涉及:module/base/fts/internal/logic/provider.go:114module/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_JwtSecretKeyP1-6 的最终定级依赖它;仓库内 supervisor 配置未注入,etc/fts_*.yamlSecretKey 与 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.golicence 生成工具,硬编码 /data/app/etc/licence.key"slcor"20250101-20350101)未纳入本模块运行时链路,其安全影响未评估。
    • pkgs/allpkgs/ecmall 之外的其他宿主(是否还有第三方调用方直连 /rest/fts/uploader)未排查。