应通过读取文件头(magic bytes)校验后缀和mime:先file.open(),再io.readfull读512字节,最后用http.detectcontenttype或硬编码比对;禁用中间件提前读body;安全取扩展名需先filepath.base再filepath.ext并转小写;大小限制应在handler内手动parsemultipartform实现。

c.FormFile 之后怎么校验后缀和 MIME?
别信 file.Filename 的后缀,也别信 file.Header.Get("Content-Type")——浏览器能伪造,用户能改,.jpg.exe 或空字节截断都可能绕过。真正可靠的判断得靠文件头(magic bytes)。
操作上分三步:先用 file.Open() 打开临时文件流,再读前 512 字节,最后用 http.DetectContentType() 或硬编码比对 magic。示例:
src, _ := file.Open()
defer src.Close()
head := make([]byte, 512)
_, _ = io.ReadFull(src, head)
contentType := http.DetectContentType(head)
// 或手动比对:if bytes.HasPrefix(head, []byte{0x89, 0x50, 0x4E, 0x47}) { /* PNG */ }
- 必须
io.ReadFull,不是io.Read;否则可能只读到几十字节,检测失效 -
http.DetectContentType返回的是标准 MIME,比如"image/png",不是"png",匹配时注意格式 - 允许列表建议用 map[string]bool 硬编码,如
allowed := map[string]bool{"image/jpeg": true, "image/png": true},别用正则或子串匹配
中间件里做校验,为什么上传总卡住或报 400?
因为中间件在 handler 之前执行,而 c.FormFile 内部会调用 c.Request.ParseMultipartForm,如果中间件提前读了 c.Request.Body(比如日志中间件、JWT 解析),Body 流就被消费光了,后续 FormFile 拿不到数据,直接返回 nil, nil,看起来像“没传文件”。
- 所有中间件里禁止调用
c.Request.Body或c.Request.ParseMultipartForm - JWT 验证类中间件,若必须放在上传路由前,需确保它不读 body;可改用 header 提取 token,跳过 body 解析
- 调试时加一行:
files, _ := c.MultipartForm(); log.Printf("files: %v", files.File),看字段是否为空 - 实在要全局校验,把校验逻辑挪到 handler 开头,而不是中间件
如何安全地从 filename 提取扩展名?
用户传的 file.Filename 可能是 ../../../etc/passwd%00.jpg,直接 filepath.Ext(file.Filename) 会返回 ".jpg",但前面路径已失控。必须先剥离路径,再取扩展。
- 用
filepath.Base(file.Filename)得到纯文件名,如"a/b/c.jpg"→"c.jpg" - 再用
filepath.Ext()安全取扩展,"c.jpg"→".jpg" - 扩展名要转小写统一处理:
strings.ToLower(filepath.Ext(baseName)) - 空扩展名(如
"shell")或非常规扩展(如".php5")应拒绝,不依赖黑名单,只放行明确允许的
为什么限制大小要在中间件之外做?
router.MaxMultipartMemory 是全局配置,生效于 Gin 解析 multipart 的第一层,但它不能触发自定义错误响应(比如返回 JSON 错误)。真要控制单个接口的大小上限,必须在 handler 里手动调用 c.Request.ParseMultipartForm(32 并捕获 error。
- 设太小(如 8MB)会导致大文件直接 400,但错误信息是默认 HTML,前端难解析
- 应在 handler 开头加:
if err := c.Request.ParseMultipartForm(32 - 这个调用必须在
c.FormFile之前,否则FormFile内部会再试一次,重复解析可能 panic - 别把
ParseMultipartForm放进中间件——它只能调用一次,且依赖当前请求上下文
最常被忽略的点:校验完 MIME 和后缀,别忘了重置文件指针。用 src.Seek(0, 0) 回到开头,否则后续 io.Copy 会从 512 字节后开始写,丢掉文件头。











