From f601e56c6f73e100421d8ce94ef6ee79a92cda95 Mon Sep 17 00:00:00 2001 From: Laura Kaminskiy Date: Sun, 8 Mar 2026 21:18:20 -0400 Subject: [PATCH 1/9] tmpdir: prevent symlink attacks and TOCTOU races (CVE-2025-71176) Open the base temporary directory using `os.open` with `O_NOFOLLOW` and `O_DIRECTORY` flags to prevent symlink attacks. Use the resulting file descriptor for `fstat` and `fchmod` operations to eliminate Time-of-Check Time-of-Use (TOCTOU) races. Co-authored-by: Windsurf, Gemini --- src/_pytest/tmpdir.py | 31 ++++++++++++++++++++++++------- testing/test_tmpdir.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 855ad273ecf..121e37e80fb 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -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 diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 12891d81488..8180eb3802f 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -619,3 +619,31 @@ 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- 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}" + # Remove the real dir if a prior factory call created it. + if rootdir.exists(): + 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() From ec18caa961a2636a38b2b5349b1e006559fa62f9 Mon Sep 17 00:00:00 2001 From: Laura Kaminskiy Date: Mon, 9 Mar 2026 00:42:32 -0400 Subject: [PATCH 2/9] docs: added a bugfix changelog entry Co-authored-by: Windsurf, Gemini --- changelog/13669.bugfix.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog/13669.bugfix.rst diff --git a/changelog/13669.bugfix.rst b/changelog/13669.bugfix.rst new file mode 100644 index 00000000000..81f443bf7dc --- /dev/null +++ b/changelog/13669.bugfix.rst @@ -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-`` 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. From b3cb812e159e825a2f8f7df77706ce31b9d8cc0c Mon Sep 17 00:00:00 2001 From: Laura Kaminskiy Date: Mon, 9 Mar 2026 00:55:27 -0400 Subject: [PATCH 3/9] chore: added name to `AUTHORS` file --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 6885ec6e793..8329ddf9288 100644 --- a/AUTHORS +++ b/AUTHORS @@ -264,6 +264,7 @@ Kojo Idrissa Kostis Anagnostopoulos Kristoffer Nordström Kyle Altendorf +Laura Kaminskiy Lawrence Mitchell Lee Kamentsky Leonardus Chen From 5894e2585d351df92238468f3e9e15ad9485a1dc Mon Sep 17 00:00:00 2001 From: Laura Kaminskiy Date: Mon, 9 Mar 2026 01:04:14 -0400 Subject: [PATCH 4/9] chore: adding test coverage Co-authored-by Windsurf --- testing/test_tmpdir.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 8180eb3802f..b08eec4e8b3 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -647,3 +647,37 @@ def test_tmp_path_factory_rejects_symlink_rootdir( 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 From 7f93f0aaad79e38aed81a0a9fb8e5aa079dcf730 Mon Sep 17 00:00:00 2001 From: Laura Kaminskiy Date: Mon, 9 Mar 2026 01:18:19 -0400 Subject: [PATCH 5/9] chore: Add tests for tmp_path retention configuration validation Co-authored-by Windsurf, Gemini --- testing/test_tmpdir.py | 94 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index b08eec4e8b3..a0a5a1b642b 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -681,3 +681,97 @@ def test_tmp_path_factory_nofollow_flag_missing( # 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( + tmp_path: Path, +) -> None: + """Verify that a negative tmp_path_retention_count raises ValueError.""" + + @dataclasses.dataclass + class BadCountConfig: + basetemp: str | Path = "" + + @property + def trace(self): + return self + + def get(self, key): + return lambda *k: None + + def getini(self, name): + if name == "tmp_path_retention_count": + return -1 + elif name == "tmp_path_retention_policy": + return "all" + else: + assert False + + @property + def option(self): + return self + + 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 = "" + + @property + def trace(self): + return self + + def get(self, key): + return lambda *k: None + + def getini(self, name): + if name == "tmp_path_retention_count": + return 3 + elif name == "tmp_path_retention_policy": + return "invalid_policy" + else: + assert False + + @property + def option(self): + return self + + 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.""" + from _pytest.tmpdir import pytest_sessionfinish + + p = pytester.makepyfile( + """ + def test_no_tmp(): + pass + """ + ) + result = pytester.runpytest(p) + result.assert_outcomes(passed=1) From e232f12a5511a633a0c086cf444bf4e3859a96a8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 05:22:05 +0000 Subject: [PATCH 6/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/test_tmpdir.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index a0a5a1b642b..1adc53b5d16 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -754,9 +754,7 @@ def test_tmp_path_factory_none_policy_sets_keep_zero( ) -> 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 - ) + tmp_factory = TempPathFactory(None, 3, "none", lambda *args: None, _ispytest=True) basetemp = tmp_factory.getbasetemp() assert basetemp.is_dir() @@ -765,8 +763,6 @@ def test_pytest_sessionfinish_noop_when_no_basetemp( pytester: Pytester, ) -> None: """Verify that pytest_sessionfinish returns early when basetemp is None.""" - from _pytest.tmpdir import pytest_sessionfinish - p = pytester.makepyfile( """ def test_no_tmp(): From fe0832ba4fd53c19e037882f131c776291dcb180 Mon Sep 17 00:00:00 2001 From: Laura Kaminskiy Date: Mon, 9 Mar 2026 01:40:53 -0400 Subject: [PATCH 7/9] chore: improve coide coverage for edge case Co-authored-by: Windsurf --- testing/test_tmpdir.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index a0a5a1b642b..56a5711e8b0 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -775,3 +775,25 @@ def test_no_tmp(): ) 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) From 068fd4e220caa2b0caff492f0ffadb0824d64cf6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 05:42:08 +0000 Subject: [PATCH 8/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/test_tmpdir.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index a9d843c6e7b..1f1ed3d28f1 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -780,9 +780,7 @@ def test_pytest_sessionfinish_handles_missing_basetemp_dir( 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 - ) + 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" From a72493973ea2759b90ea02b1e1dda931bf930138 Mon Sep 17 00:00:00 2001 From: Laura Kaminskiy Date: Mon, 9 Mar 2026 06:59:37 -0400 Subject: [PATCH 9/9] chore: remove dead code Co-authored-by: Windsurf --- testing/test_tmpdir.py | 36 +++++------------------------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 1f1ed3d28f1..c994b8a916f 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -639,9 +639,9 @@ def test_tmp_path_factory_rejects_symlink_rootdir( user = getpass.getuser() rootdir = tmp_path / f"pytest-of-{user}" - # Remove the real dir if a prior factory call created it. - if rootdir.exists(): - rootdir.rmdir() + # 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) @@ -692,24 +692,10 @@ def test_tmp_path_factory_from_config_rejects_negative_count( class BadCountConfig: basetemp: str | Path = "" - @property - def trace(self): - return self - - def get(self, key): - return lambda *k: None - def getini(self, name): if name == "tmp_path_retention_count": return -1 - elif name == "tmp_path_retention_policy": - return "all" - else: - assert False - - @property - def option(self): - return self + assert False config = cast(Config, BadCountConfig(tmp_path)) with pytest.raises(ValueError, match="tmp_path_retention_count must be >= 0"): @@ -725,24 +711,12 @@ def test_tmp_path_factory_from_config_rejects_invalid_policy( class BadPolicyConfig: basetemp: str | Path = "" - @property - def trace(self): - return self - - def get(self, key): - return lambda *k: None - def getini(self, name): if name == "tmp_path_retention_count": return 3 elif name == "tmp_path_retention_policy": return "invalid_policy" - else: - assert False - - @property - def option(self): - return self + assert False config = cast(Config, BadPolicyConfig(tmp_path)) with pytest.raises(ValueError, match="tmp_path_retention_policy must be either"):