Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
f601e56
tmpdir: prevent symlink attacks and TOCTOU races (CVE-2025-71176)
laurac8r Mar 9, 2026
ec18caa
docs: added a bugfix changelog entry
laurac8r Mar 9, 2026
b3cb812
chore: added name to `AUTHORS` file
laurac8r Mar 9, 2026
5894e25
chore: adding test coverage
laurac8r Mar 9, 2026
7f93f0a
chore: Add tests for tmp_path retention configuration validation
laurac8r 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
laurac8r Mar 9, 2026
23000e8
Merge remote-tracking branch 'origin/hotfix/cve' into hotfix/cve
laurac8r 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
laurac8r 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
65fc036
Merge branch 'main' into hotfix/cve
laurac8r Mar 16, 2026
6437860
Merge branch 'main' into hotfix/cve
laurac8r Mar 17, 2026
1d1cf8e
Merge branch 'main' into hotfix/cve
laurac8r Mar 18, 2026
a0c8ace
Merge branch 'main' into hotfix/cve
laurac8r Mar 20, 2026
1d4fee4
test(tmpdir): add tests for safe_rmtree to handle symlinks and direct…
laurac8r Mar 20, 2026
81de30b
Merge remote-tracking branch 'origin/hotfix/cve' into hotfix/cve
laurac8r Mar 20, 2026
9f82169
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 20, 2026
9a7726e
refactor: replace rmtree with safe_rmtree for improved directory remo…
laurac8r Mar 20, 2026
a8b8456
Merge remote-tracking branch 'origin/hotfix/cve' into hotfix/cve
laurac8r Mar 20, 2026
04baaa3
feat: add safe_rmtree function with symlink attack protection
laurac8r Mar 20, 2026
bb1371b
fix: enhance symlink protection in _cleanup_old_rootdirs to prevent C…
laurac8r Mar 20, 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.
50 changes: 50 additions & 0 deletions src/_pytest/pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,59 @@ def get_extended_length_path_str(path: str) -> str:
return long_path_prefix + path


def _check_symlink_attack_safety(path: Path) -> None:
"""Guard against symlink attacks before recursive directory removal.

If ``shutil.rmtree.avoids_symlink_attacks`` is True the platform's
rmtree implementation uses fd-based operations that are inherently
resistant to symlink races; we only need to verify *path* itself is
not a symlink.

When the attribute is False the platform cannot guarantee safety. We
still refuse to remove a symlink, but a TOCTOU window remains for
contents *inside* the tree, so we emit a one-time warning.

Raises ``OSError`` if *path* is a symlink.
"""
if path.is_symlink():
raise OSError(
f"Refusing to recursively remove {path}: "
"path is a symlink, not a real directory."
)
if not shutil.rmtree.avoids_symlink_attacks:
warnings.warn(
PytestWarning(
"shutil.rmtree.avoids_symlink_attacks is False on this platform: "
"recursive directory removal may be susceptible to symlink attacks."
),
stacklevel=3,
)


def safe_rmtree(path: Path, *, ignore_errors: bool = False) -> None:
"""Remove a directory tree with protection against symlink attacks.

Verifies that ``shutil.rmtree.avoids_symlink_attacks`` is True (the
platform provides a symlink-attack-resistant implementation) before
proceeding. On platforms without this guarantee an explicit symlink
check is performed and a warning is emitted.

When *ignore_errors* is True, a symlink at *path* is silently skipped
rather than raising.
"""
try:
_check_symlink_attack_safety(path)
except OSError:
if not ignore_errors:
raise
return
shutil.rmtree(str(path), ignore_errors=ignore_errors)


def rm_rf(path: Path) -> None:
"""Remove the path contents recursively, even if some elements
are read-only."""
_check_symlink_attack_safety(path)
path = ensure_extended_length_path(path)
onerror = partial(on_rm_rf_error, start_path=path)
if sys.version_info >= (3, 12):
Expand Down
114 changes: 92 additions & 22 deletions src/_pytest/tmpdir.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
from __future__ import annotations

from collections.abc import Generator
import contextlib
import dataclasses
import os
from pathlib import Path
import re
from shutil import rmtree
import tempfile
from typing import Any
from typing import final
Expand All @@ -19,6 +19,7 @@
from .pathlib import make_numbered_dir
from .pathlib import make_numbered_dir_with_cleanup
from .pathlib import rm_rf
from .pathlib import safe_rmtree
from _pytest.compat import get_user_id
from _pytest.config import Config
from _pytest.config import ExitCode
Expand All @@ -37,6 +38,70 @@
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.).

Uses ``os.scandir`` with ``follow_symlinks=False`` so that symlinks
planted under *temproot* are never followed during enumeration —
defense-in-depth against symlink attacks (CVE-2025-71176).
"""
try:
candidates = sorted(
(
Path(entry.path)
for entry in os.scandir(temproot)
if entry.is_dir(follow_symlinks=False)
and entry.name.startswith(prefix)
and Path(entry.path) != current
),
key=lambda p: p.lstat().st_mtime,
reverse=True,
)
except OSError:
return
for old in candidates[keep:]:
safe_rmtree(old, ignore_errors=True)


@final
@dataclasses.dataclass
class TempPathFactory:
Expand Down Expand Up @@ -157,29 +222,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 +258,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 Expand Up @@ -274,7 +344,7 @@ def tmp_path(
if policy == "failed" and result_dict.get("call", True):
# We do a "best effort" to remove files, but it might not be possible due to some leaked resource,
# permissions, etc, in which case we ignore it.
rmtree(path, ignore_errors=True)
safe_rmtree(path, ignore_errors=True)

del request.node.stash[tmppath_result_key]

Expand All @@ -297,7 +367,7 @@ def pytest_sessionfinish(session, exitstatus: int | ExitCode):
if basetemp.is_dir():
# We do a "best effort" to remove files, but it might not be possible due to some leaked resource,
# permissions, etc, in which case we ignore it.
rmtree(basetemp, ignore_errors=True)
safe_rmtree(basetemp, ignore_errors=True)

# Remove dead symlinks.
if basetemp.is_dir():
Expand Down
Loading
Loading