Skip to content

Commit 3e7a86b

Browse files
kwi-dkmcepl
authored andcommitted
[CVE-2023-6597] tempfile.TemporaryDirectory: fix symlink bug in cleanup
Code is from gh#python/cpython!99930, it was released upstream in 3.8.19. Fixes: bsc#1219666 Patch: CVE-2023-6597-TempDir-cleaning-symlink.patch
1 parent f47015f commit 3e7a86b

File tree

4 files changed

+211
-13
lines changed

4 files changed

+211
-13
lines changed

Lib/tempfile.py

+104-10
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,27 @@ def _sanitize_params(prefix, suffix, dir):
133133
return prefix, suffix, dir, output_type
134134

135135

136+
def _infer_return_type(*args):
137+
"""Look at the type of all args and divine their implied return type."""
138+
return_type = None
139+
for arg in args:
140+
if arg is None:
141+
continue
142+
if isinstance(arg, bytes):
143+
if return_type is str:
144+
raise TypeError("Can't mix bytes and non-bytes in "
145+
"path components.")
146+
return_type = bytes
147+
else:
148+
if return_type is bytes:
149+
raise TypeError("Can't mix bytes and non-bytes in "
150+
"path components.")
151+
return_type = str
152+
if return_type is None:
153+
return str # tempfile APIs return a str by default.
154+
return return_type
155+
156+
136157
class _RandomNameSequence:
137158
"""An instance of _RandomNameSequence generates an endless
138159
sequence of unpredictable strings which can safely be incorporated
@@ -275,6 +296,22 @@ def _mkstemp_inner(dir, pre, suf, flags, output_type):
275296
raise FileExistsError(_errno.EEXIST,
276297
"No usable temporary file name found")
277298

299+
def _dont_follow_symlinks(func, path, *args):
300+
# Pass follow_symlinks=False, unless not supported on this platform.
301+
if func in _os.supports_follow_symlinks:
302+
func(path, *args, follow_symlinks=False)
303+
elif _os.name == 'nt' or not _os.path.islink(path):
304+
func(path, *args)
305+
306+
def _resetperms(path):
307+
try:
308+
chflags = _os.chflags
309+
except AttributeError:
310+
pass
311+
else:
312+
_dont_follow_symlinks(chflags, path, 0)
313+
_dont_follow_symlinks(_os.chmod, path, 0o700)
314+
278315

279316
# User visible interfaces.
280317

@@ -776,7 +813,7 @@ def writelines(self, iterable):
776813
return rv
777814

778815

779-
class TemporaryDirectory(object):
816+
class TemporaryDirectory:
780817
"""Create and return a temporary directory. This has the same
781818
behavior as mkdtemp but can be used as a context manager. For
782819
example:
@@ -785,19 +822,75 @@ class TemporaryDirectory(object):
785822
...
786823
787824
Upon exiting the context, the directory and everything contained
788-
in it are removed.
825+
in it are removed (unless delete=False is passed or an exception
826+
is raised during cleanup and ignore_cleanup_errors is not True).
827+
828+
Optional Arguments:
829+
suffix - A str suffix for the directory name. (see mkdtemp)
830+
prefix - A str prefix for the directory name. (see mkdtemp)
831+
dir - A directory to create this temp dir in. (see mkdtemp)
832+
ignore_cleanup_errors - False; ignore exceptions during cleanup?
833+
delete - True; whether the directory is automatically deleted.
789834
"""
790835

791-
def __init__(self, suffix=None, prefix=None, dir=None):
836+
def __init__(self, suffix=None, prefix=None, dir=None,
837+
ignore_cleanup_errors=False, *, delete=True):
792838
self.name = mkdtemp(suffix, prefix, dir)
839+
self._ignore_cleanup_errors = ignore_cleanup_errors
840+
self._delete = delete
793841
self._finalizer = _weakref.finalize(
794842
self, self._cleanup, self.name,
795-
warn_message="Implicitly cleaning up {!r}".format(self))
843+
warn_message="Implicitly cleaning up {!r}".format(self),
844+
ignore_errors=self._ignore_cleanup_errors, delete=self._delete)
845+
846+
@classmethod
847+
def _rmtree(cls, name, ignore_errors=False, repeated=False):
848+
def onexc(func, path, exc_info):
849+
exc = exc_info[1]
850+
if isinstance(exc, PermissionError):
851+
if repeated and path == name:
852+
if ignore_errors:
853+
return
854+
raise
855+
856+
try:
857+
if path != name:
858+
_resetperms(_os.path.dirname(path))
859+
_resetperms(path)
860+
861+
try:
862+
_os.unlink(path)
863+
except IsADirectoryError:
864+
cls._rmtree(path)
865+
except PermissionError:
866+
# The PermissionError handler was originally added for
867+
# FreeBSD in directories, but it seems that it is raised
868+
# on Windows too.
869+
# bpo-43153: Calling _rmtree again may
870+
# raise NotADirectoryError and mask the PermissionError.
871+
# So we must re-raise the current PermissionError if
872+
# path is not a directory.
873+
if not _os.path.isdir(path) or _os.path.isjunction(path):
874+
if ignore_errors:
875+
return
876+
raise
877+
cls._rmtree(path, ignore_errors=ignore_errors,
878+
repeated=(path == name))
879+
except FileNotFoundError:
880+
pass
881+
elif isinstance(exc, FileNotFoundError):
882+
pass
883+
else:
884+
if not ignore_errors:
885+
raise
886+
887+
_shutil.rmtree(name, onerror=onexc)
796888

797889
@classmethod
798-
def _cleanup(cls, name, warn_message):
799-
_shutil.rmtree(name)
800-
_warnings.warn(warn_message, ResourceWarning)
890+
def _cleanup(cls, name, warn_message, ignore_errors=False, delete=True):
891+
if delete:
892+
cls._rmtree(name, ignore_errors=ignore_errors)
893+
_warnings.warn(warn_message, ResourceWarning)
801894

802895
def __repr__(self):
803896
return "<{} {!r}>".format(self.__class__.__name__, self.name)
@@ -806,8 +899,9 @@ def __enter__(self):
806899
return self.name
807900

808901
def __exit__(self, exc, value, tb):
809-
self.cleanup()
902+
if self._delete:
903+
self.cleanup()
810904

811905
def cleanup(self):
812-
if self._finalizer.detach():
813-
_shutil.rmtree(self.name)
906+
if self._finalizer.detach() or _os.path.exists(self.name):
907+
self._rmtree(self.name, ignore_errors=self._ignore_cleanup_errors)

Lib/test/support/__init__.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -2382,15 +2382,18 @@ def match_value(self, k, dv, v):
23822382
result = dv.find(v) >= 0
23832383
return result
23842384

2385-
23862385
_can_symlink = None
23872386
def can_symlink():
23882387
global _can_symlink
23892388
if _can_symlink is not None:
23902389
return _can_symlink
2391-
symlink_path = TESTFN + "can_symlink"
2390+
# WASI / wasmtime prevents symlinks with absolute paths, see man
2391+
# openat2(2) RESOLVE_BENEATH. Almost all symlink tests use absolute
2392+
# paths. Skip symlink tests on WASI for now.
2393+
src = os.path.abspath(TESTFN)
2394+
symlink_path = src + "can_symlink"
23922395
try:
2393-
os.symlink(TESTFN, symlink_path)
2396+
os.symlink(src, symlink_path)
23942397
can = True
23952398
except (OSError, NotImplementedError, AttributeError):
23962399
can = False
@@ -2399,6 +2402,7 @@ def can_symlink():
23992402
_can_symlink = can
24002403
return can
24012404

2405+
24022406
def skip_unless_symlink(test):
24032407
"""Skip decorator for tests that require functional symlink"""
24042408
ok = can_symlink()

Lib/test/test_tempfile.py

+98
Original file line numberDiff line numberDiff line change
@@ -1295,6 +1295,7 @@ def __exit__(self, *exc_info):
12951295
d.clear()
12961296
d.update(c)
12971297

1298+
12981299
class TestTemporaryDirectory(BaseTestCase):
12991300
"""Test TemporaryDirectory()."""
13001301

@@ -1355,6 +1356,103 @@ def test_cleanup_with_symlink_to_a_directory(self):
13551356
"were deleted")
13561357
d2.cleanup()
13571358

1359+
@support.skip_unless_symlink
1360+
def test_cleanup_with_symlink_modes(self):
1361+
# cleanup() should not follow symlinks when fixing mode bits (#91133)
1362+
with self.do_create(recurse=0) as d2:
1363+
file1 = os.path.join(d2, 'file1')
1364+
open(file1, 'wb').close()
1365+
dir1 = os.path.join(d2, 'dir1')
1366+
os.mkdir(dir1)
1367+
for mode in range(8):
1368+
mode <<= 6
1369+
with self.subTest(mode=format(mode, '03o')):
1370+
def test(target, target_is_directory):
1371+
d1 = self.do_create(recurse=0)
1372+
symlink = os.path.join(d1.name, 'symlink')
1373+
os.symlink(target, symlink,
1374+
target_is_directory=target_is_directory)
1375+
try:
1376+
os.chmod(symlink, mode, follow_symlinks=False)
1377+
except NotImplementedError:
1378+
pass
1379+
try:
1380+
os.chmod(symlink, mode)
1381+
except FileNotFoundError:
1382+
pass
1383+
os.chmod(d1.name, mode)
1384+
d1.cleanup()
1385+
self.assertFalse(os.path.exists(d1.name))
1386+
1387+
with self.subTest('nonexisting file'):
1388+
test('nonexisting', target_is_directory=False)
1389+
with self.subTest('nonexisting dir'):
1390+
test('nonexisting', target_is_directory=True)
1391+
1392+
with self.subTest('existing file'):
1393+
os.chmod(file1, mode)
1394+
old_mode = os.stat(file1).st_mode
1395+
test(file1, target_is_directory=False)
1396+
new_mode = os.stat(file1).st_mode
1397+
self.assertEqual(new_mode, old_mode,
1398+
'%03o != %03o' % (new_mode, old_mode))
1399+
1400+
with self.subTest('existing dir'):
1401+
os.chmod(dir1, mode)
1402+
old_mode = os.stat(dir1).st_mode
1403+
test(dir1, target_is_directory=True)
1404+
new_mode = os.stat(dir1).st_mode
1405+
self.assertEqual(new_mode, old_mode,
1406+
'%03o != %03o' % (new_mode, old_mode))
1407+
1408+
@unittest.skipUnless(hasattr(os, 'chflags'), 'requires os.chflags')
1409+
@support.skip_unless_symlink
1410+
def test_cleanup_with_symlink_flags(self):
1411+
# cleanup() should not follow symlinks when fixing flags (#91133)
1412+
flags = stat.UF_IMMUTABLE | stat.UF_NOUNLINK
1413+
self.check_flags(flags)
1414+
1415+
with self.do_create(recurse=0) as d2:
1416+
file1 = os.path.join(d2, 'file1')
1417+
open(file1, 'wb').close()
1418+
dir1 = os.path.join(d2, 'dir1')
1419+
os.mkdir(dir1)
1420+
def test(target, target_is_directory):
1421+
d1 = self.do_create(recurse=0)
1422+
symlink = os.path.join(d1.name, 'symlink')
1423+
os.symlink(target, symlink,
1424+
target_is_directory=target_is_directory)
1425+
try:
1426+
os.chflags(symlink, flags, follow_symlinks=False)
1427+
except NotImplementedError:
1428+
pass
1429+
try:
1430+
os.chflags(symlink, flags)
1431+
except FileNotFoundError:
1432+
pass
1433+
os.chflags(d1.name, flags)
1434+
d1.cleanup()
1435+
self.assertFalse(os.path.exists(d1.name))
1436+
1437+
with self.subTest('nonexisting file'):
1438+
test('nonexisting', target_is_directory=False)
1439+
with self.subTest('nonexisting dir'):
1440+
test('nonexisting', target_is_directory=True)
1441+
1442+
with self.subTest('existing file'):
1443+
os.chflags(file1, flags)
1444+
old_flags = os.stat(file1).st_flags
1445+
test(file1, target_is_directory=False)
1446+
new_flags = os.stat(file1).st_flags
1447+
self.assertEqual(new_flags, old_flags)
1448+
1449+
with self.subTest('existing dir'):
1450+
os.chflags(dir1, flags)
1451+
old_flags = os.stat(dir1).st_flags
1452+
test(dir1, target_is_directory=True)
1453+
new_flags = os.stat(dir1).st_flags
1454+
self.assertEqual(new_flags, old_flags)
1455+
13581456
@support.cpython_only
13591457
def test_del_on_collection(self):
13601458
# A TemporaryDirectory is deleted when garbage collected
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix a bug in :class:`tempfile.TemporaryDirectory` cleanup, which now no longer
2+
dereferences symlinks when working around file system permission errors.

0 commit comments

Comments
 (0)