Repository navigation
lang/python314: fix CVE-2026-12345 (tempfile.TemporaryDirectory race) - #1653
Conversation
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>
Reviewer's GuideBackports 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 cleanupsequenceDiagram
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
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
Claude finished @laffer1's task in 47s —— View job Review of PR #1653: lang/python314 CVE backport
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
Findings
Test coverageYour description says the upstream tests were run locally and VerdictNo blocking correctness problems found in the diff itself. Before merging, I'd:
|
There was a problem hiding this comment.
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>| + if dir_fd is None: | ||
| + _resetperms(path) | ||
| + return | ||
| + _resetflags(path) |
There was a problem hiding this comment.
🚨 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.
| + # 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) |
There was a problem hiding this comment.
🚨 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.
| + # 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 | |||
## 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>
Backports the
Lib/shutil.pyandLib/tempfile.pychanges for CVE-2026-12345 from python/cpython#158430, the 3.14 backport of python/cpython#157580.PORTREVISIONis 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 packagesucceeds (python314-3.14.7_1)test_tempfileandtest_shutilchanges were applied locally only, with the missing decorator replaced by an equivalentskipIf. Run asnobody, both pass (346 run, 70 skipped).test_cleanup_with_symlink_racefails against unpatched 3.14.7 and passes with this patchtest_move_dir_permission_deniedalready 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
TemporaryDirectorycleanup resistant to symlink races.Bug Fixes:
TemporaryDirectorycleanup that could follow symlinks and remove or modify unintended files.Build:
Tests: