Skip to content

lang/python314: fix CVE-2026-12345 (tempfile.TemporaryDirectory race) - #1653

Merged
laffer1 merged 1 commit into
masterfrom
lang/python314
Sep 30, 2026
Merged

laffer1 merged 1 commit into
masterfrom
lang/python314

Conversation

@laffer1

@laffer1 laffer1 commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Backports the Lib/shutil.py and Lib/tempfile.py changes for CVE-2026-12345 from python/cpython#158430, the 3.14 backport of python/cpython#157580. PORTREVISION is bumped to 1.

Note: the upstream 3.14 backport PR was still open with failing CI when this was written. If it changes before merging, this patch should be refreshed, or dropped once 3.14.8 ships.

The upstream test changes are not included, because they rely on test.support.requires_non_root_user, which exists only on main.

Testing (MidnightBSD 4.0.6 amd64):

  • bmake patch build fake package succeeds (python314-3.14.7_1)
  • The upstream test_tempfile and test_shutil changes were applied locally only, with the missing decorator replaced by an equivalent skipIf. Run as nobody, both pass (346 run, 70 skipped).
  • The new test_cleanup_with_symlink_race fails against unpatched 3.14.7 and passes with this patch
  • The chflags tests skip because ZFS returns EOPNOTSUPP for those flags
  • test_move_dir_permission_denied already errors when run as root on unpatched 3.14.7, so that failure is not caused by this change

🤖 Generated with Claude Code

Summary by Sourcery

Backport the upstream security fixes that make TemporaryDirectory cleanup resistant to symlink races.

Bug Fixes:

  • Fix a race condition during TemporaryDirectory cleanup that could follow symlinks and remove or modify unintended files.
  • Improve recursive cleanup error handling and permission restoration for safer directory-tree removal.

Build:

  • Bump the python314 port revision to 1.

Tests:

  • Validate the cleanup race fix and related tempfile/shutil behavior, including non-root execution.

Backport the Lib/shutil.py and Lib/tempfile.py changes from
python/cpython#158430 (3.14 backport of gh-157580). TemporaryDirectory
cleanup could be redirected through a symlink swapped in during
removal, deleting files or resetting permissions/flags outside the
temporary directory.

The upstream test changes are not included: they depend on
test.support.requires_non_root_user, which is only on main.

AI-Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Lucas Holt <luke@foolishgames.com>
@sourcery-ai

sourcery-ai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Backports the CPython 3.14 CVE fix by extending shutil.rmtree’s internal error context and updating TemporaryDirectory cleanup to use descriptor-relative, symlink-resistant operations, with compatibility fallbacks and a package revision bump.

Sequence diagram for symlink-resistant TemporaryDirectory cleanup

sequenceDiagram
    participant TD as TemporaryDirectory
    participant SH as shutil.rmtree
    participant FS as Filesystem

    TD->>SH: _rmtree(name, dir_fd, fullname)
    SH->>FS: Traverse using directory descriptors
    FS-->>SH: Error with direntry and dir_fd context
    SH-->>TD: onexc(func, path, exc, direntry, dir_fd)
    TD->>FS: _resetperms_at(base, dir_fd, fullname)
    TD->>FS: unlink(base, dir_fd=dir_fd)
    alt Entry is a directory
        TD->>TD: _rmtree(base, dir_fd, fullname)
    end
Loading

File-Level Changes

Change Details Files
Harden recursive deletion error handling so TemporaryDirectory can receive filesystem context while preserving public callback compatibility.
  • Pass directory-entry and parent file-descriptor context from the safe descriptor-based rmtree walker.
  • Allow internal callbacks to accept contextual keyword arguments while adapting legacy onexc/onerror callbacks.
  • Add an internal switch for enabling the extended callback contract.
lang/python314/files/patch-Lib_shutil.py
Make TemporaryDirectory cleanup resilient to symlink races by operating relative to traversed directory descriptors where supported.
  • Add descriptor-relative permission reset helpers with chflags, chmod, lstat, and O_NOFOLLOW fallbacks.
  • Track directory descriptors and full paths during recursive cleanup, using entry names for race-resistant unlink and chmod operations.
  • Handle permission, directory, and cleanup errors consistently with ignore-errors and repeated-cleanup behavior.
