登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go 解压 tar 时怎样避免路径穿越

来源:17golang原创

时间:2026-11-14 15:35:44 375浏览 收藏

解压不可信 tar 时,最容易写错的地方不是 tar.NewReader,而是把 Header.Name 直接交给 filepath.Join,然后默认“拼接出来的路径还在目标目录里”。可靠的做法是:先用 filepath.IsLocal 拒绝非本地名称,再只接受普通文件和目录,最后把写入限制在新建的目标根目录中;符号链接和硬链接一律不要按归档内容创建。

一句话结论:Header.Name 负责描述归档成员,不是可信的文件系统路径;先验证它,再决定是否落盘,且不要让 Header.Linkname 参与写入路径。
  • 文件名层:拒绝绝对路径、空名称和包含 .. 的非本地路径。
  • 条目层:只允许普通文件、目录,拒绝符号链接、硬链接、设备文件和 FIFO。
  • 根目录层:使用专用空目录或 os.Root,再叠加单文件与总量上限。

先拆开 Header.Name、目标根目录和链接目标

我在处理上传包时,通常先把风险拆成三个字段看。Header.Name 是归档中的成员名,可能是 docs/readme.md,也可能是绝对路径或带父目录跳转的名称;Header.Linkname 是链接条目的目标,它和当前文件名是两套语义;目标根目录则是程序真正允许写入的边界。

只检查 Header.Name 不够:一个看似正常的链接条目可以把后续写入导向根目录之外。反过来,只调用 filepath.Clean 也不够,因为 Clean 只是词法整理,不能证明结果没有越过边界。下面这张结构图把三个边界放在一起,先看清谁能影响写入,再看代码。

tar 文件名、目标根目录和链接条目的静态关系说明图
图1:Header.Name、目标根目录和 Linkname 的关系说明图,不是截图或运行证据。

用 filepath.IsLocal 拒绝越界文件名

Go 的 filepath.IsLocal 是一个适合做第一道门的词法检查:它拒绝绝对路径、空路径、会走出当前子树的路径,并考虑 Windows 保留设备名。返回 true 后,filepath.Join(root, name) 才具有“仍在 root 下”的词法保证。

注意它不检查已经存在的符号链接,也不检查另一个进程是否会在检查和写入之间替换目录。因此它解决的是归档名称问题,不是完整的文件系统竞态问题。

func localTarget(root, name string) (string, error) {
	// Header.Name 来自归档,先做词法检查,再参与 Join。
	if !filepath.IsLocal(name) {
		return "", fmt.Errorf("拒绝非本地归档名称 %q", name)
	}

	// IsLocal 成功后,Join 的结果不会靠 .. 逃出 root。
	return filepath.Join(root, name), nil
}

如果运行环境已经支持较新的 archive/tar 行为,也可以把 GODEBUG=tarinsecurepath=0 作为辅助保护。此时 Reader.Next 会对非本地文件名返回 tar.ErrInsecurePath,但官方说明仍然强调:它只校验文件名,不校验链接目标。应用代码仍应显式检查错误并拒绝该条目。

只落盘普通文件和目录

解释 Reader.Next、条目类型白名单、普通文件写入和被拒绝的链接/特殊文件之间的关系。
图2:tar 条目类型白名单与拒绝边界说明图,用于理解为什么不创建链接和特殊文件。

安全解压最简单的白名单是:目录用于创建目录,普通文件用于写入,其他类型直接拒绝。TypeSymlink 与 TypeLink 都不应该进入普通写入分支;设备文件和 FIFO 也没有必要由一个上传解包器创建。

