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
对象。一种replace
方法,类似于dataclasses.replace()
或namedtuple._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
对象的副本,其属性将根据仅限关键字的参数进行替换。
name
mtime
mode
linkname
uid
gid
uname
gname
除了 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
一个新的异常 FilterError
将添加到 tarfile
模块中。它将有几个新的子类,每个子类对应于上述拒绝原因之一。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
2 个版本(Python 的向后兼容性策略允许的最短弃用期限)后更改。
此外,依赖于tarfile.TarInfo
对象标识的代码可能会中断,请参阅TarInfo 标识和偏移量。
向后移植和向前兼容性
此功能可能会移植到旧版本的 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 上的拉取请求 #102953。
被拒绝的想法
SafeTarFile
Lars Gustäbel 的最初想法是提供一个单独的类来实现安全检查(请参阅gh-65308)。这种方法有两个主要问题
- 名称具有误导性。如果没有影响合法用例,则无法使一般的档案操作对所有类型的意外行为“安全”。
- 它没有解决不安全默认值的问题。
但是,SafeTarFile 背后的许多想法都在本 PEP 中得到了重复使用。
向 tarfile 添加 absolute_path 选项
问题 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
上次修改时间:2023-09-09 17:39:29 GMT