PEP 706 – tarfile.extractall 过滤器
- 作者:
- Petr Viktorin <encukou at gmail.com>
- 讨论至:
- Discourse 帖子
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 创建日期:
- 2023年2月9日
- Python 版本:
- 3.12
- 发布历史:
- 2023年1月25日, 2023年2月15日
- 决议:
- Discourse 消息
摘要
tarfile 中的解压方法将获得一个 filter 参数,该参数允许在解压存档时拒绝文件或修改元数据。提供了三个内置的命名过滤器,旨在限制可能令人惊讶或危险的功能。这些过滤器可以按原样使用,也可以作为自定义过滤器的基础。
经过一段弃用期后,一个严格(但更安全)的过滤器将成为默认值。
动机
tar 格式用于多种用例,其中许多用例有不同的需求。例如:
- UNIX 工作站的备份应忠实地保留所有类型的详细信息,如文件权限、指向系统配置的符号链接以及各种特殊文件。
- 解包数据包时,解包不会产生意外后果(例如通过将密码文件符号链接到公共位置来暴露密码文件)更为重要。
为了支持所有用例,tar 格式具有许多功能。在许多情况下,解压存档时最好忽略或禁用其中一些功能。
Python 允许使用 tarfile.TarFile.extractall() 解压 tar 存档,其文档警告 切勿在未经事先检查的情况下从不受信任的来源解压存档。但是,目前尚不清楚应该进行何种检查。事实上,正确进行这种检查非常棘手。因此,许多人要么不理会,要么检查不正确,导致了安全问题,例如 CVE-2007-4559。
自 tarfile 首次编写以来,文档中的警告不足以被普遍接受。在可能的情况下,应该 明确请求 不安全操作;潜在危险的操作应该 看起来 危险。然而,TarFile.extractall 在代码审查中看起来是无害的。
Tarfile 解压也通过 shutil.unpack_archive() 暴露,这允许用户不必关心他们正在处理的存档类型。该 API 非常诱人,可以在未经事先检查的情况下解压存档,尽管文档再次警告不要这样做。
有人认为 Python 没错——它的行为与文档完全一致——但这与要点无关。我们应该改进情况,而不是推卸/避免责任。Python 及其文档是改进事物的最佳场所。
基本原理
我们如何改进事物?不幸的是,我们将需要更改默认值,这意味着破坏向后兼容性。TarFile.extractall 是人们需要解压 tarball 时会用到的。它的默认行为需要改变。
什么是最佳行为?这取决于用例。因此,我们将添加几个通用的“策略”来控制解压。它们基于 用例,理想情况下它们应该具有直接的安全含义:
- 当前行为:信任存档。例如,适合作为库的构建块,这些库自己进行检查,或者解压您刚刚自己创建的存档。
- 解压 UNIX 存档:大致遵循 GNU
tar,例如从文件名中剥离前导/。 - 解压通用数据存档:
shutil.unpack_archive()的用例,其中保留特定于tar或类 Unix 文件系统的详细信息并不重要。
经过一段弃用期后,最后一个选项——最受限但最安全的一个——将成为默认值。
即使有了更好的通用默认值,用户仍应验证他们解压的存档,并可能修改一些元数据。表面上看,以下是目前实现此目的的合理方法:
- 调用
TarFile.getmembers - 验证或修改每个成员的
TarInfo - 将结果传递给
extractall的members
但是,这种方法存在一些问题:
- 可以修改
TarInfo对象,但对其的更改会影响同一TarFile对象上的所有后续操作。此行为对于大多数用途来说都很好,但尽管如此,如果TarFile.extractall默认这样做,那将是非常令人惊讶的。 - 调用
getmembers可能很昂贵,并且 需要可寻址的存档。 - 在提前验证成员时,可能需要跟踪每个成员如何更改文件系统,例如符号链接的设置方式。这很难。我们不能指望用户来做。
为了解决这些问题,我们将:
- 提供一种支持的“克隆”和修改
TarInfo对象的方法。类似于dataclasses.replace()或namedtuple._replace的replace方法应该可以解决问题。 - 在
extractall的循环中提供一个“过滤器”钩子,可以在处理成员之前修改或丢弃成员。 - 要求此钩子在提取每个成员之前调用,以便它可以扫描磁盘的 当前 状态。这将大大简化策略的实现(在标准库和用户代码中),代价是无法进行精确的“空运行”。
钩子 API 将与 TarFile.add 现有的 filter 参数非常相似。我们也将它命名为 filter。(在某些情况下,“策略”可能是更合适的名称,但该 API 可以用于安全策略之外的更多用途。)
上面描述的内置策略/过滤器将使用公共过滤器 API 实现,因此它们可以用作构建块或示例。
开创先例
如果其他存档提取库(例如 zipfile)获得类似功能,它们应该尽可能模仿此 API。
为了简化用例,内置过滤器将具有字符串名称;例如,用户可以传递 filter='data' 而不是处理 TarInfo 对象的特定函数。
shutil.unpack_archive() 函数将获得一个 filter 参数,它将传递给 extractall。
添加适用于各种存档格式的基于函数的 API 超出了本 PEP 的范围。
完整披露与分销商信息
PEP 作者为 Red Hat 工作,Red Hat 是 Python 的一个分销商,与 CPython 通常具有不同的安全需求和支持周期。此类分销商可能希望进行供应商补丁以:
- 允许在系统范围内配置默认值,并且
- 尽快更改默认值,即使在较旧的 Python 版本中也是如此。
该提案使这变得容易,并允许用户查询设置。
规范
修改和遗忘成员元数据
TarInfo 类将获得一个新的方法 replace(),它的工作方式与 dataclasses.replace 类似。它将返回 TarInfo 对象的副本,其中属性根据仅关键字参数指定进行替换:
名称mtime众数linknameuidgidunamegname
除了 name 和 linkname 之外,任何这些参数都将被允许设置为 None。当 extract 或 extractall 遇到这样的 None 时,它将不会设置该元数据。(如果 uname 或 gname 为 None,它将回退到 uid 或 gid,就像未找到名称一样。)当 addfile 或 tobuf 遇到这样的 None 时,它将引发 ValueError。当 list 遇到这样的 None 时,它将打印一个占位符字符串。
文档将提及该方法存在的原因:从 TarFile.getmembers 检索的 TarInfo 对象是“活动”的;直接修改它们会影响后续不相关的操作。
过滤器
TarFile.extract 和 TarFile.extractall 方法将新增一个 filter 仅关键字参数,该参数接受一个可调用对象,可以按如下方式调用:
filter(/, member: TarInfo, path: str) -> TarInfo|None
其中 member 是要提取的成员,path 是存档提取到的路径(即,对每个成员都相同)。
使用时,它将在每个成员被提取时调用,并且提取将根据结果进行。如果它返回 None,则该成员将被跳过。
该函数也可以引发异常。这取决于 TarFile.errorlevel,可以中止提取或导致该成员被跳过。
注意
如果提取中止,存档可能会部分提取。用户有责任进行清理。
我们还将为常见用例提供一组默认值。除了函数之外,filter 参数可以是以下字符串之一:
'fully_trusted':当前行为:按原样尊重元数据。如果用户完全信任存档,或实现自己的复杂验证,则应使用此选项。'tar':大致遵循 GNUtar命令的默认值(作为普通用户运行时)- 从文件名中删除前导
'/'和os.sep - 拒绝提取具有绝对路径的文件(在上述
/剥离之后,例如 Windows 上的C:/foo)。 - 拒绝提取其绝对路径(在跟踪符号链接之后)将超出目标的文件。(请注意,GNU
tar而是延迟创建一些链接。) - 清除高模式位(setuid、setgid、sticky)和组/其他写入位(
S_IWGRP|S_IWOTH)。(这是 GNUtar默认值的一个近似值,该默认值通过当前的umask设置来限制模式。)
- 从文件名中删除前导
'data':提取“数据”存档,禁止常见攻击向量但限制功能。特别是,许多特定于 UNIX 风格文件系统(或等效地,特定于tar存档格式)的功能被忽略,这使其成为跨平台存档的良好过滤器。除了tar- 拒绝提取链接(硬链接或软链接),这些链接链接到绝对路径。
- 拒绝提取最终链接到目标外部路径的链接(硬链接或软链接)。(在不支持链接的系统上,
tarfile在大多数情况下会回退到创建常规文件。此提案不更改该行为。) - 拒绝提取设备文件(包括管道)。
- 对于常规文件和硬链接
- 设置所有者读写权限(
S_IRUSR|S_IWUSR)。 - 如果所有者没有执行权限(
S_IXUSR),则移除组和其他 可执行 权限(S_IXGRP|S_IXOTH)。
- 设置所有者读写权限(
- 对于其他文件(目录),完全忽略模式(将其设置为
None)。 - 忽略用户和组信息(将
uid、gid、uname、gname设置为None)。
任何其他字符串都将导致 ValueError。
相应的过滤函数将作为 tarfile.fully_trusted_filter()、tarfile.tar_filter() 等可用,因此它们可以很容易地用于自定义策略。
请注意,这些过滤器从不返回 None。以这种方式跳过成员是用户定义过滤器的功能。
默认值及其配置
TarFile 将获得一个新的属性 extraction_filter,以允许配置默认过滤器。默认情况下,它将是 None,但如果 filter 参数缺失或为 None,用户可以将其设置为将被调用的可调用对象。
注意
此处不接受字符串名称。那样会鼓励诸如 my_tarfile.extraction_filter = 'data' 这样的代码。在没有此功能的 Python 版本上,这将什么也不做,默默地忽略一个与安全相关的请求。
如果参数和属性都为 None
- 在 Python 3.12-3.13 中,将发出
DeprecationWarning,并且提取将使用'fully_trusted'过滤器。 - 在 Python 3.14+ 中,它将使用
'data'过滤器。
应用程序和系统集成商可能希望更改 TarFile 类本身的 extraction_filter 以设置全局默认值。当使用函数时,他们通常会希望将其包装在 staticmethod() 中,以防止注入 self 参数。
TarFile 的子类也可以覆盖 extraction_filter。
FilterError
tarfile 模块中将添加一个新异常 FilterError。它将有几个新的子类,每个子类对应一个上述拒绝原因。FilterError 的 member 属性将包含相关的 TarInfo。
在上述列表中,“拒绝”提取文件意味着将引发 FilterError。与其他提取错误一样,如果 TarFile.errorlevel 大于或等于 1,这将中止提取;如果 errorlevel=0,错误将被记录并且成员将被忽略,但提取将继续。请注意,extractall() 可能会留下部分提取的存档;用户有责任进行清理。
错误级别,以及致命/非致命错误
目前,TarFile 有一个 errorlevel 参数/属性,它指定如何处理错误:
- 当
errorlevel=0时,文档指出“使用extract()和extractall()时,所有错误都会被忽略”。代码只忽略 非致命 和 致命 错误(见下文),因此,例如,如果您将None作为目标路径传递,您仍然会收到TypeError。 - 当
errorlevel=1(默认值)时,所有 非致命 错误都会被忽略。(通过设置 debug 参数/属性,它们可能会被记录到sys.stderr。)文档中没有定义哪些错误是 非致命 的,但代码将ExtractionError视为此类。具体来说,是以下问题:- “无法解析存档内的链接”(在不支持符号链接的系统上引发)
- “系统不支持 fifo/特殊设备”(如果系统支持这些设备,例如对于
PermissionError,则不用于故障) - “无法更改所有者/模式/修改时间”
请注意,例如,文件名过长 或 磁盘空间不足 不符合条件。非致命 错误在类 Unix 系统上不太可能出现。
- 当
errorlevel=2时,所有错误都会被引发,包括 致命 错误。哪些错误是 致命 的,再次没有定义;实际上是OSError。
过滤器拒绝提取成员不完全符合 致命/非致命 类别。
- 本 PEP 不改变现有行为。(欢迎在 Discourse 主题 25970 中提出改进意见。)
- 当过滤器拒绝提取成员时,错误默认不应静默通过。
为了满足此要求,FilterError 将被视为 致命 错误,也就是说,它只会在 errorlevel=0 时被忽略。
希望忽略 FilterError 但不忽略其他 致命 错误的用户应创建自定义过滤器函数,并在 try 块中调用另一个过滤器。
进一步验证的提示
即使进行了拟议的更改,tarfile 仍不适合在未经事先检查的情况下提取不受信任的文件。除其他问题外,拟议的策略不能阻止拒绝服务攻击。用户应进行额外检查。
新文档将告诉用户考虑
- 提取到一个新的空目录,
- 对磁盘、内存和 CPU 使用施加外部(例如,操作系统级别)限制,
- 根据允许的字符列表检查文件名(以过滤掉控制字符、易混淆字符等),
- 检查文件名是否具有预期的扩展名(不鼓励在“点击”时执行的文件,或没有扩展名的文件,如 Windows 特殊设备名称),
- 限制提取文件的数量、提取数据的总大小以及单个文件的大小,
- 检查在不区分大小写的文件系统上将被遮蔽的文件。
此外,文档将指出:
- tar 文件通常包含同一文件的多个版本:提取时,后来的版本预计会覆盖较早的版本,
tarfile不会防范“实时”数据的问题,例如,攻击者在提取(或添加)过程中篡改目标目录(有关更多信息,请参阅 GNU tar 手册)。
此列表并非详尽无遗,但文档是收集此类一般技巧的好地方。如果它变得太长或需要与 zipfile 或 shutil 合并(超出本提案范围),则可以将其移至单独的文档。
TarInfo 身份和 offset
使用 replace() 的过滤器,提取机制处理的 TarInfo 对象不一定与 members 中存在的对象相同。这可能会影响覆盖 makelink 等方法并依赖对象身份的 TarInfo 子类。
此类代码可以切换到比较 offset,即成员头在文件中的位置。
请注意,可重写方法和 offset 都仅在源代码注释中有所记载。
tarfile CLI
CLI (python -m tarfile) 将获得一个 --filter 选项,该选项将接受所提供默认过滤器之一的名称。无法指定自定义过滤器函数。
如果未提供 --filter,CLI 将使用默认过滤器(目前为 'fully_trusted',并附带弃用警告;从 Python 3.14 开始为 'data')。
不会有短选项。(-f 会与 GNU tar 的文件名选项令人困惑地相似。)
其他存档库
如果其他归档库(例如 zipfile)获得类似功能,其解压函数应使用 filter 参数,该参数至少接受字符串 'fully_trusted'(应禁用任何安全预防措施)和 'data'(应避免可能让用户感到惊讶的功能)。
标准化基于函数的过滤器 API 超出了本 PEP 的范围。
Shutil
shutil.unpack_archive() 将获得一个 filter 参数。如果给定,它将传递给底层提取函数。目前,将其传递给 zip 存档将失败(直到 zipfile 获得 filter 参数,如果它真的获得的话)。
如果未指定 filter(或将其保留为 None),则它不会被传递,因此解压 tarball 将使用默认过滤器(现在是 'fully_trusted' 附带弃用警告,从 Python 3.14 开始是 'data')。
复杂过滤器
请注意,一些用户定义的过滤器需要,例如,计算提取的成员或进行后处理。这需要比 filter 可调用对象更复杂的 API。但是,这种复杂的 API 不需要暴露给 tarfile。例如,使用假设的 StatefulFilter,用户将编写:
with StatefulFilter() as filter_func:
my_tar.extract(path, filter=filter_func)
一个简单的 StatefulFilter 示例将添加到文档中。
注意
需要有状态过滤器是反对允许注册自定义过滤器名称(除了 'fully_trusted'、'tar' 和 'data')的原因之一。有了这样的机制,(至少)设置和拆卸的 API 将需要固定下来。
向后兼容性
TarFile.extract 和 TarFile.extractall 的默认行为将更改,在发出 DeprecationWarning 两个版本(Python 向后兼容性策略 中允许的最短弃用期)之后。
此外,依赖 tarfile.TarInfo 对象身份的代码可能会中断,请参阅 TarInfo 身份和 offset。
向后移植和向前兼容性
此功能可能会向后移植到较旧的 Python 版本。
在 CPython 中,我们不在补丁发布中添加警告,因此默认过滤器应在向后移植中更改为 'fully_trusted'。
除此之外,tarfile 的 所有 更改都应该向后移植,因此 hasattr(tarfile, 'data_filter') 成为所有新功能可靠的检查。
请注意,CPython 的通常策略是避免在安全向后移植中添加新的 API。此功能在没有新的 API(TarFile.extraction_filter 和 filter 参数)的情况下没有意义,因此我们将破例。(有关详细信息,请参阅 Discourse 评论 23149/16。)
以下是考虑 tarfile 是否具有所提议功能的代码示例。
复制这些代码片段时,请注意设置 extraction_filter 将影响后续操作。
- 完全受信任的存档
my_tarfile.extraction_filter = (lambda member, path: member) my_tarfile.extractall()
- 如果可用,使用
'data'过滤器,但如果此功能不可用,则恢复到 Python 3.11 行为 ('fully_trusted')my_tarfile.extraction_filter = getattr(tarfile, 'data_filter', (lambda member, path: member)) my_tarfile.extractall()
(这是一个不安全的操作,因此应明确说明,最好附带注释。)
- 使用
'data'过滤器;如果不可用则 失败my_tarfile.extractall(filter=tarfile.data_filter)
或
my_tarfile.extraction_filter = tarfile.data_filter my_tarfile.extractall()
- 使用
'data'过滤器;如果不可用则 警告if hasattr(tarfile, 'data_filter'): my_tarfile.extractall(filter='data') else: # remove this when no longer needed warn_the_user('Extracting may be unsafe; consider updating Python') my_tarfile.extractall()
安全隐患
该提案以牺牲向后兼容性为代价提高了安全性。特别是,它将帮助用户避免 CVE-2007-4559。
如何教授此内容
API、使用说明和进一步验证的提示将添加到文档中。这些应该可供熟悉一般存档但对 UNIX 文件系统特性及相关安全问题不熟悉的用户使用。
参考实现
参见 GitHub 上的 pull request #102953。
被拒绝的想法
SafeTarFile
Lars Gustäbel 最初的想法是提供一个单独的类来实现安全检查(参见 gh-65308)。这种方法存在两个主要问题:
- 这个名字具有误导性。一般的存档操作永远不可能完全“安全”,免受所有不必要的行为,而不影响合法用例。
- 它没有解决不安全默认值的问题。
然而,SafeTarFile 背后的许多想法在本 PEP 中得到了重用。
向 tarfile 添加 absolute_path 选项
Issue gh-73974 要求在解压方法中添加 absolute_path 选项。这将是正式解决 CVE-2007-4559 的最小更改。它不足以保护不知情的人,也无法赋能勤奋和好奇的人。
'tar' 过滤器的其他名称
'tar' 过滤器暴露了类 UNIX 文件系统特有的功能,因此它可以命名为 'unix'。或者 'unix-like'、'nix'、'*nix'、'posix'?
从功能上讲,tar 格式 和 类 UNIX 文件系统 本质上是等效的,因此 tar 是一个好名字。
可能需要进一步的工作
向 zipfile 和 shutil.unpack_archive 添加过滤器
为了一致性,zipfile 和 shutil.unpack_archive() 可以获得对 filter 参数的支持。然而,这需要本 PEP 作者无法承诺在 Python 3.12 中进行的研究。
zipfile 的过滤器可能对安全没有帮助。Zip 主要用于跨平台数据包,因此,ZipFile.extract 的默认值已经类似于 'data' 过滤器所做的事情。'fully_trusted' 过滤器会 新允许 绝对路径和 .. 路径组件,除了统一的 unpack_archive API 之外,可能没有太多用处。
过滤器应该对安全以外的用例有用,但这些通常需要自定义过滤器函数,并且需要适用于 TarInfo 和 ZipInfo 的 API。这 绝对 超出了本 PEP 的范围。
如果只实现了本 PEP 而 zipfile 没有变化,那么 unpack_archive 调用者的影响是 tar 文件的默认值从 'fully_trusted' 更改为更合适的 'data'。在此过渡期内,Python 3.12-3.13 将发出 DeprecationWarning。这很烦人,但有几种处理方法:例如,有条件地添加 filter 参数,全局设置 TarFile.extraction_filter,或忽略/抑制警告直到 Python 3.14。
此外,由于许多对 unpack_archive 的调用可能不安全,因此希望 DeprecationWarning 经常会成为审查受影响代码的有用提示。
致谢
本提案基于许多人的前期工作和讨论,特别是 Lars Gustäbel、Gregory P. Smith、Larry Hastings、Joachim Wagner、Jan Matejek、Jakub Wilk、Daniel Garcia、Lumír Balhar、Miro Hrončok 等。
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0706.rst