Skip to content

Commit 356a0ff

Browse files
wiomoclkollar
authored andcommitted
pythongh-57911: Sanitize symlink targets in tarfile on win32 (pythonGH-138309)
1 parent b3c96b8 commit 356a0ff

File tree

4 files changed

+71
-42
lines changed

4 files changed

+71
-42
lines changed

Doc/whatsnew/3.15.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,10 @@ tarfile
463463
:func:`~tarfile.TarFile.errorlevel` is zero.
464464
(Contributed by Matt Prodani and Petr Viktorin in :gh:`112887`
465465
and :cve:`2025-4435`.)
466+
* :func:`~tarfile.TarFile.extract` and :func:`~tarfile.TarFile.extractall`
467+
now replace slashes by backslashes in symlink targets on Windows to prevent
468+
creation of corrupted links.
469+
(Contributed by Christoph Walcher in :gh:`57911`.)
466470

467471

468472
types

Lib/tarfile.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2718,7 +2718,13 @@ def makelink_with_filter(self, tarinfo, targetpath,
27182718
if os.path.lexists(targetpath):
27192719
# Avoid FileExistsError on following os.symlink.
27202720
os.unlink(targetpath)
2721-
os.symlink(tarinfo.linkname, targetpath)
2721+
link_target = tarinfo.linkname
2722+
if os.name == "nt":
2723+
# gh-57911: Posix-flavoured forward-slash path separators in
2724+
# symlink targets aren't acknowledged by Windows, resulting
2725+
# in corrupted links.
2726+
link_target = link_target.replace("/", os.path.sep)
2727+
os.symlink(link_target, targetpath)
27222728
return
27232729
else:
27242730
if os.path.exists(tarinfo._link_target):

Lib/test/test_tarfile.py

Lines changed: 58 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2777,7 +2777,7 @@ def test_useful_error_message_when_modules_missing(self):
27772777
str(excinfo.exception),
27782778
)
27792779

2780-
@unittest.skipUnless(os_helper.can_symlink(), 'requires symlink support')
2780+
@os_helper.skip_unless_symlink
27812781
@unittest.skipUnless(hasattr(os, 'chmod'), "missing os.chmod")
27822782
@unittest.mock.patch('os.chmod')
27832783
def test_deferred_directory_attributes_update(self, mock_chmod):
@@ -3663,6 +3663,39 @@ class TestExtractionFilters(unittest.TestCase):
36633663
# The destination for the extraction, within `outerdir`
36643664
destdir = outerdir / 'dest'
36653665