lang/python314/files/patch-Lib_tempfile.py
Register the security fix as a package revision update.
  • Increment PORTREVISION from 0 to 1.
lang/python314/Makefile

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Claude finished @laffer1's task in 47s —— View job


Review of PR #1653: lang/python314 CVE backport

  • Read the diff (Makefile, patch-Lib_shutil.py, patch-Lib_tempfile.py)
  • Check conventions, correctness and compatibility

I only read the diff. I couldn't build the port or apply the patches here, because the shell sandbox blocked compound commands and the source tarball isn't available locally. I did not verify anything by running it.

Conventions

  • Patch names follow the patch-Lib_shutil.py / patch-Lib_tempfile.py convention and sit in files/.
  • PORTREVISION 0 → 1 is correct for a patch-only change.
  • There are no license, pkg-plist or pkg-descr changes, so none of that applies.
  • No credentials or unsafe fetches, and no arch-specific code. Nothing drops ARM, RISC-V or MIPS.
  • I did not check Mk/ for a hunk-offset or fuzz problem, because I couldn't run bmake patch. Your description says it builds, so I'm taking that as given.

Findings

  1. Hand-edited hunk headers (nit). In both patches some @@ hunks carry function-context text that doesn't match where the hunk sits:

    • patch-Lib_shutil.py: the third hunk header reads _rmtree_impl = _rmtree_safe_fd if _use_fd_functions el, and the same line also appears in the hunk body.
    • patch-Lib_tempfile.py: the first hunk header reads from random import Random as _Random, and that line is also in the body.

    patch(1) ignores this text, so it's harmless. It suggests the patches were regenerated or edited by hand. Regenerate them with bmake makepatch so they are clean and reproducible.

  2. Private kwarg on public API (design, worth a note). _onexc_kwargs=False is added to the public shutil.rmtree() signature. This is a _-prefixed internal argument, but it does change the visible signature of a stdlib function in a downstream port. Please confirm it matches upstream CPython PR #158430 exactly. I can't check that from here. If the upstream PR uses a different mechanism, this port will diverge and cause a merge conflict or behaviour difference when 3.14.8 lands.

  3. Fallback paths keep the race (informational).

    • onexc(os.lstat/os.open, …) calls inside _rmtree_safe_fd don't pass dir_fd. In that case _resetperms_fd and _resetperms_at fall back to path-based _resetperms.
    • _resetflags() always uses the path-based os.chflags.
    • The last-resort lstat then chmod in _resetperms_at is explicitly racy.

    These are limitations of the upstream design, and the patch comments say so. On MidnightBSD (FreeBSD 13 base) chmod supports dir_fd, fd and follow_symlinks, so the main fd path should be used. As far as I can tell from the diff, only the chflags step remains racy.

  4. Unmerged upstream (risk). The description says the upstream 3.14 backport was still open with failing CI. Shipping an unmerged security patch in a stdlib module risks divergence, so I'd suggest:

    • adding a comment in the Makefile, or noting it in the commit message, saying to drop the patch once 3.14.8 ships;
    • re-checking the upstream PR head before merging.
  5. CVE id not verifiable. I can't confirm that CVE-2026-12345 is a real, assigned ID. The 12345 looks like a placeholder. Please double-check it before it goes into the commit history. The repo's AGENTS.md also asks for a git commit -s sign-off and an AI-Assisted-by: trailer on AI-assisted commits. Please verify the existing commit has these.

Test coverage

Your description says the upstream tests were run locally and test_cleanup_with_symlink_race fails before the patch and passes after. That is good evidence. The tests aren't in the tree because requires_non_root_user doesn't exist in 3.14, which is reasonable.

Verdict

No blocking correctness problems found in the diff itself. Before merging, I'd:

  • regenerate the patches with bmake makepatch (finding 1);
  • confirm they match upstream (finding 2);
  • confirm the CVE id (finding 5);
  • run portlint if it's available.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="lang/python314/files/patch-Lib_tempfile.py" line_range="40" />
<code_context>
+         _dont_follow_symlinks(chflags, path, 0)
++
++def _resetperms(path):
++    _resetflags(path)
+     _dont_follow_symlinks(_os.chmod, path, 0o700)
+ 
</code_context>
<issue_to_address>
**🚨 issue (security):** The descriptor-relative cleanup path still calls `_resetflags(path)`, which performs a path-based `lstat`/`chflags` sequence without a directory descriptor. If the path is replaced by a symlink between those operations, `chflags` follows the replacement and modifies flags on a file outside the temporary directory, so the CVE fix remains bypassable on systems providing `chflags`.

