Skip to content

3.10.22.2: tempfile.TemporaryDirectory cleanup race fix (CVE-2026-12345) - #89

Merged
icanhasmath merged 4 commits into
3.10.22.xfrom
3.10.22.2-tempfile-race
Oct 28, 2026
Merged

icanhasmath merged 4 commits into
3.10.22.xfrom
3.10.22.2-tempfile-race

Conversation

@icanhasmath

Copy link
Copy Markdown

ActiveState security release 3.10.22.2: fixes CVE-2026-12345 (PSF-2026-42, CVSS 4.0 5.9 Medium), a race condition in tempfile.TemporaryDirectory cleanup.

Stacked on #87 (3.10.22.1-security). Merge #87 first; this PR's base will then need to change to 3.10.22.1's merged base branch.

The vulnerability

When cleanup hits a PermissionError, it resets permissions and retries the removal by resolving the path again. An attacker who can modify the tree during cleanup can swap a directory for a symlink. Cleanup then deletes files, or resets their permissions and flags, outside the temporary directory, with the privileges of the process doing the cleanup. Upstream issue: python#157579. The fix is in main and in 3.12 through 3.15; it was not backported to 3.11.

Commits

  1. bpo-43153 (from 3.11): don't hide a PermissionError behind NotADirectoryError during cleanup. Prerequisite.
  2. TemporaryDirectory clean-up fails with unsearchable directories python/cpython#79325 (from 3.11): stop the endless recursion when the temporary directory itself can't be removed (the repeated guard). Prerequisite.
  3. Race condition in the cleanup of tempfile.TemporaryDirectory python/cpython#157579: the security fix, ported from the 3.12 backport ([3.12] gh-157579: Fix race condition in the cleanup of tempfile.TemporaryDirectory (GH-157580) (GH-158430) python/cpython#159074), plus os.lstat() supports dir_fd but is not in os.supports_dir_fd python/cpython#134993 (os.lstat in os.supports_dir_fd). Files are reset and removed relative to the directory fds that the fd-based shutil.rmtree() walk already holds, instead of by path.
  4. Version bump to 3.10.22.2 and release notes.

How the port differs from 3.12

3.12's fix depends on 3.12's shutil.rmtree() (onexc, a public dir_fd parameter, and the _onexc_kwargs switch). 3.10 only has onerror and a recursive _rmtree_safe_fd(), so:

  • rmtree() gains private keyword arguments _dir_fd and _onerror_kwargs. _rmtree_safe_fd() now passes the parent directory fd and the os.DirEntry along to the error callback.
  • With _onerror_kwargs left false (every existing caller), onerror callbacks still get the same three positional arguments.
  • TemporaryDirectory._rmtree() uses os.lstat(base, dir_fd=...) where 3.12 checks the full path with isdir()/isjunction().

The adaptations are described in the commit message bodies.

Testing (Linux x86_64, local build)

  • Full python -m test suite passes.
  • New tests: test_cleanup_with_symlink_race, test_parent_mode_preserved, test_cleanup_safe (tempfile) and test_on_error_kwargs (shutil).
  • test_cleanup_with_symlink_race fails on the base branch: the file outside the temp directory gets deleted. It passes with this change.
  • Windows doesn't use the fd-based path, so only the prerequisite changes apply there. Windows x64 CI is the check.

@ezequielp-activestate ezequielp-activestate left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blockers. Approving.

This PR is stacked on the still-open #87 (3.10.22.1-security), so it can only land after that one.

What I checked (CPython built from this branch, Linux x86_64, Debian glibc 2.28, tests run as a non-root user):

  • The fix works. I wrote an independent repro: a subdirectory of a TemporaryDirectory is made unwritable so cleanup takes the PermissionError path, and the subdirectory is swapped for a symlink to an outside directory at the handler's first chmod or at its retry unlink.

    base (3.10.22.1-security) this PR
    swap at the handler's chmod outside file deleted untouched
    swap at the retry unlink outside file deleted untouched (cleanup raises NotADirectoryError for the symlink)

    The handler now works only through directory fds (chmod(fd), unlink(name, dir_fd=…)). On this glibc os.chmod is not in supports_follow_symlinks, so _resetperms_at takes the O_NOFOLLOW open + fchmod branch, as upstream does.

  • Matches upstream. The tempfile side is the 3.12 backport (c39b52d) almost line for line. The one deliberate difference uses os.lstat(base, dir_fd=…) instead of the path-based isdir()/isjunction() check, which is the safer form. HAVE_LSTAT is in this branch's posixmodule.c have_functions, so the pythongh-134993 line in os.py takes effect.

  • No later fixes missed. I walked upstream Lib/tempfile.py and Lib/shutil.py on main, 3.14, 3.13 and 3.12 forward from the fix, and nothing later touches it. The only other recent cleanup change, pythongh-154307 (DragonFly BSD), is not security and not on 3.12.

  • shutil compatibility. Existing onerror callbacks still get exactly three positional arguments, because the kwargs are only passed with the private _onerror_kwargs=True. _rmtree_unsafe callers are unaffected.

  • Tests: test_tempfile, test_shutil and test_os pass on this branch and on the base.

