Skip to content

Commit 58ed025

Browse files
miss-islingtonStanFromIrelandencukou
authored
[3.14] gh-157579: Fix race condition in the cleanup of tempfile.TemporaryDirectory (GH-157580) (GH-158430)
* gh-157579: Fix race condition in the cleanup of `tempfile.TemporaryDirectory` (GH-157580) (cherry picked from commit 5c20517) * Add root user checks to test.support This partially backports commit 86b8617 (GH-146195); making existing tests use the helper is omitted. --------- Co-authored-by: Stan Ulbrych <stan@python.org> Co-authored-by: Petr Viktorin <encukou@gmail.com>
1 parent 21238ec commit 58ed025

7 files changed

Lines changed: 241 additions & 20 deletions

File tree

‎Doc/library/tempfile.rst‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,15 @@ The module defines the following user-callable items:
207207
debugging or when you need your cleanup behavior to be conditional based on
208208
other logic.
209209

210+
.. warning::
211+
212+
Cleanup is not robust against the tree being modified while it is removed.
213+
Files outside of the tree may have their permissions and file flags reset.
214+
215+
On systems where :data:`shutil.rmtree.avoids_symlink_attacks` is
216+
false, manipulating symbolic links during cleanup
217+
may cause files outside of the tree to be removed.
218+
210219
.. audit-event:: tempfile.mkdtemp fullpath tempfile.TemporaryDirectory
211220

212221
.. versionadded:: 3.2

‎Lib/shutil.py‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,7 @@ def _rmtree_safe_fd_step(stack, onexc):
745745
# save a call to os.lstat() when walking subdirectories.
746746
func, dirfd, path, orig_entry = stack.pop()
747747
name = path if orig_entry is None else orig_entry.name
748+
parent_fd = None if func is os.close else dirfd
748749
try:
749750
if func is os.close:
750751
os.close(dirfd)
@@ -792,22 +793,23 @@ def _rmtree_safe_fd_step(stack, onexc):
792793
except FileNotFoundError:
793794
continue
794795
except OSError as err:
795-
onexc(os.unlink, fullname, err)
796+
onexc(os.unlink, fullname, err, direntry=entry, dir_fd=topfd)
796797
except FileNotFoundError as err:
797798
if orig_entry is None or func is os.close:
798799
err.filename = path
799-
onexc(func, path, err)
800+
onexc(func, path, err, direntry=orig_entry, dir_fd=parent_fd)
800801
except OSError as err:
801802
err.filename = path
802-
onexc(func, path, err)
803+
onexc(func, path, err, direntry=orig_entry, dir_fd=parent_fd)
803804

804805
_use_fd_functions = ({os.open, os.stat, os.unlink, os.rmdir} <=
805806
os.supports_dir_fd and
806807
os.scandir in os.supports_fd and
807808
os.stat in os.supports_follow_symlinks)
808809
_rmtree_impl = _rmtree_safe_fd if _use_fd_functions else _rmtree_unsafe
809810