**Triggers:** When the host provides `os.chflags` and an attacker can swap a cleanup entry between the symlink check and `chflags`.

**Suggested fix:** Avoid path-based flag changes in the descriptor-relative path, or use a no-follow descriptor/operation for `chflags`; otherwise explicitly disable the secure path on platforms where flags cannot be changed safely.
</issue_to_address>

### Comment 2
<location path="lang/python314/files/patch-Lib_tempfile.py" line_range="72-76" />
<code_context>
++                finally:
++                    _os.close(fd)
++                return
++        # If that did not work, we change by name, which is subject to a race
++        # condition.
++        stat = _os.lstat(name, dir_fd=dir_fd)
++        if not _stat.S_ISLNK(stat.st_mode):
++            _os.chmod(name, 0o700, dir_fd=dir_fd)
++
++
+ # User visible interfaces.
</code_context>
<issue_to_address>
**🚨 issue (security):** When `os.chmod` lacks `follow_symlinks` support and `_nofollow_mode` is unavailable, `_resetperms_at` falls back to `lstat` followed by path-based `chmod`. A symlink replacement between those calls causes permissions to be changed on the symlink target outside the temporary directory.

**Triggers:** On a platform where descriptor-relative cleanup is enabled but `O_NOFOLLOW` or equivalent safe opening is unavailable.

**Suggested fix:** Treat the inability to perform a no-follow chmod as an unrecoverable cleanup error, or use a platform-specific no-follow descriptor operation instead of the final path-based `chmod`.

