仅校验后缀名危险,因文件名可被客户端伪造;须服务端先剥离路径、转小写、白名单校验,再读文件头magic bytes确认真实类型,禁用c.saveuploadedfile,改用可控流式处理。

为什么只校验后缀名是危险的
后缀名完全由客户端控制,file.Filename 可以被任意伪造——比如把恶意可执行文件命名为 avatar.jpg,后端仅靠 strings.HasSuffix(file.Filename, ".jpg") 就放行,等于给木马开了绿灯。浏览器、Postman 甚至 curl 都能轻易绕过前端 JS 校验,所以后缀检查必须放在服务端,且只能作为第一道粗筛,不能替代内容检测。
如何安全提取并校验文件扩展名
先用 filepath.Base 剥离路径,再用 strings.ToLower(path.Ext()) 获取小写后缀,避免大小写绕过(如 .JPG);但注意 path.Ext 对无后缀或带多个点的文件(如 archive.tar.gz)行为不一致,建议显式拆分:
name := filepath.Base(file.Filename)ext := strings.ToLower(filepath.Ext(name))- 硬编码白名单:
allowedExts := map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".gif": true} - 拒绝空后缀或非预期值:
if ext == "" || !allowedExts[ext] { c.AbortWithStatusJSON(400, gin.H{"error": "invalid file extension"}) }
真正可靠的类型校验必须读文件头
后缀只是字符串,而真实类型藏在文件开头的 magic bytes 里。别信 file.Header.Get("Content-Type"),它由浏览器生成,极易伪造。正确做法是打开文件流,读前 512 字节,用 http.DetectContentType 或手动比对 magic:
src, _ := file.Open(); defer src.Close()head := make([]byte, 512); _, _ = io.ReadFull(src, head)- 重置读取位置:
src.Seek(0, 0)(否则后续io.Copy会从 512 字节后开始) - 判断 PNG:
bytes.HasPrefix(head, []byte{0x89, 0x50, 0x4E, 0x47});JPEG:bytes.HasPrefix(head, []byte{0xFF, 0xD8, 0xFF}) - 若检测结果与后缀冲突(如
.jpg但 magic 是 ELF),直接拒绝
常见陷阱:c.SaveUploadedFile 不做任何校验
c.SaveUploadedFile 是个“信任即保存”的快捷函数,它既不检查后缀,也不读文件头,更不防路径遍历——传入 ../../../etc/passwd 这样的 file.Filename,它真会往系统目录里写。生产环境必须废弃它,改用显式流式处理:
- 用
os.Create创建目标文件,路径由你完全控制(如filepath.Join(uploadDir, uuid.New().String()+ext)) - 用
io.Copy传输数据,中间可插入病毒扫描、尺寸解析等逻辑 - 务必
defer dst.Close()和defer src.Close(),否则 fd 泄露会拖垮服务 - 临时文件残留?Gin 不管——上传失败时要主动
os.Remove(dstPath)回滚
最易被忽略的点:后缀校验和 magic 校验必须串联执行,且 magic 必须在重命名前完成;否则一旦文件已落盘,再删就涉及权限、原子性、并发竞争等问题,远比拦截在内存中麻烦得多。











