必须校验魔术字节而非filename或content-type,因二者可被客户端任意伪造;应打开文件读取前n字节(如8字节)比对真实类型标识,如jpeg为0xff 0xd8、png为0x89 0x50 0x4e 0x47。

仅靠 c.FormFile() 返回的 Filename 或 Header.Get("Content-Type") 做校验,根本防不住伪造——攻击者把 shell.php 改成 avatar.jpg 就能绕过,浏览器或前端 JS 校验也一戳就破。
为什么不能信 Filename 和 Content-Type
客户端可任意篡改 file.Filename(含 ../、空字节、超长名),Header.Get("Content-Type") 更是完全由浏览器/工具填写,curl -H "Content-Type: image/jpeg" 一行就能骗过所有只依赖它的逻辑。Gin 不会验证这些字段的真实性,它只负责解析 multipart 边界和字段名。
必须读魔术字节(Magic Bytes)校验文件真实类型
真实格式藏在文件开头几个字节里:JPEG 是 0xFF 0xD8,PNG 是 0x89 0x50 0x4E 0x47,PDF 是 0x25 0x50 0x44 0x46。你得手动打开文件、读前 N 字节比对,且不能跳过长度检查。
- 调用
file.Open()获取io.ReadCloser,立刻defer f.Close() - 用
io.ReadFull(f, buf)(比如[8]byte)读固定长度,避免部分读取导致误判 - 若文件实际长度 len(buf)(如传了个 3 字节的空文件),直接拒绝,不继续校验
- 别用
f.Seek(0, io.SeekStart)——multipart.FileHeader.Open()返回的 reader 在内存模式下不支持 seek,会 panic
白名单扩展名 + 魔术字节双校验才可靠
单靠魔术字节也不够:有些格式头相似(如 TIFF 和某些 RAW 图),或极小文件无法覆盖全部签名;单靠扩展名更不行。二者必须同时满足。
- 用
filepath.Ext(file.Filename)提取后缀,查硬编码白名单(如[]string{".jpg", ".jpeg", ".png", ".pdf"}),禁用黑名单 - 校验顺序:先检查
file.Size是否在合理范围 → 再提取并校验扩展名 → 最后打开文件读魔术字节 - 大小限制必须在
file.Open()前做,否则恶意超大空文件可能触发 OOM 或 fd 耗尽 - 校验通过后再决定是否调用
c.SaveUploadedFile()或流式写入磁盘
常见漏点:multipart 解析前没校验 Content-Type
如果客户端发的是 Content-Type: application/json 却带了二进制 body,c.FormFile() 可能静默返回 nil 或 panic。必须前置检查:
- 读
c.Request.Header.Get("Content-Type"),确认以multipart/form-data开头且含boundary= - 更稳妥的做法:调用一次
c.Request.MultipartReader()并peek前几个字节,捕获格式错误 - 若接口允许纯二进制上传(如
POST /upload-body),则应弃用c.FormFile(),改用c.Request.Body并校验Content-Type为application/octet-stream等白名单类型
真正容易被忽略的是:魔术字节校验必须在打开文件后立即执行,且不能假设 reader 可 seek;而白名单扩展名必须从原始 file.Filename 提取后清洗(比如用 securejoin.SecureJoin() 防路径穿越),再比对——这两步一旦顺序错或缺一,整个校验链就断了。











