-
-
Notifications
You must be signed in to change notification settings - Fork 3.1k
tmpdir: prevent symlink attacks and TOCTOU races (CVE-2025-71176) #14279
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f601e56
ec18caa
b3cb812
5894e25
7f93f0a
e232f12
fe0832b
23000e8
09bd0ed
068fd4e
a724939
9a4451a
95f39ee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's a
Suggested change
|
||||||
|
|
||||||
| 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. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mind linking this abbrebiation to the definition? |
||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Some of this should probably go under a |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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") | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can assign this to a variable and then reuse it in all these tests like so: skip_if_no_getuid = pytest.mark.skipif(not hasattr(os, "getuid"), reason="checks unix permissions")
Suggested change
|
||||||
| 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 | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is the import happening inside a function? |
||||||
|
|
||||||
| 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( | ||||||
| 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).""" | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These line numbers will become wrong over time. |
||||||
| from _pytest.tmpdir import pytest_sessionfinish | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Misplaced imports are normally discouraged. Any justification? |
||||||
|
|
||||||
| 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) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Feels like this should be isolated, no? |
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It'd be useful to symlink
14279.bugfix.rstto13669.bugfix.rstso that both are linked @ https://pytest--14279.org.readthedocs.build/en/14279/changelog.html#bug-fixes (you can also use this link to preview the change note render after pushing updates to this PR).