← 中文资源

简体中文版本

带路径穿越防护的 Zip 打包

遍历目录写入 Zip,拒绝逃逸根目录的条目,并用正斜杠保存相对路径,便于跨平台解压。

阅读英文原文

功能说明

服务端导出、备份和批量下载经常需要把目录打包成 Zip。直接遍历并写入文件名存在两个风险:绝对路径会泄露服务器目录结构,包含上级目录的相对路径在解压时可能写到目标目录之外。本例以源目录为根计算相对路径,发现路径等于两个点或以两个点加分隔符开头时立即返回错误,同时把分隔符统一转换为正斜杠,符合 Zip 规范。

文件元数据通过 FileInfoHeader 复制,保留修改时间和权限;压缩方法设为 Deflate。演示程序创建临时源目录,写入一个文本文件,打包到系统临时目录,再打开归档打印条目名,最后清理资源。

关键代码

WriteZip 先创建目标文件并延迟关闭,再创建 zip.Writer。WalkDir 的根被 Clean 处理,回调收到目录时跳过。filepath.Rel 计算相对路径,随后做前缀检查,避免两个点开头的结果混入归档。entry.Info 取得文件信息,zip.FileInfoHeader 生成头部,Name 改成斜杠形式,Method 设为 Deflate。CreateHeader 返回写入器,复制文件内容后显式关闭文件句柄,分别处理复制与关闭错误,避免在遍历中堆积描述符。

使用注意

压缩包内条目名必须使用正斜杠,解压端才能在各平台正确还原层级。该函数只防写入端路径穿越,解压端仍需再次校验,尤其是接收外部归档时。符号链接会被当作普通文件读取其目标内容,可能泄露根目录之外的数据,必要时应跳过非常规文件。大文件写入前应检查磁盘余量,打包过程不是原子的,失败会留下不完整归档。并发写同一个目标文件会互相覆盖。

验证方式

运行脚本,输出 archived: hello.txt,说明相对路径正确且文件已写入归档。解压该文件应得到内容 hello。可把源目录换成包含符号链接的目录,观察当前实现会读取链接目标;再添加跳过非常规文件的判断,确认链接不再进入归档。

代码示例

package main

import (
	"archive/zip"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
)

func WriteZip(sourceDir, target string) error {
	out, err := os.Create(target)
	if err != nil {
		return err
	}
	defer out.Close()

	archive := zip.NewWriter(out)
	defer archive.Close()

	root := filepath.Clean(sourceDir)
	return filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
		if walkErr != nil {
			return walkErr
		}
		if entry.IsDir() {
			return nil
		}
		relative, err := filepath.Rel(root, path)
		if err != nil {
			return err
		}
		if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
			return fmt.Errorf("refusing path outside root: %s", relative)
		}
		info, err := entry.Info()
		if err != nil {
			return err
		}
		header, err := zip.FileInfoHeader(info)
		if err != nil {
			return err
		}
		header.Name = filepath.ToSlash(relative)
		header.Method = zip.Deflate

		writer, err := archive.CreateHeader(header)
		if err != nil {
			return err
		}
		file, err := os.Open(path)
		if err != nil {
			return err
		}
		_, copyErr := io.Copy(writer, file)
		closeErr := file.Close()
		if copyErr != nil {
			return copyErr
		}
		return closeErr
	})
}

func main() {
	source, err := os.MkdirTemp("", "zip-source-")
	if err != nil {
		panic(err)
	}
	defer os.RemoveAll(source)
	if err := os.WriteFile(filepath.Join(source, "hello.txt"), []byte("hello"), 0o644); err != nil {
		panic(err)
	}

	target := filepath.Join(os.TempDir(), "devexamples-demo.zip")
	defer os.Remove(target)
	if err := WriteZip(source, target); err != nil {
		panic(err)
	}

	reader, err := zip.OpenReader(target)
	if err != nil {
		panic(err)
	}
	defer reader.Close()
	for _, file := range reader.File {
		fmt.Println("archived:", file.Name)
	}
}