Not assessed: Windows (the fd path is not used there), macOS/BSD chflags, and the full test suite.

icanhasmath added a commit that referenced this pull request Oct 28, 2026
Review follow-up.  3.9's TemporaryDirectory.cleanup() only removes the
tree if self._finalizer.detach() succeeds.  The test's first cleanup()
detaches the finalizer even though it raises, so the second cleanup()
in the finally block did nothing and left <tmp>/dir1 behind; regrtest
reported test_tempfile as "env changed".  Remove the tree with
d1._rmtree() instead.  (3.10's cleanup() also retries while the
directory still exists, so #89 is unaffected.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
miss-islington and others added 4 commits October 28, 2026 14:13
…or` during tempdirectory cleanup (pythonGH-29940) (pythonGH-112754)

(cherry picked from commit 8cdfee1)

Co-authored-by: Ken Jin <kenjin@python.org>
Co-authored-by: andrei kulakov <andrei.avk@gmail.com>
Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
(cherry picked from commit 23920a0)
…nup on Windows (pythonGH-112762) (pythonGH-112848)

(cherry picked from commit b2923a6)
(cherry picked from commit a18201b)
….TemporaryDirectory (CVE-2026-12345)

When TemporaryDirectory cleanup recovers from a PermissionError it resets
permissions and retries removal. It did that by resolving paths again,
so an attacker who could modify the tree during cleanup could replace a
directory with a symlink and make cleanup delete files, or reset their
permissions and flags, outside of the temporary directory. Files are now
handled relative to the open directory file descriptors that the
fd-based shutil.rmtree() walk already holds.

Port of the upstream fix (pythonGH-157580, pythonGH-158430), based on the 3.12
backport (python#159074), plus pythongh-134993 (os.lstat in os.supports_dir_fd).

3.10.22.x: the 3.12 fix relies on 3.12's shutil.rmtree() API
(onexc, a public dir_fd parameter, and the _onexc_kwargs switch that
passes direntry/dir_fd to the callback). 3.10 only has onerror and a
recursive _rmtree_safe_fd(), so this adds the private keyword arguments
_dir_fd and _onerror_kwargs to rmtree() instead and threads the parent
directory fd and os.DirEntry through _rmtree_safe_fd(). With
_onerror_kwargs left false, existing onerror callbacks get the same
three positional arguments as before. TemporaryDirectory._rmtree() is
ported onto the 3.11 shape of the code (bpo-43153 and pythongh-79325 are
picked first), using os.lstat(base, dir_fd=...) where 3.12 uses
os.path.isdir()/isjunction() on the full path. Tests are adapted:
onerror instead of onexc, local root and platform skips instead of
support.requires_non_root_user and PY_SUPPORT_TIER.

(cherry picked from commit c39b52d)
(cherry picked from commit 5c20517)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ActiveState security release of the end-of-life 3.10 line: the
pythongh-157579 tempfile.TemporaryDirectory cleanup race fix (CVE-2026-12345),
ported from 3.12, with its prerequisites bpo-43153 and pythongh-79325.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@icanhasmath
icanhasmath force-pushed the 3.10.22.2-tempfile-race branch from b1cc1e4 to fe67860 Compare October 28, 2026 19:25
icanhasmath added a commit that referenced this pull request Oct 28, 2026
Review follow-up.  3.9's TemporaryDirectory.cleanup() only removes the
tree if self._finalizer.detach() succeeds.  The test's first cleanup()
detaches the finalizer even though it raises, so the second cleanup()
in the finally block did nothing and left <tmp>/dir1 behind; regrtest
reported test_tempfile as "env changed".  Remove the tree with
d1._rmtree() instead.  (3.10's cleanup() also retries while the
directory still exists, so #89 is unaffected.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@icanhasmath
icanhasmath changed the base branch from 3.10.22.1-security to 3.10.22.x October 28, 2026 19:25
@icanhasmath

Copy link
Copy Markdown
Author

Rebased onto the release branch and retargeted, after #87/#88 (and #109/#110/#112) merged and v3.10.22.1 / v3.9.25.1 were tagged. The old base branch held the pre-rebase copies of those commits.

  • Conflicts: none. The added and removed lines are identical to the reviewed version; only context and line numbers moved.
  • Tests: rebuilt on the new base. test_tempfile, test_shutil, test_os, test_ensurepip and test_sys pass, and the tests leave nothing in TMPDIR.

@icanhasmath
icanhasmath merged commit 5508c77 into 3.10.22.x Oct 28, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants