不能只靠后缀或content-type校验,因二者均可被伪造;必须读取文件魔数(二进制头部)进行真实类型识别,如jpeg用0xff,0xd8,0xff校验,png用8字节魔数,且需注意字节切片比对、流重用及校验后安全保存。

为什么不能只靠文件后缀或 Content-Type 校验文件类型
用户上传的 Content-Type 可被任意伪造,后缀名(如 .jpg)也能随便改——攻击者把恶意脚本重命名为 shell.jpg 就能绕过基础检查。真正可靠的依据是文件开头的魔数(magic number),即二进制头部几个字节的固定模式。Gin 本身不自动读取文件体,所以必须手动读取并校验。
如何用 Gin 获取并校验上传文件的魔数头
Gin 的 c.FormFile() 只返回 *multipart.FileHeader,它不包含文件内容;必须调用 header.Open() 得到 io.ReadCloser,再读取前若干字节做比对。注意:不能直接用 os.Open(header.Filename),因为临时文件路径不可靠且可能已被清理。
- 先调用
file, err := header.Open()打开上传流 - 用
io.ReadFull(file, buf)或io.ReadAtLeast(file, buf, n)读取前 4–8 字节(不同格式需要长度不同) - 校验时建议用
bytes.HasPrefix()而非字符串比较,避免编码/空字节问题 - 校验完别忘了
file.Close(),否则连接可能卡住
<pre class="brush:php;toolbar:false;">file, _ := header.Open()
defer file.Close()
buf := make([]byte, 4)
_, _ = io.ReadFull(file, buf)
if !bytes.HasPrefix(buf, []byte{0xFF, 0xD8, 0xFF}) { // JPEG
return errors.New("invalid JPEG magic header")
}
常见文件类型的魔数及校验注意事项
不同格式的魔数长度和可变性差异大:JPEG 开头固定 3 字节,PNG 是 8 字节(89 50 4E 47 0D 0A 1A 0A
%PDF-),而 ZIP/DOCX/EXCEL 实际都是 ZIP 容器,魔数为 50 4B 03 04。注意:
- PNG 的首字节是
0x89(非 ASCII),用字符串字面量写会出错,务必用字节切片 - 某些格式(如 GIF)有多个合法魔数(
GIF87a/GIF89a),需同时覆盖 - 不要一次性读太多字节(比如读 1MB),既浪费又可能触发超时;4–8 字节足够区分绝大多数格式
- 如果业务允许「宽松校验」,可对 DOCX/XLSX 等统一按 ZIP 魔数处理,但需明确告知这是妥协方案
魔数校验和文件保存的顺序与资源管理
必须在校验通过后再保存文件,否则可能写入非法内容。但 file 流已部分读取,直接 io.Copy(dst, file) 会丢失开头字节。解决方法有两个:
- 校验前用
io.MultiReader(bytes.NewReader(buf), file)把已读字节“塞回去”,再整体拷贝 - 更稳妥的是:校验后
file.Close(),再用header.Open()重新打开一次(Gin 保证临时文件仍存在,直到请求结束) - 若用第一种方式,注意
buf长度要和实际读取一致,否则MultiReader可能行为异常
临时文件生命周期由 Gin 控制,通常在 handler 返回后自动清理,但手动调用 header.Open() 多次不会导致重复创建或泄漏。
魔数校验不是银弹——它只防伪装,不防内容篡改或嵌套恶意 payload。真正在意安全的场景,还得配合病毒扫描、沙箱解析或白名单 MIME 类型二次验证。











