Skip to content
Closed
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
90 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
eb1179e
Merge branch 'main' into hotfix/cve
laurac8r Mar 23, 2026
991a5dd
refactor: rename _safe_open_dir to _verify_ownership_and_tighten_perm…
laurac8r Mar 23, 2026
e83a0ca
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 23, 2026
29c866c
refactor: rename _safe_open_dir to _verify_ownership_and_tighten_perm…
laurac8r Mar 25, 2026
02e492d
fix: update tmp_path directory creation to prevent symlink attacks an…
laurac8r Mar 25, 2026
76a9dd0
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 25, 2026
56df2c4
hotfix: unicode encoding
laurac8r Mar 26, 2026
f4b54ae
chore: remove changelog entry
laurac8r Mar 28, 2026
8ab71db
fix: improve docstring for safe_rmtree to clarify symlink attack prot…
laurac8r Mar 28, 2026
f6847a0
docs: move functionality comments from docstring to body
laurac8r Mar 28, 2026
ea4c00e
docs: move functionality comments from docstring to body
laurac8r Mar 28, 2026
af8b534
chore: move comment
laurac8r Mar 28, 2026
5c6a6cd
chore: reformatting
laurac8r Mar 28, 2026
e369678
fix: update assertion for symlink warnings in test_tmpdir
laurac8r Mar 28, 2026
727304d
docs: update docstring to include issue reference for TestSafeRmtree
laurac8r Mar 28, 2026
2df369b
Merge branch 'main' into hotfix/cve
laurac8r Mar 28, 2026
7137411
fix: remove redundant import of shutil in test_tmpdir
laurac8r Mar 28, 2026
3bcfa95
add symlink
laurac8r Mar 28, 2026
cd34048
hotfix: make Windows tests pass
laurac8r Mar 28, 2026
5ee872a
fix: enhance symlink safety checks and add create_symlink option in m…
laurac8r Mar 28, 2026
ab08a78
fix: disable symlink creation in make_numbered_dir by default
laurac8r Mar 28, 2026
8df59d5
test: add test for make_numbered_dir with create_symlink=False
laurac8r Mar 28, 2026
53efdc4
chore: linting
laurac8r Mar 28, 2026
12e90a9
enhance: prevent warning spam in cleanup loops
laurac8r Mar 28, 2026
e5941d2
docs: move comment
laurac8r Mar 28, 2026
079664e
docs: Rewrote comment to accurately describe best-effort behavior
laurac8r Mar 28, 2026
9177048
hotfix: Per-entry OSError handling in _cleanup_old_rootdirs via a _mt…
laurac8r Mar 28, 2026
0c94285
feat:
laurac8r Mar 28, 2026
140ca06
hotfix: Removed redundant path-based os.chmod; fd-based fchmod now ha…
laurac8r Mar 28, 2026
aba08fc
refactor: Added stacklevel keyword param to _check_symlink_attack_saf…
laurac8r Mar 28, 2026
671867f
docs: Documented POSIX-only O_NOFOLLOW limitation in _verify_ownershi…
laurac8r Mar 28, 2026
24d7bb5
hotfix: Improve symlink attack defense in _cleanup_old_rootdirs by re…
laurac8r Mar 28, 2026
7af0a46
chore: remove unnecessary comment
laurac8r Mar 28, 2026
99cce96
hotfix: Move ensure_extended_length_path call after _check_symlink_at…
laurac8r Mar 28, 2026
a2a2db5
hotfix: Simplify warnings in safe_rmtree tests by removing unnecessar…
laurac8r Mar 31, 2026
03e0931
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 31, 2026
bc9dc77
hotfix: Introduce _safe_open_dir context manager to enhance symlink s…
laurac8r Apr 3, 2026
479a5a6
hotfix: Update test_warns_once_when_avoids_symlink_attacks to ensure …
laurac8r Apr 3, 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
3 changes: 3 additions & 0 deletions changelog/13669.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Fixed a symlink attack vulnerability (CVE-2025-71176) in the :fixture:`tmp_path` fixture's base directory handling.

The ``pytest-of-<user>`` directory under the system temp root is now opened with ``O_NOFOLLOW`` and verified using file-descriptor-based ``fstat``/``fchmod``, preventing symlink attacks and TOCTOU races.
31 changes: 24 additions & 7 deletions src/_pytest/tmpdir.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,14 +172,31 @@ def getbasetemp(self) -> Path:
# just error out on this, at least for a while.
uid = get_user_id()
if uid is not None:
rootdir_stat = rootdir.stat()
if rootdir_stat.st_uid != uid:
# Open the directory without following symlinks to prevent
# symlink attacks (CVE-2025-71176). Using a file descriptor
# for fstat/fchmod also eliminates TOCTOU races.
open_flags = os.O_RDONLY
for _flag in ("O_NOFOLLOW", "O_DIRECTORY"):
open_flags |= getattr(os, _flag, 0)
try:
dir_fd = os.open(str(rootdir), open_flags)
except OSError as e:
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)
f"The temporary directory {rootdir} could not be "
"safely opened (it may be a symlink). "
"Remove the symlink or directory and try again."
) from e
try:
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)
finally:
os.close(dir_fd)
keep = self._retention_count
if self._retention_policy == "none":
keep = 0
Expand Down
146 changes: 146 additions & 0 deletions testing/test_tmpdir.py
Original file line number Diff line number Diff line change
Expand Up @@ -619,3 +619,149 @@ def test_tmp_path_factory_fixes_up_world_readable_permissions(

# After - fixed.
assert (basetemp.parent.stat().st_mode & 0o077) == 0


@pytest.mark.skipif(not hasattr(os, "getuid"), reason="checks unix permissions")
def test_tmp_path_factory_rejects_symlink_rootdir(
tmp_path: Path, monkeypatch: MonkeyPatch
) -> None:
"""CVE-2025-71176: verify that a symlink at the pytest-of-<user> location
is rejected to prevent symlink attacks."""
monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path))

