必须设置maxmultipartmemory限制,否则大文件导致oom进程被杀;需校验文件魔数与扩展名、生成唯一文件名、用filepath.join构造绝对路径并预创建目录、所有下载须经鉴权路由。

上传前必须设置内存与文件大小限制
不设限的 MaxMultipartMemory 会让 Gin 把整个上传文件读进内存,大文件直接触发 OOM —— 进程被系统 kill,连错误日志都来不及打。这不是理论风险,是上线第一天就可能发生的事故。
正确做法是在初始化路由前强制设限:
router := gin.Default() router.MaxMultipartMemory = 32
- 这个值必须小于你服务容器或服务器的可用内存余量,别写成
100 后再加个“反正有 swap” - 前端传
Content-Length超过该值时,Gin 会直接返回413 Payload Too Large,不进路由逻辑 - 注意:它只限制单次请求的 multipart 总内存,不等于单文件大小(一个请求可含多个文件)
文件名和扩展名必须双重校验
只看 header.Filename 或只靠前端 JS 检查扩展名,等于给攻击者留了后门。真实攻击中,用户会用 Burp Suite 改包、重放、伪造 Content-Type,甚至上传带 BOM 的 PHP 文件绕过纯后缀判断。
必须做两件事:
- 生成服务端唯一文件名:
uuid.NewString() + "_" + strconv.FormatInt(time.Now().Unix(), 10) + ext,彻底丢弃客户端传来的Filename - 扩展名白名单 + 内容魔数校验:先从
file.Header.Get("Content-Type")初筛,再用io.ReadFull(file, buf)读前 16 字节比对魔数(如jpg是FF D8 FF),拒绝所有未明确允许的类型(.php、.sh、.exe等一律 400)
保存路径不能依赖相对路径拼接
c.SaveUploadedFile() 接收的是相对路径,但它的“相对”是相对于当前工作目录(os.Getwd()),不是项目根目录。本地开发时 go run 在 cmd/ 下,文件却存到 ./uploads/,结果出现在 GOPATH 里;部署时用 systemd 启动,工作目录可能是 /,./uploads 就变成根目录下的 /uploads —— 权限爆炸、路径混乱。
统一用 filepath.Join() 构造绝对路径,并提前确保目录存在:
uploadDir := filepath.Join(".", "uploads")
if err := os.MkdirAll(uploadDir, 0755); err != nil {
c.AbortWithStatusJSON(500, gin.H{"error": "upload dir init failed"})
return
}
dst := filepath.Join(uploadDir, newFilename)
c.SaveUploadedFile(file, dst)
- 别用
"./uploads/" + filename,字符串拼接无法跨平台(Windows 路径分隔符是\) -
MkdirAll必须在SaveUploadedFile前调用,否则首次上传必 500 - 权限
0755足够,别设0777—— 上传目录不可执行
直链服务必须走 Gin 路由,不能暴露物理路径
很多人图省事,把上传目录配给 Nginx 的 location /uploads/,以为“静态服务更快”。这等于把鉴权、防盗链、访问统计全扔掉。攻击者只要知道文件名,就能直接下载任意用户上传的内容,包括管理员误传的 .env 或数据库备份。
正确姿势是:所有文件访问必须经过 Gin 中间件校验
- 用
router.StaticFS("/dl", http.Dir(uploadDir))是错的 —— 它不走中间件链 - 应注册显式路由:
router.GET("/dl/:file_id", serveDownload),在serveDownload里查 DB 验证权限、检查签名、设置Content-Disposition - 临时直链推荐带时间戳+HMAC 签名:
/dl/abc123?t=1723362540&s=def456,服务端验证t未超时且s == hmac.Sum256([]byte(file_id + t + secret))
最常被忽略的一点:魔数校验和签名验证必须在 SaveUploadedFile 之后、文件落盘之前完成。一旦恶意文件写入磁盘,后续任何拦截都只是“亡羊补牢”。











