Go 的 ParseMultipartForm 必须先调用才能读取文件,因 http.Request 默认不自动解析 multipart 数据;若未调用,r.MultipartForm 为 nil,r.FormFile 将返回错误或空文件句柄,导致静默失败。
ParseMultipartForm 必须先调用才能读取文件很多开发者在处理表单上传时直接调用 r.FormFile 或遍历 r.MultipartReader(),却没注意 ParseMultipartForm 是前置必要步骤。Go 的 http.Request 默认不会自动解析 multipart 数据——它被懒加载,不调用就为空。
如果不显式调用,r.MultipartForm 为 nil,r.FormFile 会返回 http.ErrNotMultipart 或空文件句柄,但错误信息容易被忽略,导致后续 file.Size 为 0、file.Header 为空等静默失败。
r.ParseMultipartForm(maxMemory)
maxMemory 是内存缓冲上限(单位字节),超过此值的文件部分会暂存到磁盘临时文件;建议设为合理值(如 32 即 32MB)
Content-Type 和 Filename 不能只信前端传来的值前端通过 input type="file" 提交的 Content-Type(即 file.Header.Get("Content-Type"))和文件名(file.Filename)均可被任意篡改。仅靠它们做校验极易绕过,比如把恶意脚本命名为 avatar.jpg 并声明 image/jpeg。
file.Open() 获取 io.Reader 后,读取前几个字节做 magic number 检查(如 jpeg 开头是 FF D8 FF)net/http.DetectContentType 只能用于纯文本/HTML 等简单类型,对二进制文件不可靠,不推荐依赖golang.org/x/image/draw + image.DecodeConfig 解析图片头,或用 github.com/h2non/filetype 库做真实类型探测file.Filename 必须过滤路径遍历(如 ../../etc/passwd),用 path.Base(file.Filename) 提取基础名file.Size 读取后立即判断,别等拷贝时才检查file.Size 是 multipart.FileHeader 字段,在 r.FormFile 返回时已确定,代表客户端声称的文件大小。但它可能被伪造——不过 Go 在解析时已从 multipart boundary 中提取该值,所以比前端 JS file.size 稍可信,但仍非绝对可靠。
r.FormFile 成功后立刻检查:if file.Size > 10
io.Copy 到磁盘时再判断,否则攻击者可构造超大文件触发 OOM 或填满磁盘ParseMultipartForm 时 maxMemory 小于文件大小,Go 会将超出部分写入临时磁盘文件,此时 file.Size 仍准确,但实际 I/O 已发生以下是一个最小可行的表单文件验证逻辑,聚焦「先验、轻量、防绕过」:不做完整解码,只读头部做判断。
func uploadHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 1. 必须先解析 multipart,否则 FormFile 失效
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("avatar")
if err != nil {
http.Error(w, "no file uploaded", http.StatusBadRequest)
return
}
defer file.Close()
// 2. 检查声明大小
if header.Size > 5<<20 {
http.Error(w, "file too large", http.StatusBadRequest)
return
}
// 3. 清洗文件名,防止路径穿越
safeName := path.Base(header.Filename)
if safeName == "" || safeName == "." || safeName == ".." {
http.Error(w, "invalid filename", http.StatusBadRequest)
return
}
// 4. 读取前 512 字节做类型探测(真实内
容)
buf := make([]byte, 512)
n, _ := io.ReadFull(file, buf)
if n < 512 {
buf = buf[:n]
}
kind, _ := filetype.Match(buf)
if kind == filetype.Unknown || (kind.Extension != "jpg" && kind.Extension != "png" && kind.Extension != "gif") {
http.Error(w, "unsupported file type", http.StatusBadRequest)
return
}
// 5. 重置 reader 位置,准备后续保存(需支持 Seek)
if seeker, ok := file.(io.Seeker); ok {
seeker.Seek(0, 0)
}
// ✅ 此时才可安全保存或进一步处理
fmt.Fprintf(w, "OK: %s (%s)", safeName, kind.Extension)
}
真实项目中还要加 MIME 白名单、扩展名二次校验、临时目录权限控制,但核心逻辑逃不开这四步:解析 → 大小截断 → 名称清洗 → 内容探针。最容易被跳过的,是第 4 步——没有它,所有基于 Content-Type 或后缀的校验都形同虚设。