```suggestion
+        raise OSError("Cannot safely reset permissions")
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨

+ if dir_fd is None:
+ _resetperms(path)
+ return
+ _resetflags(path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 issue (security): The descriptor-relative cleanup path still calls _resetflags(path), which performs a path-based lstat/chflags sequence without a directory descriptor. If the path is replaced by a symlink between those operations, chflags follows the replacement and modifies flags on a file outside the temporary directory, so the CVE fix remains bypassable on systems providing chflags.

Triggers: When the host provides os.chflags and an attacker can swap a cleanup entry between the symlink check and chflags.

Suggested fix: Avoid path-based flag changes in the descriptor-relative path, or use a no-follow descriptor/operation for chflags; otherwise explicitly disable the secure path on platforms where flags cannot be changed safely.

Comment on lines +72 to +76
+ # If that did not work, we change by name, which is subject to a race
+ # condition.
+ stat = _os.lstat(name, dir_fd=dir_fd)
+ if not _stat.S_ISLNK(stat.st_mode):
+ _os.chmod(name, 0o700, dir_fd=dir_fd)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 issue (security): When os.chmod lacks follow_symlinks support and _nofollow_mode is unavailable, _resetperms_at falls back to lstat followed by path-based chmod. A symlink replacement between those calls causes permissions to be changed on the symlink target outside the temporary directory.

Triggers: On a platform where descriptor-relative cleanup is enabled but O_NOFOLLOW or equivalent safe opening is unavailable.

Suggested fix: Treat the inability to perform a no-follow chmod as an unrecoverable cleanup error, or use a platform-specific no-follow descriptor operation instead of the final path-based chmod.

Suggested change
+ # If that did not work, we change by name, which is subject to a race
+ # condition.
+ stat = _os.lstat(name, dir_fd=dir_fd)
+ if not _stat.S_ISLNK(stat.st_mode):
+ _os.chmod(name, 0o700, dir_fd=dir_fd)
+ raise OSError("Cannot safely reset permissions")

@@ -0,0 +1,72 @@
--- Lib/shutil.py.orig 2026-09-29 20:36:21 UTC
@@ -0,0 +1,163 @@
--- Lib/tempfile.py.orig 2026-09-29 20:36:21 UTC
@laffer1
laffer1 merged commit 588826a into master Sep 30, 2026
9 of 10 checks passed
@laffer1
laffer1 deleted the lang/python314 branch September 30, 2026 01:29
laffer1 added a commit that referenced this pull request Oct 23, 2026
## Summary

New port `lang/python315` for Python 3.15.1, released 2026-10-21. The
distfile SHA256 matches the python.org release API. The port is modeled
on `lang/python314` and FreeBSD's `lang/python315`. `PYTHON_DEFAULT`
stays at 3.12.

Changes compared with `lang/python314`:
- **`USES+=gmake`**: 3.15's `Makefile.pre.in` uses GNU make `$(if ...)`
for `PY_CORE_EXE_LDFLAGS`. Under bmake that expands to nothing. FreeBSD
added gmake to python315 too.
- **LIBMPDEC option now uses `CONFIGURE_WITH`**: 3.15 removed the
implicit fallback to the bundled libmpdec, so with the option OFF the
port has to pass `--without-system-libmpdec` explicitly. The default
(ON) is unchanged.
- **`--with-missing-stdlib-config`** (new in 3.15):
`files/missing-stdlib.json.in` replaces the generic "module not found"
message. Importing a split-out module now names the port to install:
`ModuleNotFoundError: Standard library module '_sqlite3' is packaged
separately, install it with: mport install py315-sqlite3`
- **`build-details.json`** (PEP 739) is installed in post-install. Since
3.15, `altinstall` no longer ships it; python314 does.
- **LIBPC fix**: post-patch points `LIBPC` at `$(prefix)` and the
`-${CP}` of the `.pc` files is gone. This is the same fix as the
python314 3.14.9 update (#1965). Before it, fake left a doubled stage
tree that `mport.check-fake` reported as missing from the plist.
- Dropped the sparc64 `CFLAGS` block and `OPTIONS_EXCLUDE_powerpc64`.
Fixed the tab-plus-space after `DISABLED_EXTENSIONS=` that portlint
reports as FATAL.
- `pkg-plist` was generated from a clean fake, in python314's
`%%XYDOT%%`/`%%XY%%`/`%%ABI%%`/`%%SUPPORTED_OPENSSL%%` style.
Forward-expanding it matches the staged tree exactly. New entries
include the `profiling` package (Tachyon), `_math_integer`,
`_missing_stdlib_info.py` and `xxlimited_3_13`. `sre_compile`,
`sre_constants`, `sre_parse` and the `_asyncio` shared module (now built
in) are gone.

## Patches

| Patch | Fate |
|---|---|
| `patch-Lib_shutil.py`, `patch-Lib_tempfile.py` (CVE-2026-12345
backport from #1653) | **Dropped**. Upstream in 3.15.1 as gh-157579;
both reverse-apply cleanly. |
| `patch-configure` (MidnightBSD in
MACHDEP/MULTIARCH/LDLIBRARY/LDSHARED/CCSHARED/LINKFORSHARED/x11/_scproxy)
| **Kept**, regenerated with `makepatch` (offsets only). 3.15's
configure has the same set of `FreeBSD*` cases as 3.14.7. |
| `patch-Makefile.pre.in` (respect user CFLAGS/LDFLAGS; move
`-config`/`.pc` symlinks to libainstall) | **Kept**, regenerated
(offsets only). Same as FreeBSD python315. |
| `patch-Misc_python-config.sh.in` (realpath) | **Kept** unchanged. Same
as FreeBSD. |
| `patch-Modules___posixsubprocess.c`, `patch-Modules_posixmodule.c`
(`__MidnightBSD__`) | **Kept**, regenerated (offsets only). Base clang
also defines `__FreeBSD__`, so these are belt-and-braces; kept to match
python314. |
| FreeBSD `patch-Lib_test_test__os_test__posix.py` | **Not imported**.
Test-only, and it keys on `sys.platform == 'freebsd'`, which never
matches `midnightbsd`. |

## Mk changes and their effect on existing ports

- `Mk/extensions/python.mk`: `_PYTHON_VERSIONS` gains `3.15` after
`3.14` (`3.11 3.12 3.13 3.13t 3.14 3.15 3.10 2.7`).
- `Mk/components/default-versions.mk`: comment lists 3.15.
- `lang/Makefile`: `SUBDIR += python315`.
- No change to `Mk/bsd.mport.mk` and no pod change (no public variable
added or changed).

Effects, checked with `bmake -V` before and after:
- Default builds are unchanged. devel/py-setuptools,
textproc/py-pygments, devel/py-wheel and devel/meson keep
`FLAVOR=py312`, the same PKGNAME and the same RUN_DEPENDS.
- The 153 `USE_PYTHON=allflavors` ports gain `py315` at the end of
FLAVORS, after py314. Their default flavor is unchanged. Ports without
allflavors still have only `py312`; `FLAVOR=py315` gives "Unknown
flavor" there unless `BUILD_ALL_PYTHON_FLAVORS` is set.
- `bmake FLAVOR=py315` works on allflavors ports, e.g.
`py315-setuptools-63.1.0_5` with `PYTHON_REL=31501` and depends on
`/usr/local/bin/python3.15:lang/python315`.
- With a dummy port, `USES=python:3.15+` and `python:3.15` now resolve
to 3.15; before, nothing satisfied them and the port was set to IGNORE.
`3.13+` still resolves to 3.13 and `3.10+` to 3.12.

## Testing

On 4.0.9 amd64 (stargazer), built against OpenSSL 3.0.20
(`OPENSSL_SHLIBVER=12 DEPENDS_TARGET=refuse-dependency-install`):
- `makesum`, `patch`, `build`, `fake` and `package` all pass from a
clean work dir. Fake succeeds with no "not installed" and no "missing
from the plist" lines. The result is `python315-3.15.1.mport`. Module
check: 116 modules, 0 missing, 0 failed on import; `_gdbm`, `_sqlite3`
and `_tkinter` are disabled on purpose, and `_scproxy` is n/a.
- `portlint -AC`: 0 fatal errors. The 5 warnings are the same ones
python314 gets.
- `bmake check-license`: OK (`python`).
- Smoke test of the staged interpreter: `ssl, ctypes, readline, zlib,
lzma, bz2, decimal (libmpdec 4.0.0), compression.zstd, math.integer,
profiling.sampling` import fine. `frozendict` and `sentinel` builtins
exist, `lazy import` works, and `sys.flags.utf8_mode == 1`. `import
sqlite3` fails as intended with the new message.
- `-m test test_tempfile test_shutil test_ssl test_sqlite3 test_posix
test_subprocess test_os test_decimal test_ctypes test_readline
test_zstd`:
- Pass: test_tempfile, test_ssl, test_decimal, test_ctypes,
test_readline, test_zstd, test_os.test_os.
  - test_sqlite3 skipped (split-out module).
- test_subprocess fails only because child processes lose
`LD_LIBRARY_PATH` when run from the stage directory.
- test_os.test_posix fails 7 tests: fallocate EINVAL on ZFS,
sched_*affinity(-1), posix_spawn setscheduler. test_shutil fails 1:
`lchflags(UF_OPAQUE)` EOPNOTSUPP on ZFS. These come from FreeBSD-only
skips that `sys.platform == 'midnightbsd'` doesn't hit. The installed
python3.12 fails the same way (6 failures in test_posix + test_shutil),
so they are not new.

## Follow-ups (not in this PR)

- No free-threaded `lang/python315t` variant (3.15 adds a stable ABI for
free-threaded builds).
- `lang/python-doc-*` is not updated for 3.15.
- py315 builds of databases/py-sqlite3, databases/py-gdbm and
x11-toolkits/py-tkinter have not been tried.
- Experimental JIT (`--enable-experimental-jit`, needs LLVM at build
time) is not offered as an option.
- configure's `_PYTHREAD_NAME_MAXLEN` case only lists `FreeBSD*`, as in
python314. MidnightBSD falls back to the default.
- Test-suite platform checks for midnightbsd (above) could be patched in
all lang/python3* ports.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

## Summary by Sourcery

Add Python 3.15.1 support and integrate it into the ports framework
without changing the default Python version.

New Features:
- Add a Python 3.15.1 port with MidnightBSD-specific integration,
packaging, and split standard-library module guidance.

Bug Fixes:
- Ensure Python 3.15 builds correctly with GNU make, explicit libmpdec
configuration, correct staged pkg-config paths, and MidnightBSD platform
support.

Enhancements:
- Extend Python version and flavor resolution to support Python 3.15
while keeping Python 3.12 as the default.
- Package Python 3.15 runtime metadata and newly introduced
standard-library components with a generated plist.

Tests:
- Validate the new port through build, fake, package, module-import, and
selected Python test-suite checks.

Chores:
- Register python315 in the language ports collection and update
supported-version metadata.

Signed-off-by: Lucas Holt <luke@foolishgames.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

2 participants