Visitar URL original
gh-157579: Fix race condition in the cleanup of `tempfile.TemporaryDirectory` by StanFromIreland · Pull Request #157580 · python/cpython · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ff74078
Fix `tempfile.TemporaryDirectory` cleanup race condition
StanFromIreland Aug 14, 2026
0fd04ae
Pass descriptors to `onexc` instead of re-walking
StanFromIreland Aug 26, 2026
55c0c1f
Petr's review
StanFromIreland Aug 28, 2026
b4c1865
Add regression check for _rmtree_use_dir_fd=True
encukou Sep 1, 2026
51c9c6a
Avoid infinite recursion if directory removal fails
encukou Sep 1, 2026
bbbb669
Bring back chmod by name (avoid regression); use LBYL
encukou Sep 1, 2026
a9a993e
Bring back chmod(open(nofollow))
encukou Sep 1, 2026
f524182
Add warning for Windows
encukou Sep 1, 2026
d4e5045
Use lstat; check for it
encukou Sep 1, 2026
4a9eb37
Test that cleanup() does not chmod the parent directory
encukou Sep 1, 2026
4a2ab25
Catch OSError on ignore_errors
encukou Sep 1, 2026
99c6f63
Apply suggestion from @StanFromIreland
encukou Sep 8, 2026
ffcf4bb
Match else: to try:, and don't catch AttributeError
encukou Sep 8, 2026
7994121
Use new issue number.
StanFromIreland Sep 15, 2026
10c4541
Merge branch 'main' into tempfile-toctou-2
StanFromIreland Sep 15, 2026
a837624
Add WASI skip
StanFromIreland Sep 15, 2026
4baeeb1
Update Lib/test/test_tempfile.py
StanFromIreland Sep 17, 2026
c12bec3
Revert "Update Lib/test/test_tempfile.py"
StanFromIreland Sep 17, 2026
de46e06
Make dir1 writeable before rename
StanFromIreland Sep 17, 2026
d8aab2b
Add skip for Emscripten
StanFromIreland Sep 22, 2026
9992aae
Fix case
encukou Sep 22, 2026
7568089
Merge branch 'main' into tempfile-toctou-2
encukou Sep 22, 2026
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
Next Next commit
Fix tempfile.TemporaryDirectory cleanup race condition
  • Loading branch information
StanFromIreland committed Aug 14, 2026
commit ff74078286c4b6f5e32d703fad0772d6b2deba24
136 changes: 113 additions & 23 deletions Lib/tempfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import shutil as _shutil
import errno as _errno
from random import Random as _Random
import stat as _stat
import sys as _sys
import types as _types
import weakref as _weakref
Expand Down Expand Up @@ -274,15 +275,81 @@ def _dont_follow_symlinks(func, path, *args):
elif not _os.path.islink(path):
func(path, *args)

def _resetperms(path):
def _resetflags(path):
try:
chflags = _os.chflags
except AttributeError:
pass
else:
_dont_follow_symlinks(chflags, path, 0)

def _resetperms(path):
_resetflags(path)
_dont_follow_symlinks(_os.chmod, path, 0o700)

# True if TemporaryDirectory._rmtree() can work relative to open directories
# instead of resolving paths again.
_rmtree_use_dir_fd = (
_shutil.rmtree.avoids_symlink_attacks
and {_os.open, _os.stat, _os.chmod, _os.unlink} <= _os.supports_dir_fd
and _os.chmod in _os.supports_fd
and hasattr(_os, 'O_NOFOLLOW')
)

def _open_nofollow(name, dir_fd=None):
# Open name relative to dir_fd, failing if its last component is a symlink.
return _os.open(name, _os.O_RDONLY | _os.O_NONBLOCK | _os.O_NOFOLLOW,
dir_fd=dir_fd)

def _open_containing_dir(root, path, dir_fd):
# Open the directory containing path, which must be a path under root,
# resolving every component below root without following symlinks.
*names, base = path[len(root):].lstrip(_os.sep).split(_os.sep)
fd = _open_nofollow(root, dir_fd)
try:
for name in names:
newfd = _open_nofollow(name, fd)
_os.close(fd)
fd = newfd
except OSError:
_os.close(fd)
raise
return fd, base

def _resetperms_fd(dir_fd, path):
# Same as _resetperms(), but for the directory referred to by dir_fd.
if dir_fd is None:
_resetperms(path)
return
_resetflags(path)
_os.chmod(dir_fd, 0o700)

def _resetperms_at(name, dir_fd, path):
# Same as _resetperms(), but name is resolved relative to the directory
# file descriptor dir_fd. It is only used for os.chflags(), which dosen't
# supports dir_fd or file descriptors.
if dir_fd is None:
_resetperms(path)
return
_resetflags(path)
try:
fd = _open_nofollow(name, dir_fd)
except OSError:
pass
else:
try:
_os.chmod(fd, 0o700)
finally:
_os.close(fd)
return
try:
_os.chmod(name, 0o700, dir_fd=dir_fd, follow_symlinks=False)
except (NotImplementedError, ValueError, OSError):
# Not supported for this file on this platform. So we look for a symlink
# first, which is subject to a race condition.
if not _stat.S_ISLNK(_os.stat(name, dir_fd=dir_fd, follow_symlinks=False).st_mode):
_os.chmod(name, 0o700, dir_fd=dir_fd)


# User visible interfaces.

Expand Down Expand Up @@ -927,7 +994,11 @@ def __init__(self, suffix=None, prefix=None, dir=None,
ignore_errors=self._ignore_cleanup_errors, delete=self._delete)

