当前位置:首页 > 文章列表 > Golang > Go问答 > Go 解压 tar 时怎样避免路径穿越

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

来源:17golang原创 2026-11-14 15:35:44 0浏览 收藏

解压不可信 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删除
Go binary.ByteOrder.Uint32 的短切片保护与定长读取方案Go binary.ByteOrder.Uint32 的短切片保护与定长读取方案
上一篇
Go binary.ByteOrder.Uint32 的短切片保护与定长读取方案
Go base64.Encoding.WithPadding 怎样控制编码中的填充字符
下一篇
Go base64.Encoding.WithPadding 怎样控制编码中的填充字符
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    1021次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    1025次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    1162次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    1129次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    967次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议 和 隐私政策
返回登录
  • 重置密码