Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Doc/library/tarfile.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1132,7 +1132,9 @@ reused in custom filters:
path (after following symlinks) would end up outside the destination.
This raises :class:`~tarfile.OutsideDestinationError`.
- :ref:`Refuse <tarfile-extraction-refuse>` to extract links (hard or soft)
whose name refers to the destination directory itself.
that would be made at the destination directory itself or outside it.
Only the directories leading to the link are resolved: a link replaces
whatever is at its own path rather than following it.
This raises :class:`~tarfile.OutsideDestinationError`.
Note that such files are refused even on platforms that do not support
symbolic links.
Expand All @@ -1152,6 +1154,9 @@ reused in custom filters:

Filenames containing ``..`` components are now normalized.

Links that would be made at the destination directory itself, or
at an existing symbolic link outside it, are now refused.

.. function:: data_filter(member, path)

Implements the ``'data'`` filter.
Expand Down
19 changes: 13 additions & 6 deletions Lib/tarfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -854,12 +854,19 @@ def _get_filtered_attrs(member, dest_path, for_data=True):
strict=os.path.ALLOW_MISSING)
if os.path.commonpath([target_path, dest_path]) != dest_path:
raise OutsideDestinationError(member, target_path)
# A link member named after the destination directory itself
# would replace it with a (sym)link, redirecting the destination
# for all subsequent members.
if ((member.islnk() or member.issym())
and os.path.normpath(name) == os.curdir):
raise OutsideDestinationError(member, target_path)
if member.islnk() or member.issym():
# A link is made at its own path without following a link already
# there (that is unlinked first), so judge it by where it is made:
# resolve only the parent directory. The link must land strictly
# inside the destination; one that replaced the destination itself
# would redirect all subsequent members.
parent, leaf = os.path.split(name.rstrip('/' + os.sep))
parent = os.path.realpath(os.path.join(dest_path, parent),
strict=os.path.ALLOW_MISSING)
link_path = os.path.normpath(os.path.join(parent, leaf))
if (os.path.commonpath([link_path, dest_path]) != dest_path
or os.path.normcase(link_path) == os.path.normcase(dest_path)):
raise OutsideDestinationError(member, link_path)
# Limit permissions (no high bits, and go-w)
mode = member.mode
if mode is not None:
Expand Down
76 changes: 76 additions & 0 deletions Lib/test/test_tarfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -4676,6 +4676,82 @@
with self.check_context(arc.open(), filter):
self.expect_exception(tarfile.OutsideDestinationError)

@symlink_test
@os_helper.skip_unless_symlink
def test_link_reaching_destination(self):
# gh-160863: link members whose names are not lexically '.' but
# that would still be made at the destination directory itself.
def via_parent_name(arc):
arc.add('../dest', symlink_to='dest')

def via_symlink_to_dot(arc):
arc.add('d/')
arc.add('s', symlink_to='.')
arc.add('s/.', hardlink_to='d')

def via_symlink_to_dot_symlink(arc):
arc.add('s', symlink_to='.')
arc.add('s/.', symlink_to='.')

for make in (via_parent_name, via_symlink_to_dot,
via_symlink_to_dot_symlink):
with ArchiveMaker() as arc:
make(arc)
for filter in 'tar', 'data':
with self.subTest(make.__name__, filter=filter):
with self.check_context(arc.open(), filter):
self.expect_exception(tarfile.OutsideDestinationError)

@symlink_test
@os_helper.skip_unless_symlink
def test_link_replacing_outer_symlink(self):
# gh-160863: a link member must not replace an existing symlink
# outside the destination, even one that points into it.
with ArchiveMaker() as arc:
arc.add('first.txt', content='1')
arc.add('../current', symlink_to='real')
arc.add('later.txt', content='2')
for filter in 'tar', 'data':
with self.subTest(filter=filter), \
os_helper.temp_dir(self.outerdir):
real = self.outerdir / 'real'
current = self.outerdir / 'current'
real.mkdir()
current.symlink_to('real')
with arc.open() as tar:
with self.assertRaises(tarfile.OutsideDestinationError):
tar.extractall(current, filter=filter)
self.assertTrue(current.is_symlink())
self.assertEqual(os.listdir(real), ['first.txt'])

# The link's parent directory may also be reached through a
# symlink made earlier in the same archive.
with ArchiveMaker() as arc:
arc.add('s', symlink_to='..')
arc.add('s/outer', symlink_to='dest')
with os_helper.temp_dir(self.outerdir):
outer = self.outerdir / 'outer'
outer.symlink_to('dest')
with arc.open() as tar:
with self.assertRaises(tarfile.OutsideDestinationError):
tar.extractall(self.destdir, filter='tar')

Check failure on line 4737 in Lib/test/test_tarfile.py

View workflow job for this annotation

GitHub Actions / Windows / Build and test (Win32, switch-case)

test_link_replacing_outer_symlink (test.test_tarfile.TestExtractionFilters.test_link_replacing_outer_symlink)

PermissionError: [WinError 5] Access is denied: 'D:\\a\\cpython\\cpython\\build\\test_python_6488æ\\@test_6488_tmpæ-tardir\\outerdir\\dest\\s\\outer'

Check failure on line 4737 in Lib/test/test_tarfile.py

View workflow job for this annotation

GitHub Actions / Windows / Build and test (x64, switch-case)

test_link_replacing_outer_symlink (test.test_tarfile.TestExtractionFilters.test_link_replacing_outer_symlink)

PermissionError: [WinError 5] Access is denied: 'D:\\a\\cpython\\cpython\\build\\test_python_9376æ\\@test_9376_tmpæ-tardir\\outerdir\\dest\\s\\outer'

Check failure on line 4737 in Lib/test/test_tarfile.py

View workflow job for this annotation

GitHub Actions / Windows (free-threading) / Build and test (arm64, switch-case)

test_link_replacing_outer_symlink (test.test_tarfile.TestExtractionFilters.test_link_replacing_outer_symlink)

PermissionError: [WinError 5] Access is denied: 'C:\\a\\cpython\\cpython\\build\\test_python_7880æ\\@test_7880_tmpæ-tardir\\outerdir\\dest\\s\\outer'

Check failure on line 4737 in Lib/test/test_tarfile.py

View workflow job for this annotation

GitHub Actions / Windows (free-threading) / Build and test (x64, switch-case)

test_link_replacing_outer_symlink (test.test_tarfile.TestExtractionFilters.test_link_replacing_outer_symlink)

PermissionError: [WinError 5] Access is denied: 'D:\\a\\cpython\\cpython\\build\\test_python_10260æ\\@test_10260_tmpæ-tardir\\outerdir\\dest\\s\\outer'

Check failure on line 4737 in Lib/test/test_tarfile.py

View workflow job for this annotation

GitHub Actions / Windows / Build and test (arm64, switch-case)

test_link_replacing_outer_symlink (test.test_tarfile.TestExtractionFilters.test_link_replacing_outer_symlink)

PermissionError: [WinError 5] Access is denied: 'C:\\a\\cpython\\cpython\\build\\test_python_1548æ\\@test_1548_tmpæ-tardir\\outerdir\\dest\\s\\outer'

Check failure on line 4737 in Lib/test/test_tarfile.py

View workflow job for this annotation

GitHub Actions / Windows / Build and test (x64, tail-call)

test_link_replacing_outer_symlink (test.test_tarfile.TestExtractionFilters.test_link_replacing_outer_symlink)

PermissionError: [WinError 5] Access is denied: 'D:\\a\\cpython\\cpython\\build\\test_python_9532æ\\@test_9532_tmpæ-tardir\\outerdir\\dest\\s\\outer'

Check failure on line 4737 in Lib/test/test_tarfile.py

View workflow job for this annotation

GitHub Actions / Windows (free-threading) / Build and test (x64, tail-call)

test_link_replacing_outer_symlink (test.test_tarfile.TestExtractionFilters.test_link_replacing_outer_symlink)

PermissionError: [WinError 5] Access is denied: 'D:\\a\\cpython\\cpython\\build\\test_python_7092æ\\@test_7092_tmpæ-tardir\\outerdir\\dest\\s\\outer'
self.assertTrue(outer.is_symlink())

@symlink_test
@os_helper.skip_unless_symlink
def test_link_to_dot_reextracted(self):
# A symlink to '.' resolves to the destination once it exists;
# extracting the same archive again must still replace it.
with ArchiveMaker() as arc:
arc.add('self', symlink_to='.')
for filter in 'tar', 'data':
with self.subTest(filter=filter), \
os_helper.temp_dir(self.outerdir):
for _ in range(2):
with arc.open() as tar:
tar.extractall(self.destdir, filter=filter)
self.assertEqual(os.readlink(self.destdir / 'self'), '.')

@symlink_test
@os_helper.skip_unless_symlink
def test_empty_name_symlink_chain(self):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
:func:`tarfile.tar_filter` and :func:`tarfile.data_filter` now judge a link
member by where the link would be made, resolving only its parent directory.
This rejects links that reach the destination directory, or an existing
symbolic link outside it, through ``..`` components or earlier symlink
members, which :gh:`160097` had allowed past the ``'data'`` filter.
Loading