3666+
@classmethod
3667+
def setUpClass(cls):
3668+
# Posix and Windows have different pathname resolution:
3669+
# either symlink or a '..' component resolve first.
3670+
# Let's see which we are on.
3671+
if os_helper.can_symlink():
3672+
testpath = os.path.join(TEMPDIR, 'resolution_test')
3673+
os.mkdir(testpath)
3674+
3675+
# testpath/current links to `.` which is all of:
3676+
# - `testpath`
3677+
# - `testpath/current`
3678+
# - `testpath/current/current`
3679+
# - etc.
3680+
os.symlink('.', os.path.join(testpath, 'current'))
3681+
3682+
# we'll test where `testpath/current/../file` ends up
3683+
with open(os.path.join(testpath, 'current', '..', 'file'), 'w'):
3684+
pass
3685+
3686+
if os.path.exists(os.path.join(testpath, 'file')):
3687+
# Windows collapses 'current\..' to '.' first, leaving
3688+
# 'testpath\file'
3689+
cls.dotdot_resolves_early = True
3690+
elif os.path.exists(os.path.join(testpath, '..', 'file')):
3691+
# Posix resolves 'current' to '.' first, leaving
3692+
# 'testpath/../file'
3693+
cls.dotdot_resolves_early = False
3694+
else:
3695+
raise AssertionError('Could not determine link resolution')
3696+
else:
3697+
cls.dotdot_resolves_early = True
3698+
36663699
@contextmanager
36673700
def check_context(self, tar, filter, *, check_flag=True):
36683701
"""Extracts `tar` to `self.destdir` and allows checking the result
@@ -3809,23 +3842,21 @@ def test_parent_symlink(self):
38093842
arc.add('current', symlink_to='.')
38103843

38113844
# effectively points to ./../
3812-
arc.add('parent', symlink_to='current/..')
3845+
if self.dotdot_resolves_early:
3846+
arc.add('parent', symlink_to='current/../..')
3847+
else:
3848+
arc.add('parent', symlink_to='current/..')
38133849

38143850
arc.add('parent/evil')
38153851

38163852
if os_helper.can_symlink():
38173853
with self.check_context(arc.open(), 'fully_trusted'):
3818-
if self.raised_exception is not None:
3819-
# Windows will refuse to create a file that's a symlink to itself
3820-
# (and tarfile doesn't swallow that exception)
3821-
self.expect_exception(FileExistsError)
3822-
# The other cases will fail with this error too.
3823-
# Skip the rest of this test.
3824-
return
3854+
self.expect_file('current', symlink_to='.')
3855+
if self.dotdot_resolves_early:
3856+
self.expect_file('parent', symlink_to='current/../..')
38253857
else:
3826-
self.expect_file('current', symlink_to='.')
38273858
self.expect_file('parent', symlink_to='current/..')
3828-
self.expect_file('../evil')
3859+
self.expect_file('../evil')
38293860

38303861
with self.check_context(arc.open(), 'tar'):
38313862
self.expect_exception(
@@ -3927,35 +3958,6 @@ def test_parent_symlink2(self):
39273958
# Test interplaying symlinks
39283959
# Inspired by 'dirsymlink2b' in jwilk/traversal-archives
39293960

3930-
# Posix and Windows have different pathname resolution:
3931-
# either symlink or a '..' component resolve first.
3932-
# Let's see which we are on.
3933-
if os_helper.can_symlink():
3934-
testpath = os.path.join(TEMPDIR, 'resolution_test')
3935-
os.mkdir(testpath)
3936-
3937-
# testpath/current links to `.` which is all of:
3938-
# - `testpath`
3939-
# - `testpath/current`
3940-
# - `testpath/current/current`
3941-
# - etc.
3942-
os.symlink('.', os.path.join(testpath, 'current'))
3943-
3944-
# we'll test where `testpath/current/../file` ends up
3945-
with open(os.path.join(testpath, 'current', '..', 'file'), 'w'):
3946-
pass
3947-
3948-
if os.path.exists(os.path.join(testpath, 'file')):
3949-
# Windows collapses 'current\..' to '.' first, leaving
3950-
# 'testpath\file'
3951-
dotdot_resolves_early = True
3952-
elif os.path.exists(os.path.join(testpath, '..', 'file')):
3953-
# Posix resolves 'current' to '.' first, leaving
3954-
# 'testpath/../file'
3955-
dotdot_resolves_early = False
3956-
else:
3957-
raise AssertionError('Could not determine link resolution')
3958-
39593961
with ArchiveMaker() as arc:
39603962

39613963
# `current` links to `.` which is both the destination directory
@@ -3991,7 +3993,7 @@ def test_parent_symlink2(self):
39913993

39923994
with self.check_context(arc.open(), 'data'):
39933995
if os_helper.can_symlink():
3994-
if dotdot_resolves_early:
3996+
if self.dotdot_resolves_early:
39953997
# Fail when extracting a file outside destination
39963998
self.expect_exception(
39973999
tarfile.OutsideDestinationError,
@@ -4039,6 +4041,21 @@ def test_absolute_symlink(self):
40394041
tarfile.AbsoluteLinkError,
40404042
"'parent' is a link to an absolute path")
40414043

4044+
@symlink_test
4045+
@os_helper.skip_unless_symlink
4046+
def test_symlink_target_seperator_rewrite_on_windows(self):
4047+
with ArchiveMaker() as arc:
4048+
arc.add('link', symlink_to="relative/test/path")
4049+
4050+
with self.check_context(arc.open(), 'fully_trusted'):
4051+
self.expect_file('link', type=tarfile.SYMTYPE)
4052+
link_path = os.path.normpath(self.destdir / "link")
4053+
link_target = os.readlink(link_path)
4054+
if os.name == "nt":
4055+
self.assertEqual(link_target, "relative\\test\\path")
4056+
else:
4057+
self.assertEqual(link_target, "relative/test/path")
4058+
40424059
def test_absolute_hardlink(self):
40434060
# Test hardlink to an absolute path
40444061
# Inspired by 'dirsymlink' in https://github.com/jwilk/traversal-archives
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
When extracting tar files on Windows, slashes in symlink targets will be
2+
replaced by backslashes to prevent corrupted links.

0 commit comments

Comments
 (0)