Skip to content

Commit c063191

Browse files
[3.10] gh-149486: tarfile.data_filter: validate written link target (GH-149487) (#153180)
1 parent 938ec03 commit c063191

3 files changed

Lines changed: 129 additions & 39 deletions

File tree

Lib/tarfile.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -816,16 +816,22 @@ def _get_filtered_attrs(member, dest_path, for_data=True):
816816
if member.islnk() or member.issym():
817817
if os.path.isabs(member.linkname):
818818
raise AbsoluteLinkError(member)
819+
# A link member that resolves to the destination directory itself
820+
# would replace it with a (sym)link, redirecting the destination
821+
# for all subsequent members.
822+
if target_path == dest_path:
823+
raise OutsideDestinationError(member, target_path)
819824
normalized = os.path.normpath(member.linkname)
820825
if normalized != member.linkname:
821826
new_attrs['linkname'] = normalized
822827
if member.issym():
823-
target_path = os.path.join(dest_path,
824-
os.path.dirname(name),
825-
member.linkname)
828+
# The symlink is created at `name` with trailing separators
829+
# stripped, so its target is relative to the directory
830+
# containing that path.
831+
link_dir = os.path.dirname(name.rstrip('/' + os.sep))
832+
target_path = os.path.join(dest_path, link_dir, normalized)
826833
else:
827-
target_path = os.path.join(dest_path,
828-
member.linkname)
834+
target_path = os.path.join(dest_path, normalized)
829835
target_path = os.path.realpath(target_path,
830836
strict=os.path.ALLOW_MISSING)
831837
if os.path.commonpath([target_path, dest_path]) != dest_path:

Lib/test/test_tarfile.py

Lines changed: 113 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3357,6 +3357,39 @@ class TestExtractionFilters(unittest.TestCase):
33573357
# The destination for the extraction, within `outerdir`
33583358
destdir = outerdir / 'dest'
33593359

3360+
@classmethod
3361+
def setUpClass(cls):
3362+
# Posix and Windows have different pathname resolution:
3363+
# either symlink or a '..' component resolve first.
3364+
# Let's see which we are on.
3365+
if os_helper.can_symlink():
3366+
testpath = os.path.join(TEMPDIR, 'resolution_test')
3367+
os.mkdir(testpath)
3368+
3369+
# testpath/current links to `.` which is all of:
3370+
# - `testpath`
3371+
# - `testpath/current`
3372+
# - `testpath/current/current`
3373+
# - etc.
3374+
os.symlink('.', os.path.join(testpath, 'current'))
3375+
3376+
# we'll test where `testpath/current/../file` ends up
3377+
with open(os.path.join(testpath, 'current', '..', 'file'), 'w'):
3378+
pass
3379+
3380+
if os.path.exists(os.path.join(testpath, 'file')):
3381+
# Windows collapses 'current\..' to '.' first, leaving
3382+
# 'testpath\file'
3383+
cls.dotdot_resolves_early = True
3384+
elif os.path.exists(os.path.join(testpath, '..', 'file')):
3385+
# Posix resolves 'current' to '.' first, leaving
3386+
# 'testpath/../file'
3387+
cls.dotdot_resolves_early = False
3388+
else:
3389+
raise AssertionError('Could not determine link resolution')
3390+
else:
3391+
cls.dotdot_resolves_early = False
3392+
33603393
@contextmanager
33613394
def check_context(self, tar, filter, *, check_flag=True, ignored_trees=()):
33623395
"""Extracts `tar` to `self.destdir` and allows checking the result
@@ -3538,10 +3571,19 @@ def test_parent_symlink(self):
35383571
+ "which is outside the destination")
35393572

35403573
with self.check_context(arc.open(), 'data'):
3541-
self.expect_exception(
3542-
tarfile.LinkOutsideDestinationError,
3543-
"""'parent' would link to ['"].*outerdir['"], """
3544-
+ "which is outside the destination")
3574+
if self.dotdot_resolves_early:
3575+
# 'current/../..' normalises to '..', which is rejected.
3576+
self.expect_exception(
3577+
tarfile.LinkOutsideDestinationError,
3578+
"""'parent' would link to ['"].*outerdir['"], """
3579+
+ "which is outside the destination")
3580+
else:
3581+
# 'current/..' normalises to '.'; the rewritten link is
3582+
# created and 'parent/evil' lands harmlessly inside the
3583+
# destination.
3584+
self.expect_file('current', symlink_to='.')
3585+
self.expect_file('parent', symlink_to='.')
3586+
self.expect_file('evil')
35453587

35463588
else:
35473589
# No symlink support. The symlinks are ignored.
@@ -3628,35 +3670,6 @@ def test_parent_symlink2(self):
36283670
# Test interplaying symlinks
36293671
# Inspired by 'dirsymlink2b' in jwilk/traversal-archives
36303672

3631-
# Posix and Windows have different pathname resolution:
3632-
# either symlink or a '..' component resolve first.
3633-
# Let's see which we are on.
3634-
if os_helper.can_symlink():
3635-
testpath = os.path.join(TEMPDIR, 'resolution_test')
3636-
os.mkdir(testpath)
3637-
3638-
# testpath/current links to `.` which is all of:
3639-
# - `testpath`
3640-
# - `testpath/current`
3641-
# - `testpath/current/current`
3642-
# - etc.
3643-
os.symlink('.', os.path.join(testpath, 'current'))
3644-
3645-
# we'll test where `testpath/current/../file` ends up
3646-
with open(os.path.join(testpath, 'current', '..', 'file'), 'w'):
3647-
pass
3648-
3649-
if os.path.exists(os.path.join(testpath, 'file')):
3650-
# Windows collapses 'current\..' to '.' first, leaving
3651-
# 'testpath\file'
3652-
dotdot_resolves_early = True
3653-
elif os.path.exists(os.path.join(testpath, '..', 'file')):
3654-
# Posix resolves 'current' to '.' first, leaving
3655-
# 'testpath/../file'
3656-
dotdot_resolves_early = False
3657-
else:
3658-
raise AssertionError('Could not determine link resolution')
3659-
36603673
with ArchiveMaker() as arc:
36613674

36623675
# `current` links to `.` which is both the destination directory
@@ -3692,7 +3705,7 @@ def test_parent_symlink2(self):
36923705

36933706
with self.check_context(arc.open(), 'data'):
36943707
if os_helper.can_symlink():
3695-
if dotdot_resolves_early:
3708+
if self.dotdot_resolves_early:
36963709
# Fail when extracting a file outside destination
36973710
self.expect_exception(
36983711
tarfile.OutsideDestinationError,
@@ -3809,6 +3822,72 @@ def test_sly_relative2(self):
38093822
+ """['"].*moo['"], which is outside the """
38103823
+ "destination")
38113824

3825+
@os_helper.skip_unless_symlink
3826+
def test_normpath_realpath_mismatch(self):
3827+
# The link-target check must validate the value that will actually
3828+
# be written to disk (the normalised linkname), not the original.
3829+
# Here 'a' is a symlink to a deep nonexistent path, so realpath()
3830+
# of 'a/../../...' stays inside the destination while normpath()
3831+
# collapses 'a/..' lexically and escapes.
3832+
depth = len(self.destdir.parts) + 5
3833+
deep = '/'.join(f'p{i}' for i in range(depth))
3834+
sneaky = 'a/' + '../' * depth + 'flag'
3835+
for kind in 'symlink_to', 'hardlink_to':
3836+
with self.subTest(kind):
3837+
with ArchiveMaker() as arc:
3838+
arc.add('a', symlink_to=deep)
3839+
arc.add('escape', **{kind: sneaky})
3840+
with self.check_context(arc.open(), 'data'):
3841+
self.expect_exception(
3842+
tarfile.LinkOutsideDestinationError)
3843+
3844+
@os_helper.skip_unless_symlink
3845+
def test_symlink_trailing_slash(self):
3846+
# A trailing slash on a symlink member's name must not cause the
3847+
# link target to be resolved relative to the wrong directory.
3848+
with ArchiveMaker() as arc:
3849+
t = tarfile.TarInfo('x/')
3850+
t.type = tarfile.SYMTYPE
3851+
t.linkname = '..'
3852+
arc.tar_w.addfile(t)
3853+
arc.add('x/escaped', content='hi')
3854+
3855+
with self.check_context(arc.open(), 'data'):
3856+
self.expect_exception(tarfile.LinkOutsideDestinationError)
3857+
3858+
@os_helper.skip_unless_symlink
3859+
def test_link_at_destination(self):
3860+
# A link member whose name resolves to the destination directory
3861+
# itself must be rejected: otherwise the destination is replaced
3862+
# by a symlink and later members can be redirected through it.
3863+
for name in '', '.', './':
3864+
with ArchiveMaker() as arc:
3865+
t = tarfile.TarInfo(name)
3866+
t.type = tarfile.SYMTYPE
3867+
t.linkname = '.'
3868+
arc.tar_w.addfile(t)
3869+
3870+
with self.check_context(arc.open(), 'data'):
3871+
self.expect_exception(tarfile.OutsideDestinationError)
3872+
3873+
@os_helper.skip_unless_symlink
3874+
def test_empty_name_symlink_chain(self):
3875+
# Regression test for a chain of empty-named symlinks that
3876+
# incrementally redirects the destination outwards.
3877+
with ArchiveMaker() as arc:
3878+
for name, target in [('', ''), ('a/', '..'),
3879+
('', 'dummy'), ('', 'a'),
3880+
('b/', '..'),
3881+
('', 'dummy'), ('', 'a/b')]:
3882+
t = tarfile.TarInfo(name)
3883+
t.type = tarfile.SYMTYPE
3884+
t.linkname = target
3885+
arc.tar_w.addfile(t)
3886+
arc.add('escaped', content='hi')
3887+
3888+
with self.check_context(arc.open(), 'data'):
3889+
self.expect_exception(tarfile.FilterError)
3890+
38123891
def test_deep_symlink(self):
38133892
# Test that symlinks and hardlinks inside a directory
38143893
# point to the correct file (`target` of size 3).
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
:func:`tarfile.data_filter` now validates link targets using the same
2+
normalised value that is written to disk, strips trailing separators from
3+
the member name when resolving a symlink's directory, and rejects link
4+
members that would replace the destination directory itself. This closes
5+
several path-traversal bypasses of the ``data`` extraction filter.

0 commit comments

Comments
 (0)