func extractTar(r io.Reader, root string, maxFileSize int64) error {
	tr := tar.NewReader(r)
	for {
		hdr, err := tr.Next()
		if errors.Is(err, io.EOF) {
			// 归档正常结束。
			return nil
		}
		if err != nil {
			return fmt.Errorf("读取 tar 头失败: %w", err)
		}
		if errors.Is(err, tar.ErrInsecurePath) || !filepath.IsLocal(hdr.Name) {
			return fmt.Errorf("拒绝不安全名称 %q", hdr.Name)
		}

		target := filepath.Join(root, hdr.Name)
		switch hdr.Typeflag {
		case tar.TypeDir:
			// 目录权限由服务统一控制,不直接信任归档中的 Mode。
			if err := os.MkdirAll(target, 0o755); err != nil {
				return fmt.Errorf("创建目录 %q 失败: %w", hdr.Name, err)
			}
		case tar.TypeReg, tar.TypeRegA:
			// 先限制声明大小,避免把超大成员完整写入磁盘。
			if hdr.Size  maxFileSize {
				return fmt.Errorf("文件 %q 超过大小上限", hdr.Name)
			}
			if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
				return fmt.Errorf("创建父目录失败: %w", err)
			}
			// O_EXCL 防止归档条目静默覆盖已存在文件。
			f, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
			if err != nil {
				return fmt.Errorf("创建文件 %q 失败: %w", hdr.Name, err)
			}
			_, copyErr := io.CopyN(f, tr, hdr.Size)
			closeErr := f.Close()
			if copyErr != nil {
				return fmt.Errorf("写入文件 %q 失败: %w", hdr.Name, copyErr)
			}
			if closeErr != nil {
				return fmt.Errorf("关闭文件 %q 失败: %w", hdr.Name, closeErr)
			}
		default:
			// 不创建链接、设备和 FIFO,避免 Linkname 改写后续写入边界。
			return fmt.Errorf("拒绝归档类型 %d: %q", hdr.Typeflag, hdr.Name)
		}
	}
}

这段代码假定 root 是本次解压新建的专用目录,并且没有不可信进程同时修改它。若目录是共享的,MkdirAll 仍可能遇到预先放置的符号链接;这时不能把词法检查当成文件系统级隔离。

把目标根目录当成真正的安全边界

在可控的临时目录中解压,最实用的做法是每个任务新建一个空目录,任务结束后整体移交或删除。这样即便归档里有重复条目,也不会覆盖宿主机已有配置。

如果目标目录会被其他用户或进程写入,Go 1.24 起可以关注 os.OpenRoot、Root.OpenFile 等根目录级 API;Go 1.25 起还提供了 Root.MkdirAll。它们让文件名解析和符号链接检查绑定在打开的根目录上。升级时仍要看目标 Go 版本的安全公告和平台限制,不能把 API 名称当成自动完成的权限沙箱。

另外,路径穿越只是解包风险的一部分。生产代码还应设置归档条目数量、单文件大小和总解压大小上限;这些限制解决资源耗尽问题,和 Header.Name 的路径边界是两条独立的防线。

发布前按这份清单收口

  1. 是否在 filepath.Join 之前检查了 filepath.IsLocal(hdr.Name)?
  2. 是否把 TypeSymlink、TypeLink、设备文件和 FIFO 排除在白名单外?
  3. 是否把解压结果写入新建的专用目录,而不是应用目录或系统目录?
  4. 是否避免信任 Header.Mode,并使用 O_EXCL 防止静默覆盖?
  5. 共享目录是否改用根目录级文件 API,并评估并发替换符号链接的风险?
  6. 是否限制条目数、单文件大小和总输出大小?

记住这条边界就够了:tar 里的名称和链接都是输入数据,不是授权。先让名称保持本地,再让条目类型保持简单,最后让文件系统操作保持在明确的根目录内,解压器才有可审计的安全边界。

常见问题

只判断字符串是否以目标目录开头可以吗?不建议。前缀比较没有路径边界概念,还会遇到大小写、分隔符和符号链接问题;至少使用 filepath.IsLocal,并把写入放进专用目录。

为什么不直接把符号链接还原出来?因为链接目标来自归档作者,恢复它会把后续写入交给链接解析。除非你有专门的文件系统隔离和链接策略,否则解压服务应直接拒绝链接条目。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>