# Pre-create a target directory that the symlink will point to.
attacker_dir = tmp_path / "attacker-controlled"
attacker_dir.mkdir(mode=0o700)

# Figure out what rootdir name pytest would use, then replace it
# with a symlink pointing to the attacker-controlled directory.
import getpass

user = getpass.getuser()
rootdir = tmp_path / f"pytest-of-{user}"
# Ensure the real dir exists so the cleanup branch is exercised.
rootdir.mkdir(mode=0o700, exist_ok=True)
rootdir.rmdir()
rootdir.symlink_to(attacker_dir)

tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True)
with pytest.raises(OSError, match="could not be safely opened"):
tmp_factory.getbasetemp()


@pytest.mark.skipif(not hasattr(os, "getuid"), reason="checks unix permissions")
def test_tmp_path_factory_rejects_wrong_owner(
tmp_path: Path, monkeypatch: MonkeyPatch
) -> None:
"""CVE-2025-71176: verify that a rootdir owned by a different user is
rejected (covers the fstat uid mismatch branch)."""
monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path))

# Make get_user_id() return a uid that won't match the directory owner.
monkeypatch.setattr("_pytest.tmpdir.get_user_id", lambda: os.getuid() + 1)

tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True)
with pytest.raises(OSError, match="not owned by the current user"):
tmp_factory.getbasetemp()


@pytest.mark.skipif(not hasattr(os, "getuid"), reason="checks unix permissions")
def test_tmp_path_factory_nofollow_flag_missing(
tmp_path: Path, monkeypatch: MonkeyPatch
) -> None:
"""CVE-2025-71176: verify that the code still works when O_NOFOLLOW or
O_DIRECTORY flags are not available on the platform."""
monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path))
monkeypatch.delattr(os, "O_NOFOLLOW", raising=False)
monkeypatch.delattr(os, "O_DIRECTORY", raising=False)

tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True)
basetemp = tmp_factory.getbasetemp()

# Should still create the directory with safe permissions.
assert basetemp.is_dir()
assert (basetemp.parent.stat().st_mode & 0o077) == 0


def test_tmp_path_factory_from_config_rejects_negative_count(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we might be going overboard with the number of tests here. Could you verify if it is possible to remove/merge some of them?

tmp_path: Path,
) -> None:
"""Verify that a negative tmp_path_retention_count raises ValueError."""

@dataclasses.dataclass
class BadCountConfig:
basetemp: str | Path = ""

def getini(self, name):
if name == "tmp_path_retention_count":
return -1
assert False

config = cast(Config, BadCountConfig(tmp_path))
with pytest.raises(ValueError, match="tmp_path_retention_count must be >= 0"):
TempPathFactory.from_config(config, _ispytest=True)


def test_tmp_path_factory_from_config_rejects_invalid_policy(
tmp_path: Path,
) -> None:
"""Verify that an invalid tmp_path_retention_policy raises ValueError."""

@dataclasses.dataclass
class BadPolicyConfig:
basetemp: str | Path = ""

def getini(self, name):
if name == "tmp_path_retention_count":
return 3
elif name == "tmp_path_retention_policy":
return "invalid_policy"
assert False

config = cast(Config, BadPolicyConfig(tmp_path))
with pytest.raises(ValueError, match="tmp_path_retention_policy must be either"):
TempPathFactory.from_config(config, _ispytest=True)


def test_tmp_path_factory_none_policy_sets_keep_zero(
tmp_path: Path, monkeypatch: MonkeyPatch
) -> None:
"""Verify that retention_policy='none' sets keep=0."""
monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path))
tmp_factory = TempPathFactory(None, 3, "none", lambda *args: None, _ispytest=True)
basetemp = tmp_factory.getbasetemp()
assert basetemp.is_dir()


def test_pytest_sessionfinish_noop_when_no_basetemp(
pytester: Pytester,
) -> None:
"""Verify that pytest_sessionfinish returns early when basetemp is None."""
p = pytester.makepyfile(
"""
def test_no_tmp():
pass
"""
)
result = pytester.runpytest(p)
result.assert_outcomes(passed=1)


def test_pytest_sessionfinish_handles_missing_basetemp_dir(
tmp_path: Path,
) -> None:
"""Cover the branch where basetemp is set but the directory no longer
exists when pytest_sessionfinish runs (314->320 partial branch)."""
from _pytest.tmpdir import pytest_sessionfinish

factory = TempPathFactory(None, 3, "failed", lambda *args: None, _ispytest=True)
# Point _basetemp at a path that does not exist on disk.
factory._basetemp = tmp_path / "already-gone"

class FakeSession:
class config:
_tmp_path_factory = factory

# exitstatus=0 + policy="failed" + _given_basetemp=None enters the
# cleanup block; basetemp.is_dir() is False so rmtree is skipped.
pytest_sessionfinish(FakeSession, exitstatus=0)