810-
def rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None):
811+
def rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None,
812+
_onexc_kwargs=False):
811813
"""Recursively delete a directory tree.
812814
813815
If dir_fd is not None, it should be a file descriptor open to a directory;
@@ -830,24 +832,29 @@ def rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None):
830832

831833
sys.audit("shutil.rmtree", path, dir_fd)
832834
if ignore_errors:
833-
def onexc(*args):
835+
def onexc(*args, **kwargs):
834836
pass
835837
elif onerror is None and onexc is None:
836-
def onexc(*args):
838+
def onexc(*args, **kwargs):
837839
raise
838840
elif onexc is None:
839841
if onerror is None:
840-
def onexc(*args):
842+
def onexc(*args, **kwargs):
841843
raise
842844
else:
843845
# delegate to onerror
844-
def onexc(*args):
846+
def onexc(*args, **kwargs):
845847
func, path, exc = args
846848
if exc is None:
847849
exc_info = None, None, None
848850
else:
849851
exc_info = type(exc), exc, exc.__traceback__
850852
return onerror(func, path, exc_info)
853+
elif not _onexc_kwargs:
854+
# Only the internal caller in tempfile asks for the extra arguments.
855+
_onexc = onexc
856+
def onexc(func, path, err, **kwargs):
857+
return _onexc(func, path, err)
851858

852859
_rmtree_impl(path, dir_fd, onexc)
853860

‎Lib/tempfile.py‎

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import shutil as _shutil
4444
import errno as _errno
4545
from random import Random as _Random
46+
import stat as _stat
4647
import sys as _sys
4748
import types as _types
4849
import weakref as _weakref
@@ -273,15 +274,68 @@ def _dont_follow_symlinks(func, path, *args):
273274
elif not _os.path.islink(path):
274275
func(path, *args)
275276

276-
def _resetperms(path):
277+
def _resetflags(path):
277278
try:
278279
chflags = _os.chflags
279280
except AttributeError:
280281
pass
281282
else:
282283
_dont_follow_symlinks(chflags, path, 0)
284+
285+
def _resetperms(path):
286+
_resetflags(path)
283287
_dont_follow_symlinks(_os.chmod, path, 0o700)
284288

289+
# True if TemporaryDirectory._rmtree() can work relative to open directories
290+
# instead of resolving paths again.
291+
_rmtree_use_dir_fd = (
292+
{_os.chmod, _os.unlink, _os.lstat} <= _os.supports_dir_fd
293+
and _os.chmod in _os.supports_fd
294+
)
295+
296+
def _resetperms_fd(dir_fd, path):
297+
# Same as _resetperms(), but for the directory referred to by dir_fd.
298+
if dir_fd is None:
299+
_resetperms(path)
300+
return
301+
_resetflags(path)
302+
_os.chmod(dir_fd, 0o700)
303+
304+
try:
305+
_nofollow_mode = _os.O_RDONLY | _os.O_NONBLOCK | _os.O_NOFOLLOW
306+
except AttributeError:
307+
_nofollow_mode = None
308+
309+
def _resetperms_at(name, dir_fd, path):
310+
# Same as _resetperms(), but name is resolved relative to the directory
311+
# file descriptor dir_fd. path is only used for os.chflags(), which
312+
# doesn't support dir_fd or file descriptors.
313+
if dir_fd is None:
314+
_resetperms(path)
315+
return
316+
_resetflags(path)
317+
if _os.chmod in _os.supports_follow_symlinks:
318+
_os.chmod(name, 0o700, dir_fd=dir_fd, follow_symlinks=False)
319+
else:
320+
# dir_fd & follow_symlinks is not supported on this platform.
321+
# Try chmod opening the file with O_NOFOLLOW.
322+
if _nofollow_mode is not None:
323+
try:
324+
fd = _os.open(name, _nofollow_mode, dir_fd=dir_fd)
325+
except OSError:
326+
pass
327+
else:
328+
try:
329+
_os.chmod(fd, 0o700)
330+
finally:
331+
_os.close(fd)
332+
return
333+
# If that did not work, we change by name, which is subject to a race
334+
# condition.
335+
stat = _os.lstat(name, dir_fd=dir_fd)
336+
if not _stat.S_ISLNK(stat.st_mode):
337+
_os.chmod(name, 0o700, dir_fd=dir_fd)
338+
285339

286340
# User visible interfaces.
287341

@@ -913,24 +967,43 @@ def __init__(self, suffix=None, prefix=None, dir=None,
913967
ignore_errors=self._ignore_cleanup_errors, delete=self._delete)
914968

915969
@classmethod
916-
def _rmtree(cls, name, ignore_errors=False, repeated=False):
917-
def onexc(func, path, exc):
970+
def _rmtree(cls, name, ignore_errors=False, repeated=False, dir_fd=None,
971+
fullname=None):
972+
if fullname is None:
973+
fullname = name
974+
975+
def onexc(func, path, exc, direntry=None, dir_fd=None):
918976
# On DragonFly BSD, UF_NOUNLINK removal fails with EISDIR, not EPERM.
919977
if isinstance(exc, (PermissionError, IsADirectoryError)):
920978
if repeated and path == name:
921979
if ignore_errors:
922980
return
923981
raise
924982

983+
# fullpath is path as seen from the working directory
984+
fullpath = fullname + path[len(name):]
985+
# base is path relative to dir_fd, the directory rmtree()
986+
# reached it through, or the whole path when there is none
987+
if dir_fd is None or not _rmtree_use_dir_fd:
988+
base, dir_fd = path, None
989+
elif direntry is None:
990+
base = path
991+
else:
992+
base = direntry.name
993+
925994
try:
926995
if path != name:
927-
_resetperms(_os.path.dirname(path))
928-
_resetperms(path)
996+
# The parent directory of path is the one referred to
997+
# by dir_fd.
998+
_resetperms_fd(dir_fd, _os.path.dirname(fullpath))
999+
_resetperms_at(base, dir_fd, fullpath)
9291000

9301001
try:
931-
_os.unlink(path)
1002+
_os.unlink(base, dir_fd=dir_fd)
9321003
except IsADirectoryError:
933-
cls._rmtree(path, ignore_errors=ignore_errors)
1004+
cls._rmtree(base, ignore_errors=ignore_errors,
1005+
repeated=(path == name),
1006+
dir_fd=dir_fd, fullname=fullpath)
9341007
except PermissionError:
9351008
# The PermissionError handler was originally added for
9361009
# FreeBSD in directories, but it seems that it is raised
@@ -939,21 +1012,27 @@ def onexc(func, path, exc):
9391012
# raise NotADirectoryError and mask the PermissionError.
9401013
# So we must re-raise the current PermissionError if
9411014
# path is not a directory.
942-
if not _os.path.isdir(path) or _os.path.isjunction(path):
1015+
if (not _os.path.isdir(fullpath)
1016+
or _os.path.isjunction(fullpath)):
9431017
if ignore_errors:
9441018
return
9451019
raise
946-
cls._rmtree(path, ignore_errors=ignore_errors,
947-
repeated=(path == name))
1020+
cls._rmtree(base, ignore_errors=ignore_errors,
1021+
repeated=(path == name),
1022+
dir_fd=dir_fd, fullname=fullpath)
9481023
except FileNotFoundError:
9491024
pass
1025+
except OSError:
1026+
if ignore_errors:
1027+
return
1028+
raise
9501029
elif isinstance(exc, FileNotFoundError):
9511030
pass
9521031
else:
9531032
if not ignore_errors:
9541033
raise
9551034

956-
_shutil.rmtree(name, onexc=onexc)
1035+
_shutil.rmtree(name, onexc=onexc, dir_fd=dir_fd, _onexc_kwargs=True)
9571036

9581037
@classmethod
9591038
def _cleanup(cls, name, warn_message, ignore_errors=False, delete=True):

‎Lib/test/support/__init__.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@
7171
"BrokenIter",
7272
"in_systemd_nspawn_sync_suppressed",
7373
"run_no_yield_async_fn", "run_yielding_async_fn", "async_yield",
74-
"reset_code", "on_github_actions"
74+
"reset_code", "on_github_actions",
75+
"requires_root_user", "requires_non_root_user",
7576
]
7677

7778

@@ -3376,3 +3377,7 @@ def skip_on_low_desktop_heap_memory_subprocess(returncode):
33763377
if returncode == STATUS_DLL_INIT_FAILED:
33773378
raise unittest.SkipTest('gh-150436: DLL init failed, likely because '
33783379
'of low desktop heap memory')
3380+
3381+
_ROOT_IN_POSIX = hasattr(os, 'geteuid') and os.geteuid() == 0
3382+
requires_root_user = unittest.skipUnless(_ROOT_IN_POSIX, "test needs root privilege")
3383+
requires_non_root_user = unittest.skipIf(_ROOT_IN_POSIX, "test needs non-root account")

‎Lib/test/test_shutil.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,37 @@ def check_args_to_onexc(self, func, arg, exc):
493493
self.assertTrue(isinstance(exc, OSError))
494494
self.errorState = 3
495495

496+
@os_helper.skip_if_dac_override
497+
@os_helper.skip_unless_working_chmod
498+
@unittest.skipUnless(shutil.rmtree.avoids_symlink_attacks,
499+
'requires the fd based implementation of rmtree()')
500+
def test_on_exc_kwargs(self):
501+
os.mkdir(TESTFN)
502+
self.addCleanup(shutil.rmtree, TESTFN)
503+
504+
child_dir_path = os.path.join(TESTFN, 'b')
505+
child_file_path = os.path.join(child_dir_path, 'a')
506+
os.mkdir(child_dir_path)
507+
os_helper.create_empty_file(child_file_path)
508+
old_child_dir_mode = os.stat(child_dir_path).st_mode
509+
# Make unwritable.
510+
new_mode = stat.S_IREAD|stat.S_IEXEC
511+
os.chmod(child_dir_path, new_mode)
512+
513+
self.addCleanup(os.chmod, child_dir_path, old_child_dir_mode)
514+
515+
calls = []
516+
def onexc(func, path, err, direntry=None, dir_fd=None):
517+
calls.append((func, path, err))
518+
if func is os.unlink:
519+
self.assertEqual(direntry.name, os.path.basename(path))
520+
self.assertTrue(os.path.samestat(
521+
os.stat(path), os.stat(direntry.name, dir_fd=dir_fd)))
522+
523+
shutil.rmtree(TESTFN, onexc=onexc, _onexc_kwargs=True)
524+
self.assertIn((os.unlink, child_file_path),
525+
[(func, path) for func, path, err in calls])
526+
496527
@unittest.skipIf(sys.platform[:6] == 'cygwin',
497528
"This test can't be run on Cygwin (issue #1071513).")
498529
@os_helper.skip_if_dac_override

0 commit comments

Comments
 (0)