Skip to content

Commit 8eafb15

Browse files
committed
gh-160446: Replace an existing file instead of writing through it in tarfile and zipfile
When extracting a regular file, open the output with O_CREAT|O_EXCL and, if something already exists at the path, unlink it and retry once, instead of opening with O_TRUNC. Previously an existing symlink or hardlink at that path was written through, which could modify a file outside the extraction directory, also under tarfile's 'data' and 'tar' filters. Windows and Emscripten follow a dangling symlink even with O_EXCL, so there the path is unlinked before opening. The helper lives in a new private module, _archive, shared by tarfile and zipfile.
1 parent b1e32ff commit 8eafb15

6 files changed

Lines changed: 65 additions & 12 deletions

File tree

‎Lib/_archive.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Internal helpers shared by tarfile and zipfile."""
2+
3+
import os
4+
import sys
5+
6+
7+
if sys.platform in ("win32", "emscripten"):
8+
# These follow a dangling symlink even with O_EXCL, so remove what is
9+
# there first.
10+
def create_or_replace(path):
11+
"""Open path for writing as a new file, replacing what is there.
12+
13+
An existing symlink or hard link is replaced, instead of written
14+
through to the file it refers to.
15+
"""
16+
try:
17+
os.unlink(path)
18+
except FileNotFoundError:
19+
pass
20+
return open(path, "xb")
21+
else:
22+
# O_EXCL refuses any existing file, including a dangling symlink.
23+
def create_or_replace(path):
24+
"""Open path for writing as a new file, replacing what is there.
25+
26+
An existing symlink or hard link is replaced, instead of written
27+
through to the file it refers to.
28+
"""
29+
try:
30+
return open(path, "xb")
31+
except FileExistsError:
32+
os.unlink(path)
33+
return open(path, "xb")

‎Lib/tarfile.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import struct
4545
import copy
4646
import re
47+
import _archive
4748

4849
try:
4950
import pwd
@@ -2766,7 +2767,7 @@ def makefile(self, tarinfo, targetpath):
27662767
source = self.fileobj
27672768
source.seek(tarinfo.offset_data)
27682769
bufsize = self.copybufsize
2769-
with bltn_open(targetpath, "wb") as target:
2770+
with _archive.create_or_replace(targetpath) as target:
27702771
if tarinfo.sparse is not None:
27712772
for offset, size in tarinfo.sparse:
27722773
target.seek(offset)

‎Lib/test/archiver_tests.py‎

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,9 @@ def test_overwrite_dir_as_file(self):
5353
target = os.path.join(self.testdir, 'test')
5454
os.mkdir(target)
5555
with self.open(self.ar_with_file) as ar:
56-
with self.assertRaises(PermissionError if sys.platform == 'win32'
57-
else IsADirectoryError):
56+
# Removing the directory fails with EISDIR on Linux and EPERM on
57+
# other platforms.
58+
with self.assertRaises((IsADirectoryError, PermissionError)):
5859
self.extractall(ar)
5960
self.assertTrue(os.path.isdir(target))
6061

@@ -81,30 +82,42 @@ def test_overwrite_file_as_implicit_dir(self):
8182

8283
@os_helper.skip_unless_symlink
8384
def test_overwrite_file_symlink_as_file(self):
84-
# XXX: It is potential security vulnerability.
8585
target = os.path.join(self.testdir, 'test')
8686
target2 = os.path.join(self.testdir, 'test2')
8787
self.create_file(target2, b'content')
8888
os.symlink('test2', target)
8989
with self.open(self.ar_with_file) as ar:
9090
self.extractall(ar)
91-
self.assertTrue(os.path.islink(target))
92-
self.assertTrue(os.path.isfile(target2))
93-
with open(target2, 'rb') as f:
91+
self.assertFalse(os.path.islink(target))
92+
with open(target, 'rb') as f:
9493
self.assertEqual(f.read(), b'newcontent')
94+
with open(target2, 'rb') as f:
95+
self.assertEqual(f.read(), b'content')
9596

9697
@os_helper.skip_unless_symlink
9798
def test_overwrite_broken_file_symlink_as_file(self):
98-
# XXX: It is potential security vulnerability.
9999
target = os.path.join(self.testdir, 'test')
100100
target2 = os.path.join(self.testdir, 'test2')
101101
os.symlink('test2', target)
102102
with self.open(self.ar_with_file) as ar:
103103
self.extractall(ar)
104-
self.assertTrue(os.path.islink(target))
105-
self.assertTrue(os.path.isfile(target2))
106-
with open(target2, 'rb') as f:
104+
self.assertFalse(os.path.islink(target))
105+
with open(target, 'rb') as f:
107106
self.assertEqual(f.read(), b'newcontent')
107+
self.assertFalse(os.path.lexists(target2))
108+
109+
@os_helper.skip_unless_hardlink
110+
def test_overwrite_file_hardlink_as_file(self):
111+
target = os.path.join(self.testdir, 'test')
112+
target2 = os.path.join(self.testdir, 'test2')
113+
self.create_file(target2, b'content')
114+
os.link(target2, target)
115+
with self.open(self.ar_with_file) as ar:
116+
self.extractall(ar)
117+
with open(target, 'rb') as f:
118+
self.assertEqual(f.read(), b'newcontent')
119+
with open(target2, 'rb') as f:
120+
self.assertEqual(f.read(), b'content')
108121

109122
@os_helper.skip_unless_symlink
110123
def test_overwrite_dir_symlink_as_dir(self):

‎Lib/zipfile/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
44
XXX references to utf-8 need further investigation.
55
"""
6+
import _archive
67
import binascii
78
import io
89
import os
@@ -2518,7 +2519,7 @@ def _extract_member(self, member, targetpath, pwd):
25182519
return targetpath
25192520

25202521
with self.open(member, pwd=pwd) as source, \
2521-
open(targetpath, "wb") as target:
2522+
_archive.create_or_replace(targetpath) as target:
25222523
shutil.copyfileobj(source, target)
25232524

25242525
return targetpath
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
:mod:`tarfile` and :mod:`zipfile` now replace an existing file when
2+
extracting a regular file of the same name, instead of writing to it.
3+
Previously, an existing symlink or hardlink at that path was written through,
4+
which could modify a file outside the extraction directory.

‎Python/stdlib_module_names.h‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)