@classmethod
def _rmtree(cls, name, ignore_errors=False, repeated=False):
def _rmtree(cls, name, ignore_errors=False, repeated=False, dir_fd=None,
fullname=None):
if fullname is None:
fullname = name

def onexc(func, path, exc):
# On DragonFly BSD, UF_NOUNLINK removal fails with EISDIR, not EPERM.
if isinstance(exc, (PermissionError, IsADirectoryError)):
Expand All @@ -936,29 +1007,48 @@ def onexc(func, path, exc):
return
raise

fullpath = fullname + path[len(name):]
try:
if path != name:
_resetperms(_os.path.dirname(path))
_resetperms(path)
# Resolve the components of path below name again, but
# without following symlinks, so that replacing one of them
# with a symlink cannot make the recovery below escape the
# tree rooted at name (CVE-2026-12345).
if path != name and _rmtree_use_dir_fd:
parent_fd, base = _open_containing_dir(name, path, dir_fd)
else:
parent_fd, base = dir_fd, path

try:
_os.unlink(path)
except IsADirectoryError:
cls._rmtree(path, ignore_errors=ignore_errors)
except PermissionError:
# The PermissionError handler was originally added for
# FreeBSD in directories, but it seems that it is raised
# on Windows too.
# bpo-43153: Calling _rmtree again may
# raise NotADirectoryError and mask the PermissionError.
# So we must re-raise the current PermissionError if
# path is not a directory.
if not _os.path.isdir(path) or _os.path.isjunction(path):
if ignore_errors:
return
raise
cls._rmtree(path, ignore_errors=ignore_errors,
repeated=(path == name))
if path != name:
# The parent directory of path is the one referred
# to by parent_fd.
_resetperms_fd(parent_fd,
_os.path.dirname(fullpath))
_resetperms_at(base, parent_fd, fullpath)

try:
_os.unlink(base, dir_fd=parent_fd)
except IsADirectoryError:
cls._rmtree(base, ignore_errors=ignore_errors,
dir_fd=parent_fd, fullname=fullpath)
except PermissionError:
# The PermissionError handler was originally added for
# FreeBSD in directories, but it seems that it is raised
# on Windows too.
# bpo-43153: Calling _rmtree again may
# raise NotADirectoryError and mask the PermissionError.
# So we must re-raise the current PermissionError if
# path is not a directory.
if not _os.path.isdir(fullpath) or _os.path.isjunction(fullpath):
if ignore_errors:
return
raise
cls._rmtree(base, ignore_errors=ignore_errors,
repeated=(path == name),
dir_fd=parent_fd, fullname=fullpath)
finally:
if parent_fd != dir_fd:
_os.close(parent_fd)
except FileNotFoundError:
pass
elif isinstance(exc, FileNotFoundError):
Expand All @@ -967,7 +1057,7 @@ def onexc(func, path, exc):
if not ignore_errors:
raise

_shutil.rmtree(name, onexc=onexc)
_shutil.rmtree(name, onexc=onexc, dir_fd=dir_fd)

@classmethod
def _cleanup(cls, name, warn_message, ignore_errors=False, delete=True):
Expand Down
45 changes: 45 additions & 0 deletions Lib/test/test_tempfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -1861,6 +1861,51 @@ def test(target, target_is_directory):
new_flags = os.stat(dir1).st_flags
self.assertEqual(new_flags, old_flags)

@os_helper.skip_unless_symlink
@os_helper.skip_unless_working_chmod
@support.requires_non_root_user
@unittest.skipUnless(shutil.rmtree.avoids_symlink_attacks,
'requires the fd based implementation of rmtree()')
def test_cleanup_with_symlink_race(self):
# cleanup() should not operate on files outside of the temporary
# directory when a directory is replaced with a symlink while it
# recovers from a PermissionError (CVE-2026-12345).
with self.do_create(recurse=0) as target:
target_file = os.path.join(target, 'file1')
open(target_file, 'wb').close()
target_mode = os.stat(target_file).st_mode

d1 = self.do_create(recurse=0)
dir1 = os.path.join(d1.name, 'dir1')
os.mkdir(dir1)
open(os.path.join(dir1, 'file1'), 'wb').close()
# Removing contents of dir1 fails with a PermissionError, and
# dir1 is replaced with a symlink to target at the very moment
# cleanup() starts to recover from that error.
os.chmod(dir1, 0o500)
unlink = os.unlink
def hook(path, *, dir_fd=None):
try:
return unlink(path, dir_fd=dir_fd)
except PermissionError:
if not os.path.islink(dir1):
os.rename(dir1, dir1 + '_moved')
os.symlink(target, dir1)
raise
try:
with mock.patch('os.unlink', hook):
with contextlib.suppress(OSError):
d1.cleanup()
finally:
if os.path.islink(dir1):
os.unlink(dir1)
os.rename(dir1 + '_moved', dir1)
os.chmod(dir1, 0o700)
Comment thread
StanFromIreland marked this conversation as resolved.
d1.cleanup()

self.assertTrue(os.path.exists(target_file))
self.assertEqual(os.stat(target_file).st_mode, target_mode)

@support.cpython_only
def test_del_on_collection(self):
# A TemporaryDirectory is deleted when garbage collected
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Fix a race condition in the cleanup of :class:`tempfile.TemporaryDirectory`.
When working around file system permission errors, permissions are now reset
and files removed relative to open directory file descriptors instead of
resolving their path again, so that replacing a directory of the tree with a
symbolic link can no longer make the cleanup change the mode of, or delete,
files outside of the temporary directory. This addresses :cve:`2026-12345`.