必须在读取文件前调用 r.ParseMultipartForm,否则 r.MultipartForm 为 nil,导致 r.FormFile 失败或 panic;参数指定内存缓存上限(如 32

为什么 r.ParseMultipartForm 必须在读取文件前调用
Go 的 http.Request 不会自动解析 multipart 表单(包括文件),必须显式调用 r.ParseMultipartForm,否则 r.MultipartForm 为 nil,后续调用 r.FormFile 或遍历 r.MultipartForm.File 都会 panic 或返回空值。
常见错误现象:http: no such file、nil pointer dereference、multipart: NextPart: EOF(实际是未解析就尝试读)。
-
ParseMultipartForm的参数是最大内存缓存字节数(如32 表示 32MB),超过该值的文件部分会暂存到磁盘临时目录 - 若不调用,
r.FormValue("xxx")仍可读普通字段,但所有文件字段均不可见 - 调用后,
r.MultipartForm.File才包含按表单字段名索引的[]*multipart.FileHeader
如何安全读取并保存上传的文件
使用 r.FormFile 是最简方式,但它只返回第一个同名文件;若需支持多文件上传(),必须用 r.MultipartForm.File 遍历。
关键风险点:用户可伪造 Filename 字段(如 ../../etc/passwd),必须清洗路径;同时需限制文件大小、类型、扩展名,避免写入危险位置或触发解析器漏洞。
立即学习“go语言免费学习笔记(深入)”;
- 用
filepath.Base(header.Filename)提取原始文件名,再结合filepath.Join(uploadDir, safeName)构造保存路径 - 检查
header.Size是否超出业务允许上限(如 10MB),超限直接返回 400 - 用
header.Header.Get("Content-Type")做初步 MIME 校验(注意:该值由客户端提供,不可信,仅作辅助) - 打开文件前先
os.Create目标路径,再用io.Copy流式写入,避免全量加载进内存
file, header, err := r.FormFile("avatar")
if err != nil {
http.Error(w, "no file uploaded", http.StatusBadRequest)
return
}
defer file.Close()
dst, err := os.Create(filepath.Join("./uploads", filepath.Base(header.Filename)))
if err != nil {
http.Error(w, "failed to create file", http.StatusInternalServerError)
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
http.Error(w, "failed to save file", http.StatusInternalServerError)
return
}
如何处理多个同名文件(multiple 场景)
当 HTML 使用 ,Go 不会自动聚合为单个 FileHeader,而是在 r.MultipartForm.File["documents"] 中返回切片。若未调用 ParseMultipartForm,该切片为空。
容易踩的坑:直接对 r.FormFile("documents") 循环调用(它只返回一个),或忽略 r.MultipartForm 的初始化时机。
- 务必先调用
r.ParseMultipartForm(maxMemory) - 检查
r.MultipartForm.File是否包含目标 key,再遍历其值 - 每个
*multipart.FileHeader都需单独Open()获取multipart.File(底层是io.ReadCloser) - 建议为每个文件生成唯一文件名(如
uuid.New().String() + ext),避免覆盖和冲突
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, "parse error", http.StatusBadRequest)
return
}
files := r.MultipartForm.File["documents"]
for _, fhdr := range files {
src, err := fhdr.Open()
if err != nil {
continue // log and skip
}
defer src.Close()
dst, _ := os.Create("./uploads/" + uuid.New().String() + filepath.Ext(fhdr.Filename))
defer dst.Close()
io.Copy(dst, src)
}
为什么不能依赖 Content-Type 判断文件类型
浏览器设置的 Content-Type 完全由客户端控制,攻击者可轻易伪造为 image/jpeg,实际上传的是 PHP WebShell。真实校验必须基于文件内容魔数(magic bytes)。
Go 标准库不提供开箱即用的 MIME 探测,需手动读取前几百字节比对签名,或引入轻量库如 gabriel-vasile/mimetype。
- 读取
file的前 512 字节(io.LimitReader(file, 512)),传给mimetype.Detect - 白名单校验返回的 MIME 类型(如只允许
image/png、application/pdf) - 即使 MIME 匹配,也应结合扩展名做二次过滤(如
.png文件必须匹配 PNG 魔数89 50 4E 47) - 不要用
filepath.Ext做唯一判断——攻击者可传shell.php.jpg
真正安全的文件上传,核心不在“怎么读”,而在“读完后敢不敢信它”。魔数校验、路径净化、大小限制、存储隔离,缺一不可。










