Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
f601e56
tmpdir: prevent symlink attacks and TOCTOU races (CVE-2025-71176)
Mar 9, 2026
ec18caa
docs: added a bugfix changelog entry
Mar 9, 2026
b3cb812
chore: added name to `AUTHORS` file
Mar 9, 2026
5894e25
chore: adding test coverage
Mar 9, 2026
7f93f0a
chore: Add tests for tmp_path retention configuration validation
Mar 9, 2026
e232f12
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 9, 2026
fe0832b
chore: improve coide coverage for edge case
Mar 9, 2026
23000e8
Merge remote-tracking branch 'origin/hotfix/cve' into hotfix/cve
Mar 9, 2026
09bd0ed
Merge branch 'pytest-dev:main' into hotfix/cve
laurac8r Mar 9, 2026
068fd4e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 9, 2026
a724939
chore: remove dead code
Mar 9, 2026
9a4451a
Merge branch 'pytest-dev:main' into hotfix/cve
laurac8r Mar 9, 2026
95f39ee
Merge branch 'pytest-dev:main' into hotfix/cve
laurac8r Mar 11, 2026
ed4a728
Apply suggestion from @webknjaz
laurac8r Mar 13, 2026
206731a
Apply suggestion from @webknjaz
laurac8r Mar 13, 2026
975b944
Merge branch 'pytest-dev:main' into hotfix/cve
laurac8r Mar 15, 2026
d456ad4
docs: enhance CVE-2025-71176 changelog entry with hyperlinks
Mar 15, 2026
a624cc1
Merge remote-tracking branch 'origin/hotfix/cve' into hotfix/cve
Mar 15, 2026
c025681
refactor(tmpdir): extract _safe_open_dir into a reusable context manager
Mar 15, 2026
f2c6f23
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 15, 2026
d58ba2a
docs: update docstring
Mar 15, 2026
9d501ec
Merge remote-tracking branch 'origin/hotfix/cve' into hotfix/cve
Mar 15, 2026
45bdce9
refactor(testing): consolidate imports in test_tmpdir.py
Mar 15, 2026
879767b
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 15, 2026
ab3d9e4
refactor: consolidate imports and hoist getpass to module level
Mar 15, 2026
40e8fdd
Merge remote-tracking branch 'origin/hotfix/cve' into hotfix/cve
Mar 15, 2026
64aa0f1
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 15, 2026
e1ab060
test(tmpdir): make test_pytest_sessionfinish_handles_missing_basetemp…
Mar 15, 2026
c8be3c5
Merge remote-tracking branch 'origin/hotfix/cve' into hotfix/cve
Mar 15, 2026
3a27865
hotfix: mitigate DoS when a non-directory file blocks pytest-of-<user>
Mar 15, 2026
e403fbf
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 15, 2026
0e3581c
test(tmpdir): add regression test for mkdir failure after unlink in _…
Mar 15, 2026
f9918cf
Merge remote-tracking branch 'origin/hotfix/cve' into hotfix/cve
Mar 15, 2026
d1d6cae
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 15, 2026
24003ce
Merge branch 'main' into hotfix/cve
Mar 15, 2026
064b26e
tmpdir: replace predictable rootdir with mkdtemp-based random suffix …
Mar 15, 2026
124027e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 15, 2026
654e3dd
refactor: style: minor code formatting cleanup in tmpdir and test_tmpdir
Mar 15, 2026
9a586f6
Merge remote-tracking branch 'origin/hotfix/cve' into hotfix/cve
Mar 15, 2026
43aa2c8
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 15, 2026
e696db0
test(tmpdir): strengthen fchmod defense-in-depth test by widening per…
Mar 15, 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
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ Kojo Idrissa
Kostis Anagnostopoulos
Kristoffer Nordström
Kyle Altendorf
Laura Kaminskiy
Lawrence Mitchell
Lee Kamentsky
Leonardus Chen
Expand Down
9 changes: 9 additions & 0 deletions changelog/13669.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Fixed a symlink attack vulnerability ([CVE-2025-71176](https://github.com/pytest-dev/pytest/issues/13669)) in
the [tmp_path](https://github.com/pytest-dev/pytest/blob/295d9da900a0dbe8b4093d6a6bc977cd567aa4b0/src/_pytest/tmpdir.py#L258)
fixture's base directory handling.

The ``pytest-of-<user>`` directory under the system temp root is now opened
with [O_NOFOLLOW](https://man7.org/linux/man-pages/man2/open.2.html#:~:text=not%20have%20one.-,O_NOFOLLOW,-If%20the%20trailing)
and verified using
file-descriptor-based [fstat](https://linux.die.net/man/2/fstat)/[fchmod](https://linux.die.net/man/2/fchmod),
preventing symlink attacks and [TOCTOU](https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use) races.
102 changes: 83 additions & 19 deletions src/_pytest/tmpdir.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

from collections.abc import Generator
import contextlib
import dataclasses
import os
from pathlib import Path
Expand Down Expand Up @@ -37,6 +38,64 @@
RetentionType = Literal["all", "failed", "none"]


@contextlib.contextmanager
def _safe_open_dir(path: Path) -> Generator[int]:
"""Open a directory without following symlinks and yield its file descriptor.

Uses O_NOFOLLOW and O_DIRECTORY (when available) to prevent symlink
attacks (CVE-2025-71176). The fd-based operations (fstat, fchmod)
also eliminate TOCTOU races.

Args:
path: Directory to open.

Yields:
An open file descriptor for the directory.

Raises:
OSError: If the path cannot be safely opened (e.g. it is a symlink).
"""
open_flags = os.O_RDONLY
for _flag in ("O_NOFOLLOW", "O_DIRECTORY"):
open_flags |= getattr(os, _flag, 0)
try:
dir_fd = os.open(str(path), open_flags)
except OSError as e:
raise OSError(
f"The temporary directory {path} could not be "
"safely opened (it may be a symlink). "
"Remove the symlink or directory and try again."
) from e
try:
yield dir_fd
finally:
os.close(dir_fd)


def _cleanup_old_rootdirs(
temproot: Path, prefix: str, keep: int, current: Path
) -> None:
"""Remove old randomly-named rootdirs, keeping the *keep* most recent.

*current* is excluded so the running session's rootdir is never removed.
Errors are silently ignored (other sessions may hold locks, etc.).
"""
try:
candidates = sorted(
(
p
for p in temproot.iterdir()
if p.is_dir() and p.name.startswith(prefix) and p != current
),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
except OSError:
return
for old in candidates[keep:]:
rmtree(old, ignore_errors=True)


@final
@dataclasses.dataclass
class TempPathFactory:
Expand Down Expand Up @@ -157,29 +216,32 @@ def getbasetemp(self) -> Path:
user = get_user() or "unknown"
# use a sub-directory in the temproot to speed-up
# make_numbered_dir() call
rootdir = temproot.joinpath(f"pytest-of-{user}")
# Use a randomly-named rootdir created via mkdtemp to avoid
# the entire class of predictable-name attacks (symlink races,
# DoS via pre-created files/dirs, etc.). See #13669.
rootdir_prefix = f"pytest-of-{user}-"
try:
rootdir.mkdir(mode=0o700, exist_ok=True)
rootdir = Path(tempfile.mkdtemp(prefix=rootdir_prefix, dir=temproot))
except OSError:
# getuser() likely returned illegal characters for the platform, use unknown back off mechanism
rootdir = temproot.joinpath("pytest-of-unknown")
rootdir.mkdir(mode=0o700, exist_ok=True)
# Because we use exist_ok=True with a predictable name, make sure
# we are the owners, to prevent any funny business (on unix, where
# temproot is usually shared).
# Also, to keep things private, fixup any world-readable temp
# rootdir's permissions. Historically 0o755 was used, so we can't
# just error out on this, at least for a while.
# getuser() likely returned illegal characters for the
# platform, fall back to a safe prefix.
rootdir_prefix = "pytest-of-unknown-"
rootdir = Path(tempfile.mkdtemp(prefix=rootdir_prefix, dir=temproot))
# mkdtemp applies the umask; ensure 0o700 unconditionally.
os.chmod(rootdir, 0o700)
# Defense-in-depth: verify ownership and tighten permissions
# via fd-based ops to eliminate TOCTOU races (CVE-2025-71176).
uid = get_user_id()
if uid is not None:
rootdir_stat = rootdir.stat()
if rootdir_stat.st_uid != uid:
raise OSError(
f"The temporary directory {rootdir} is not owned by the current user. "
"Fix this and try again."
)
if (rootdir_stat.st_mode & 0o077) != 0:
os.chmod(rootdir, rootdir_stat.st_mode & ~0o077)
with _safe_open_dir(rootdir) as dir_fd:
rootdir_stat = os.fstat(dir_fd)
if rootdir_stat.st_uid != uid:
raise OSError(
f"The temporary directory {rootdir} is not owned by the current user. "
"Fix this and try again."
)
if (rootdir_stat.st_mode & 0o077) != 0:
os.fchmod(dir_fd, rootdir_stat.st_mode & ~0o077)
keep = self._retention_count
if self._retention_policy == "none":
keep = 0
Expand All @@ -190,6 +252,8 @@ def getbasetemp(self) -> Path:
lock_timeout=LOCK_TIMEOUT,
mode=0o700,
)
# Clean up old rootdirs from previous sessions.
_cleanup_old_rootdirs(temproot, rootdir_prefix, keep, current=rootdir)
assert basetemp is not None, basetemp
self._basetemp = basetemp
self._trace("new basetemp", basetemp)
Expand Down
Loading
Loading