diff --git a/Lib/tarfile.py b/Lib/tarfile.py index 57bba1bba54db35..e5e7b1f732bcd68 100644 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -895,6 +895,11 @@ def _get_filtered_attrs(member, dest_path, for_data=True): if member.islnk() or member.issym(): if os.path.isabs(member.linkname): raise AbsoluteLinkError(member) + # A link member that resolves to the destination directory itself + # would replace it with a (sym)link, redirecting the destination + # for all subsequent members. + if target_path == dest_path: + raise OutsideDestinationError(member, target_path) normalized = os.path.normpath(member.linkname) if normalized != member.linkname: new_attrs['linkname'] = normalized diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py index 639fd0449a989ab..3025b7407eb9321 100644 --- a/Lib/test/test_tarfile.py +++ b/Lib/test/test_tarfile.py @@ -4676,6 +4676,43 @@ def test_link_at_destination(self): with self.check_context(arc.open(), filter): self.expect_exception(tarfile.OutsideDestinationError) + @symlink_test + @os_helper.skip_unless_symlink + def test_link_resolving_to_destination(self): + # gh-160863: the 'data' filter rejects link members whose path + # resolves to the destination, even if the name is not lexically '.'. + 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') + + for make in via_parent_name, via_symlink_to_dot: + with ArchiveMaker() as arc: + make(arc) + with self.subTest(make.__name__): + with self.check_context(arc.open(), 'data'): + self.expect_exception(tarfile.OutsideDestinationError) + + # A symlink outside the destination that points at it must not be + # replaced. + with ArchiveMaker() as arc: + arc.add('first.txt', content='1') + arc.add('../current', symlink_to='real') + arc.add('later.txt', content='2') + with 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='data') + self.assertTrue(current.is_symlink()) + self.assertEqual(os.listdir(real), ['first.txt']) + @symlink_test @os_helper.skip_unless_symlink def test_empty_name_symlink_chain(self): diff --git a/Misc/NEWS.d/next/Security/2026-10-29-13-00-00.gh-issue-160863.Kq7mVd.rst b/Misc/NEWS.d/next/Security/2026-10-29-13-00-00.gh-issue-160863.Kq7mVd.rst new file mode 100644 index 000000000000000..78a1310fc9ff4dc --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-10-29-13-00-00.gh-issue-160863.Kq7mVd.rst @@ -0,0 +1,3 @@ +:func:`tarfile.data_filter` again rejects link members whose path resolves +to the destination directory itself, such as ``../dest``. :gh:`160097` +had narrowed this check to names that are lexically ``.``.