仅靠文件后缀名校验不安全,因攻击者可将shell.php伪造成avatar.jpg上传;浏览器和前端校验均可绕过,后端必须基于文件头魔数(如jpeg的0xff 0xd8、png的0x89 0x50 0x4e 0x47)校验,且须用io.readfull读取前n字节并重置指针,禁用content-type和filename依赖。

为什么仅靠文件后缀名校验不安全
用户上传的 avatar.jpg 可能是伪造后缀的恶意二进制,比如把一个 shell.php 改名为 photo.jpg 后上传。浏览器和前端 JS 校验都可被绕过,后端必须基于内容本身判断类型。Gin 默认的 c.FormFile() 只返回 *multipart.FileHeader,它包含 Filename 和 Size,但不自动读取文件体——魔术字节(Magic Bytes)就藏在开头几个字节里,必须显式读取才能验证。
关键点:不能依赖 Header.Get("Content-Type"),它由客户端提供,完全不可信;也不能只检查 file.Filename 的扩展名。
如何用 Gin 读取并校验前 N 个字节
Gin 的 c.FormFile() 返回的 *multipart.FileHeader 提供了 Open() 方法,返回一个 io.ReadCloser。你需要手动读取前 4–8 字节(不同格式所需长度不同),再比对魔术字节签名。
实操建议:
- 调用
file.Open()获取文件句柄,立即用defer f.Close()确保释放 - 用
io.ReadFull(f, buf)读取固定长度(如[8]byte),避免部分读取导致误判 - 校验前先确认
buf实际读取长度是否足够(例如 PNG 需要前 8 字节,若文件小于 8 字节则直接拒绝) - 不要用
f.Seek(0, io.SeekStart)回退——multipart.FileHeader.Open()返回的 reader 不一定支持 seek,尤其在内存上传模式下会 panic
示例(校验 PNG):
file, err := c.FormFile("image")
if err != nil {
c.AbortWithStatusJSON(400, gin.H{"error": "no file uploaded"})
return
}
f, err := file.Open()
if err != nil {
c.AbortWithStatusJSON(500, gin.H{"error": "failed to open file"})
return
}
defer f.Close()
var header [8]byte
_, err = io.ReadFull(f, header[:])
if err != nil {
c.AbortWithStatusJSON(400, gin.H{"error": "file too small for magic byte check"})
return
}
if !bytes.Equal(header[:4], []byte{0x89, 0x50, 0x4E, 0x47}) {
c.AbortWithStatusJSON(400, gin.H{"error": "not a valid PNG"})
return
}
Gin 中常见格式的魔术字节对照与陷阱
不同格式的魔术字节位置、长度、大小端约定各不相同。JPEG 的 0xFF 0xD8 在开头,PDF 的 %PDF 在前 4 字节但可能带 BOM,Office 文档(如 DOCX)本质是 ZIP,需匹配 PK\x03\x04。
容易踩的坑:
- JPEG 校验只比前 2 字节?不够——有些 JPEG 包含 APPn 段,真正起始仍是
0xFF 0xD8,但必须确保这俩字节出现在 offset 0,不能跳过头部搜索 - PDF 文件可能以 UTF-8 BOM(
0xEF 0xBB 0xBF)开头,导致%PDF实际在 offset 3,此时应先跳过 BOM 再比对,或统一 strip 后校验 - DOCX/XLSX 是 ZIP 格式,魔术字节为
PK\x03\x04(即0x50 0x4B 0x03 0x04),但某些工具生成的 ZIP 可能用PK\x05\x06(空 ZIP),这种极少见,生产环境建议按严格标准拒绝 - Go 的
[]byte比较区分大小写,"pdf"≠"PDF",PDF 魔术字节是 ASCII 字符%PDF,不是小写
如何避免内存爆炸和临时文件滥用 Gin 默认将小文件(Open() 后不做限制地读取整个文件校验,可能触发 OOM 或耗尽磁盘空间。
必须做的控制:
- 在
c.Request.ParseMultipartForm()前设置最大内存阈值:c.Request.MultipartForm.MaxMemory = 32 ,否则默认 32MB 可能过高 - 校验魔术字节时,只读取必要字节数(通常 ≤ 16),绝不用
io.ReadAll()或f.Stat().Size判断后再读——前者危险,后者不适用于内存上传模式(Size可能不准) - 如果业务还需后续处理(如缩略图生成),建议校验通过后,用
io.Copy将f流式写入安全路径,而非反复Open() - 不要用
os.CreateTemp手动落盘再校验——多一次 I/O,且临时文件权限/清理易出错
魔术字节校验本身很快,但它的可靠性高度依赖你是否真正读取了原始字节流。任何中间层(如反向代理、CDN)若对上传 body 做了编码或改写,都会破坏魔术字节——这种情况需要前置协议对齐,不是 Gin 层能解决的。











