安全解压zip需三步:先校验结构有效性,再过滤危险路径(如用filepath.clean并检查".."或"/"开头),最后限制解压范围(用abs路径+strings.hasprefix二次校验);直接保存再解压会落地不可信数据,应内存中完成头校验和条目扫描。

直接解压用户上传的 ZIP 包而不做校验,等于给攻击者留了一扇后门——路径遍历、空文件、恶意文件名、超深嵌套目录都能触发 panic 或写入任意路径。安全解压不是“调用 archive/zip 就完事”,关键在三步:校验 ZIP 结构有效性、过滤危险路径、限制解压范围。
为什么不能直接用 c.SaveUploadedFile 后再解压
上传的 ZIP 文件可能根本不是 ZIP(比如改后缀的恶意二进制),也可能损坏、加密或含非法字符路径。如果先保存再解压,等于把不可信数据落地到磁盘,后续校验失败时已造成污染或浪费空间。
- 必须在内存中完成 ZIP 头校验和条目扫描,确认可解压后再决定是否落地
-
c.FormFile("zip")返回的是*multipart.FileHeader,它只包含元信息;真正读取内容需调用file.Open()获取multipart.File流 - 若用
c.SaveUploadedFile保存后再os.Open,就多一次磁盘 IO,且无法拦截损坏包
如何用 archive/zip 安全校验 ZIP 内容而不 panic
Go 标准库的 archive/zip 对损坏 ZIP 处理较粗暴——zip.NewReader 遇到 CRC 错误或格式异常会直接 panic。必须包裹在 recover 中,或更稳妥地:用 io.LimitReader 控制读取上限 + 检查每个 zip.FileHeader 的 Name 和 UncompressedSize64。
- 打开前限制读取长度,防止超大 ZIP 耗尽内存:
limitedReader := io.LimitReader(file, 10(10MB 上限) - 用
zip.NewReader(limitedReader, size)初始化,size 传实际读取字节数,避免stat不准引发 panic - 遍历
zr.File时,对每个fh做:strings.Contains(fh.Name, "..")、filepath.IsAbs(fh.Name)、len(fh.Name) > 255 - 跳过目录项(
fh.IsDir())或空文件(fh.UncompressedSize64 == 0),避免创建无意义路径
解压时如何防止路径穿越写入系统目录
ZIP 中的文件名如 ../../../etc/passwd 会被 filepath.Join(dst, fh.Name) 拼接成越权路径。不能依赖字符串替换,必须用 filepath.Clean + 显式校验前缀。
- 对每个
fh.Name执行cleanName := filepath.Clean(fh.Name) - 检查
cleanName是否以"."开头,或包含".."(filepath.Clean会转成"..",所以仍需检查) - 强制要求 cleanName 必须是相对路径且不越界:
!strings.HasPrefix(cleanName, "..") && !filepath.IsAbs(cleanName) - 最终拼接用
dstPath := filepath.Join(uploadDir, cleanName),再确保uploadDir是绝对路径且dstPath仍落在其子树内(用strings.HasPrefix(dstPath, uploadDir))
解压后如何安全返回文件列表供前端预览
前端常需要 ZIP 内部文件清单做预览或选择。但直接返回原始 fh.Name 可能含控制字符或 XSS 危险字符串;返回完整路径又暴露服务端结构。
- 只返回清洗后的基础名:
filepath.Base(fh.Name),丢弃路径层级 - 过滤掉非显示字符(如
\x00-\x08,\x0e-\x1f),避免前端渲染异常 - 对文件大小做单位转换(
humanize.Bytes(int64(fh.UncompressedSize64))),但不要暴露原始字节数以防侧信道 - 若需支持中文名,确保 ZIP 使用 UTF-8 编码(Go 默认支持),但避免依赖
fh.Comment(常为空或乱码)
最易被忽略的一点:ZIP 解压本身不校验文件内容完整性。即使路径安全、结构合法,内部文件仍可能是木马。生产环境必须搭配后续步骤——比如对解压出的图片做 image.DecodeConfig 校验,或对文本类文件做 MIME 类型 sniff,而不是仅靠扩展名判断。











