From b38a84b9f9708773d5389840336fa6049e982af9 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith" <68491+gpshead@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:24:35 -0800 Subject: [PATCH] gh-161323: Refuse drive-relative paths in tarfile extraction filters (GH-161334) * gh-161323: Refuse drive-relative paths in tarfile extraction filters On Windows, os.path.join() does not put a name such as 'C:foo' below a destination that has no drive, so the 'tar' and 'data' filters now refuse names and link targets with a drive, after normalization. Fixes CVE-2026-107784. reported by Girish Konda ANT-2026-RSL1F4AT (cherry picked from commit e4c751ba63d41660332db162ff7836e933631858) Co-authored-by: Gregory P. Smith <68491+gpshead@users.noreply.github.com> Co-authored-by: Petr Viktorin --- Doc/library/tarfile.rst | 20 ++++++--- Lib/tarfile.py | 13 +++--- Lib/test/test_tarfile.py | 42 +++++++++++++++++++ ...-10-22-08-07-52.gh-issue-161323.q7Drv2.rst | 5 +++ 4 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 Misc/NEWS.d/next/Security/2026-10-22-08-07-52.gh-issue-161323.q7Drv2.rst diff --git a/Doc/library/tarfile.rst b/Doc/library/tarfile.rst index d123468d8f51b9f..fb550e27778908a 100644 --- a/Doc/library/tarfile.rst +++ b/Doc/library/tarfile.rst @@ -1096,14 +1096,15 @@ reused in custom filters: Implements the ``'tar'`` filter. - Strip leading slashes (``/`` and :data:`os.sep`) from filenames. - - :ref:`Refuse ` to extract files with absolute - paths (in case the name is absolute - even after stripping slashes, e.g. ``C:/foo`` on Windows). - This raises :class:`~tarfile.AbsolutePathError`. - Normalize filenames (:attr:`TarInfo.name`) that contain ``..`` components using :func:`os.path.normpath`. Note that this removes internal ``..`` components, which may change the meaning of the name if it traverses symbolic links. + - :ref:`Refuse ` to extract files with absolute + paths (in case the name is absolute + even after stripping slashes, e.g. ``C:/foo`` on Windows) + or with a drive (e.g. ``C:foo`` on Windows). + This raises :class:`~tarfile.AbsolutePathError`. - :ref:`Refuse ` to extract files whose absolute path (after following symlinks) would end up outside the destination. This raises :class:`~tarfile.OutsideDestinationError`. @@ -1121,6 +1122,10 @@ reused in custom filters: Filenames containing ``..`` components are now normalized. + .. versionchanged:: next + + Filenames with a drive that are not absolute are now refused. + .. function:: data_filter(member, path) Implements the ``'data'`` filter. @@ -1133,7 +1138,8 @@ reused in custom filters: symbolic links. - :ref:`Refuse ` to extract links (hard or soft) - that link to absolute paths, or ones that link outside the destination. + that link to absolute paths or to paths with a drive, + or ones that link outside the destination. This raises :class:`~tarfile.AbsoluteLinkError` or :class:`~tarfile.LinkOutsideDestinationError`. @@ -1167,6 +1173,10 @@ reused in custom filters: Link targets are now normalized. + .. versionchanged:: next + + Link targets with a drive that are not absolute are now refused. + .. _tarfile-extraction-refuse: diff --git a/Lib/tarfile.py b/Lib/tarfile.py index beb894fb39e8aad..3a2673d7f112c79 100644 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -819,10 +819,6 @@ def _get_filtered_attrs(member, dest_path, for_data=True): # Include os.sep (target OS directory separator) as well. if name.startswith(('/', os.sep)): name = new_attrs['name'] = member.path.lstrip('/' + os.sep) - if os.path.isabs(name): - # Path is absolute even after stripping. - # For example, 'C:/foo' on Windows. - raise AbsolutePathError(member) # Ensure we stay in the destination if '..' in name.replace(os.sep, '/').split('/'): # Directories are created from the name as given, so a name that @@ -831,6 +827,11 @@ def _get_filtered_attrs(member, dest_path, for_data=True): normalized = os.path.normpath(name) if normalized != name: name = new_attrs['name'] = normalized + if os.path.isabs(name) or os.path.splitdrive(name)[0]: + # Path is absolute even after stripping, or has a drive. + # For example, 'C:/foo' or 'C:foo' on Windows. + # os.path.join() ignores a destination without a drive for 'C:foo'. + raise AbsolutePathError(member) target_path = os.path.realpath(os.path.join(dest_path, name), strict=os.path.ALLOW_MISSING) if os.path.commonpath([target_path, dest_path]) != dest_path: @@ -874,11 +875,11 @@ def _get_filtered_attrs(member, dest_path, for_data=True): new_attrs['gname'] = None # Check link destination for 'data' if member.islnk() or member.issym(): - if os.path.isabs(member.linkname): - raise AbsoluteLinkError(member) normalized = os.path.normpath(member.linkname) if normalized != member.linkname: new_attrs['linkname'] = normalized + if os.path.isabs(normalized) or os.path.splitdrive(normalized)[0]: + raise AbsoluteLinkError(member) if member.issym(): # The symlink is created at `name` with trailing separators # stripped, so its target is relative to the directory diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py index 5c6b94e080d0fc1..51785ba73341efd 100644 --- a/Lib/test/test_tarfile.py +++ b/Lib/test/test_tarfile.py @@ -2,6 +2,7 @@ import sys import os import io +import ntpath from hashlib import sha256 from contextlib import contextmanager, ExitStack from random import Random @@ -4014,6 +4015,47 @@ def test_absolute(self): tarfile.AbsolutePathError, """['"].*escaped.evil['"] has an absolute path""") + def test_drive_relative(self): + # Test names and link targets that have a drive but no root. + # They are not absolute, but os.path.join() does not put them + # below a destination that has no drive or another drive. + # Windows path handling is used on all platforms. + dest = r'C:\outerdir\dest' + with support.swap_attr(os, 'path', ntpath): + for name in 'C:escaped.evil', 'x/../C:escaped.evil': + for filter in tarfile.tar_filter, tarfile.data_filter: + with self.subTest(name=name, filter=filter.__name__): + with self.assertRaises(tarfile.AbsolutePathError): + filter(tarfile.TarInfo(name), dest) + + for linkname in 'C:escaped.evil', 'x/../C:escaped.evil', './C:evil': + for type in tarfile.SYMTYPE, tarfile.LNKTYPE: + with self.subTest(linkname=linkname, type=type): + member = tarfile.TarInfo('link') + member.type = type + member.linkname = linkname + with self.assertRaises(tarfile.AbsoluteLinkError): + tarfile.data_filter(member, dest) + + member = tarfile.TarInfo('x/../C/benign.txt') + member = tarfile.data_filter(member, dest) + self.assertEqual(member.name, r'C\benign.txt') + + @unittest.skipUnless(os.name == 'nt', 'requires Windows drives') + def test_drive_relative_to_cwd(self): + # Test a name like 'C:escaped.evil' with a destination that has no + # drive. The file must not be created in the current directory. + with os_helper.temp_cwd() as cwd: + drive = os.path.splitdrive(cwd)[0] + with ArchiveMaker() as arc: + arc.add(f'{drive}escaped.evil') + + for filter in 'tar', 'data': + with self.subTest(filter=filter), arc.open() as tar: + with self.assertRaises(tarfile.AbsolutePathError): + tar.extractall('dest', filter=filter) + self.assertFalse(os.path.lexists('escaped.evil')) + def test_parent_dir_out_and_back(self): # Test a member that leaves the destination and comes back. # The containment check looks at the resolved path, which stays diff --git a/Misc/NEWS.d/next/Security/2026-10-22-08-07-52.gh-issue-161323.q7Drv2.rst b/Misc/NEWS.d/next/Security/2026-10-22-08-07-52.gh-issue-161323.q7Drv2.rst new file mode 100644 index 000000000000000..95949e93576c6cb --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-10-22-08-07-52.gh-issue-161323.q7Drv2.rst @@ -0,0 +1,5 @@ +On Windows, the ``'tar'`` and ``'data'`` :mod:`tarfile` extraction filters +now refuse member names and link targets that have a drive but are not +absolute, such as ``C:foo``, with :exc:`~tarfile.AbsolutePathError` or +:exc:`~tarfile.AbsoluteLinkError`. Such members could be extracted outside +the destination directory. Fixes :cve:`2026-107784`.