直接用 gzip.NewReader 可能 panic,因其遇非 gzip 格式或损坏头时直接 panic 而非返回错误;需先检查 magic bytes(0x1f, 0x8b)确认 gzip 格式,并在解压时实时限制输出大小防内存爆炸。

为什么直接用 gzip.NewReader 可能 panic?
Go 标准库的 gzip.NewReader 在遇到非 gzip 格式或损坏头时会直接 panic,而不是返回错误。HTTP 响应体可能不是 gzip(比如服务端未按约定压缩、CDN 覆盖了 Content-Encoding、或响应是 304/204 空体),这时你还没读 body 就崩了。
安全解压的第一步不是解,而是「先确认它是 gzip」——靠检查 magic bytes,而不是信任 Content-Encoding: gzip 头(它可被伪造或误配)。
-
gzip.NewReader必须传入非空io.Reader,且内部会立即读前 10 字节校验头;若失败就 panic - 不要依赖
resp.Header.Get("Content-Encoding") == "gzip"做判断依据,它不可信 - 真正安全的做法:先用
bytes.HasPrefix(bodyBytes, []byte{0x1f, 0x8b})检查 magic header
如何避免内存爆炸式解压?
攻击者可构造极小的 gzip 文件,解压后膨胀成 GB 级数据(billion laughs 变种)。Go 的 gzip.Reader 默认不限制解压后大小,必须手动加保护。
标准做法是包装一个带限流的 io.Reader,在解压过程中实时统计已输出字节数,超限时返回 io.ErrUnexpectedEOF 或自定义错误。
- 用
io.LimitReader(gzipReader, maxDecompressedSize)不起作用——它限制的是「从 gzipReader 读出的总字节数」,但 gzipReader 内部已解压完全部内容,限流失效 - 正确方式:用
io.MultiReader+ 自定义 reader 实现「边解压边计数」,或使用gzip.NewReader后调用Read时手动累加 - 推荐组合:
gzip.NewReader(io.MultiReader(bytes.NewReader(magicCheckedBytes), ...))不行,要先截断原始 body 流再 wrap
怎么写一个带 magic 检查和 size 限制的解压函数?
核心逻辑三步:检查 magic → 构建带限流的 gzip reader → 读取并校验总量。下面是一个生产可用的简化版:
func SafeGunzip(data []byte, maxSize int64) ([]byte, error) {
if len(data) < 10 {
return nil, errors.New("gzip: data too short for header")
}
if !bytes.HasPrefix(data, []byte{0x1f, 0x8b}) {
return data, nil // not gzip, return as-is
}
gr, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("gzip.NewReader: %w", err)
}
defer gr.Close()
var buf bytes.Buffer
buf.Grow(int(min(int64(buf.Cap()), maxSize)))
// 逐块读,实时检查累计大小
_, err = io.Copy(&buf, io.LimitReader(gr, maxSize))
if err != nil && errors.Is(err, io.ErrUnexpectedEOF) {
return nil, fmt.Errorf("gzip: decompressed size exceeds limit %d", maxSize)
}
if err != nil {
return nil, fmt.Errorf("gzip read failed: %w", err)
}
return buf.Bytes(), nil
}
注意:io.LimitReader(gr, maxSize) 这里限制的是「解压后最多读多少字节」,不是原始压缩流长度——这才是防膨胀的关键。
HTTP 响应体场景下容易漏掉的细节
直接对 resp.Body 调用 SafeGunzip 会失败,因为 Body 是 stream-only,读一次就 EOF。必须先 io.ReadAll(resp.Body) 拿到完整字节,再传入解压函数。
- 别在解压前关闭
resp.Body,否则io.ReadAll会返回空 - 如果响应很大(>10MB),
io.ReadAll可能 OOM,此时应改用 streaming 解压 + 限流 writer(如io.Discard或文件写入),而非全加载进内存 - 某些代理或中间件会修改
Content-Encoding但不改 body,导致 magic 检查失败——这不是 bug,是环境现实
最麻烦的点不是代码怎么写,而是你要决定:当 magic 检查失败但 header 声称是 gzip 时,是报错、跳过解压、还是 fallback 到原 body。这个策略没法通用,得看你的 API SLA 和下游容忍度。

















