From 8d5b8a03e76fd930d3b0cf31e78f0cbccf1275dc Mon Sep 17 00:00:00 2001 From: Mason Barden Date: Thu, 5 Mar 2026 18:30:29 -0500 Subject: [PATCH 1/4] fixes to resolve symlink vulnerability --- src/_pytest/tmpdir.py | 14 +++++++++--- testing/test_tmpdir.py | 50 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 855ad273ecf..10b97109fc0 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -109,7 +109,8 @@ def from_config( def _ensure_relative_to_basetemp(self, basename: str) -> str: basename = os.path.normpath(basename) if (self.getbasetemp() / basename).resolve().parent != self.getbasetemp(): - raise ValueError(f"{basename} is not a normalized and relative path") + raise ValueError( + f"{basename} is not a normalized and relative path") return basename def mktemp(self, basename: str, numbered: bool = True) -> Path: @@ -132,7 +133,8 @@ def mktemp(self, basename: str, numbered: bool = True) -> Path: p = self.getbasetemp().joinpath(basename) p.mkdir(mode=0o700) else: - p = make_numbered_dir(root=self.getbasetemp(), prefix=basename, mode=0o700) + p = make_numbered_dir(root=self.getbasetemp(), + prefix=basename, mode=0o700) self._trace("mktemp", p) return p @@ -158,12 +160,18 @@ def getbasetemp(self) -> Path: # use a sub-directory in the temproot to speed-up # make_numbered_dir() call rootdir = temproot.joinpath(f"pytest-of-{user}") + if rootdir.is_symlink(): + raise OSError(f"Symlink attack detected at {rootdir}") try: rootdir.mkdir(mode=0o700, exist_ok=True) except OSError: # getuser() likely returned illegal characters for the platform, use unknown back off mechanism rootdir = temproot.joinpath("pytest-of-unknown") + if rootdir.is_symlink(): + raise OSError(f"Symlink attack detected at {rootdir}") rootdir.mkdir(mode=0o700, exist_ok=True) + if rootdir.is_symlink(): + raise OSError(f"Symlink attack detected at {rootdir}") # 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). @@ -172,7 +180,7 @@ 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() + rootdir_stat = os.lstat(rootdir) if rootdir_stat.st_uid != uid: raise OSError( f"The temporary directory {rootdir} is not owned by the current user. " diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 12891d81488..a3c9d67c250 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -116,12 +116,14 @@ def test_2(tmp_path): for child in root.iterdir(): base_dir = list( - filter(lambda x: x.is_dir() and not x.is_symlink(), child.iterdir()) + filter(lambda x: x.is_dir() + and not x.is_symlink(), child.iterdir()) ) assert len(base_dir) == 1 test_dir = list( filter( - lambda x: x.is_dir() and not x.is_symlink(), base_dir[0].iterdir() + lambda x: x.is_dir() and not x.is_symlink( + ), base_dir[0].iterdir() ) ) # Check only the failed one remains @@ -183,7 +185,8 @@ def test_fixt(fixt): root = pytester._test_tmproot for child in root.iterdir(): base_dir = list( - filter(lambda x: x.is_dir() and not x.is_symlink(), child.iterdir()) + filter(lambda x: x.is_dir() + and not x.is_symlink(), child.iterdir()) ) assert len(base_dir) == 0 @@ -215,12 +218,14 @@ def test_fixt(fixt): root = pytester._test_tmproot for child in root.iterdir(): base_dir = list( - filter(lambda x: x.is_dir() and not x.is_symlink(), child.iterdir()) + filter(lambda x: x.is_dir() + and not x.is_symlink(), child.iterdir()) ) assert len(base_dir) == 1 test_dir = list( filter( - lambda x: x.is_dir() and not x.is_symlink(), base_dir[0].iterdir() + lambda x: x.is_dir() and not x.is_symlink( + ), base_dir[0].iterdir() ) ) assert len(test_dir) == 1 @@ -516,7 +521,8 @@ def test_on_rm_rf_error(self, tmp_path: Path) -> None: # we ignore FileNotFoundError exc_info2 = (FileNotFoundError, FileNotFoundError(), None) - assert not on_rm_rf_error(None, str(fn), exc_info2, start_path=tmp_path) + assert not on_rm_rf_error( + None, str(fn), exc_info2, start_path=tmp_path) # unknown function with pytest.warns( @@ -587,7 +593,8 @@ def test_tmp_path_factory_create_directory_with_safe_permissions( """Verify that pytest creates directories under /tmp with private permissions.""" # Use the test's tmp_path as the system temproot (/tmp). monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path)) - tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) + tmp_factory = TempPathFactory( + None, 3, "all", lambda *args: None, _ispytest=True) basetemp = tmp_factory.getbasetemp() # No world-readable permissions. @@ -607,15 +614,40 @@ def test_tmp_path_factory_fixes_up_world_readable_permissions( """ # Use the test's tmp_path as the system temproot (/tmp). monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path)) - tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) + tmp_factory = TempPathFactory( + None, 3, "all", lambda *args: None, _ispytest=True) basetemp = tmp_factory.getbasetemp() # Before - simulate bad perms. os.chmod(basetemp.parent, 0o777) assert (basetemp.parent.stat().st_mode & 0o077) != 0 - tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) + tmp_factory = TempPathFactory( + None, 3, "all", lambda *args: None, _ispytest=True) basetemp = tmp_factory.getbasetemp() # After - fixed. assert (basetemp.parent.stat().st_mode & 0o077) == 0 + + +@pytest.mark.skipif(not hasattr(os, "symlink"), reason="requires symlink support") +def test_tmp_path_factory_fixes_rejects_symlink_attack( + tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + """ A local attacker could create a symlink at the predictable + /tmp/pytest-of-{user} user pointing to an attacker controlled location. + """ + # Use the test's tmp_path as the system temproot (/tmp). + monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path)) + # Set username + monkeypatch.setattr("_pytest.tmpdir.get_user", lambda: "testuser") + attacker_target = tmp_path / "attacker_controlled" + # Simulate symlink attack + attacker_target.mkdir(mode=0o700) + symlink_path = tmp_path = tmp_path/"pytest-of-testuser" + symlink_path.symlink_to(attacker_target) + + tmp_factory = TempPathFactory( + None, 3, "all", lambda *args: None, _ispytest=True) + with pytest.raises(OSError, match="Symlink"): + tmp_factory.getbasetemp() From 422fe295785db7dd403ae6817f72b2e6c7204b16 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:41:55 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/tmpdir.py | 6 ++---- testing/test_tmpdir.py | 34 ++++++++++++---------------------- 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 10b97109fc0..e7ee479f427 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -109,8 +109,7 @@ def from_config( def _ensure_relative_to_basetemp(self, basename: str) -> str: basename = os.path.normpath(basename) if (self.getbasetemp() / basename).resolve().parent != self.getbasetemp(): - raise ValueError( - f"{basename} is not a normalized and relative path") + raise ValueError(f"{basename} is not a normalized and relative path") return basename def mktemp(self, basename: str, numbered: bool = True) -> Path: @@ -133,8 +132,7 @@ def mktemp(self, basename: str, numbered: bool = True) -> Path: p = self.getbasetemp().joinpath(basename) p.mkdir(mode=0o700) else: - p = make_numbered_dir(root=self.getbasetemp(), - prefix=basename, mode=0o700) + p = make_numbered_dir(root=self.getbasetemp(), prefix=basename, mode=0o700) self._trace("mktemp", p) return p diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index a3c9d67c250..e18afddf9b3 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -116,14 +116,12 @@ def test_2(tmp_path): for child in root.iterdir(): base_dir = list( - filter(lambda x: x.is_dir() - and not x.is_symlink(), child.iterdir()) + filter(lambda x: x.is_dir() and not x.is_symlink(), child.iterdir()) ) assert len(base_dir) == 1 test_dir = list( filter( - lambda x: x.is_dir() and not x.is_symlink( - ), base_dir[0].iterdir() + lambda x: x.is_dir() and not x.is_symlink(), base_dir[0].iterdir() ) ) # Check only the failed one remains @@ -185,8 +183,7 @@ def test_fixt(fixt): root = pytester._test_tmproot for child in root.iterdir(): base_dir = list( - filter(lambda x: x.is_dir() - and not x.is_symlink(), child.iterdir()) + filter(lambda x: x.is_dir() and not x.is_symlink(), child.iterdir()) ) assert len(base_dir) == 0 @@ -218,14 +215,12 @@ def test_fixt(fixt): root = pytester._test_tmproot for child in root.iterdir(): base_dir = list( - filter(lambda x: x.is_dir() - and not x.is_symlink(), child.iterdir()) + filter(lambda x: x.is_dir() and not x.is_symlink(), child.iterdir()) ) assert len(base_dir) == 1 test_dir = list( filter( - lambda x: x.is_dir() and not x.is_symlink( - ), base_dir[0].iterdir() + lambda x: x.is_dir() and not x.is_symlink(), base_dir[0].iterdir() ) ) assert len(test_dir) == 1 @@ -521,8 +516,7 @@ def test_on_rm_rf_error(self, tmp_path: Path) -> None: # we ignore FileNotFoundError exc_info2 = (FileNotFoundError, FileNotFoundError(), None) - assert not on_rm_rf_error( - None, str(fn), exc_info2, start_path=tmp_path) + assert not on_rm_rf_error(None, str(fn), exc_info2, start_path=tmp_path) # unknown function with pytest.warns( @@ -593,8 +587,7 @@ def test_tmp_path_factory_create_directory_with_safe_permissions( """Verify that pytest creates directories under /tmp with private permissions.""" # Use the test's tmp_path as the system temproot (/tmp). monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path)) - tmp_factory = TempPathFactory( - None, 3, "all", lambda *args: None, _ispytest=True) + tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) basetemp = tmp_factory.getbasetemp() # No world-readable permissions. @@ -614,16 +607,14 @@ def test_tmp_path_factory_fixes_up_world_readable_permissions( """ # Use the test's tmp_path as the system temproot (/tmp). monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path)) - tmp_factory = TempPathFactory( - None, 3, "all", lambda *args: None, _ispytest=True) + tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) basetemp = tmp_factory.getbasetemp() # Before - simulate bad perms. os.chmod(basetemp.parent, 0o777) assert (basetemp.parent.stat().st_mode & 0o077) != 0 - tmp_factory = TempPathFactory( - None, 3, "all", lambda *args: None, _ispytest=True) + tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) basetemp = tmp_factory.getbasetemp() # After - fixed. @@ -634,7 +625,7 @@ def test_tmp_path_factory_fixes_up_world_readable_permissions( def test_tmp_path_factory_fixes_rejects_symlink_attack( tmp_path: Path, monkeypatch: MonkeyPatch ) -> None: - """ A local attacker could create a symlink at the predictable + """A local attacker could create a symlink at the predictable /tmp/pytest-of-{user} user pointing to an attacker controlled location. """ # Use the test's tmp_path as the system temproot (/tmp). @@ -644,10 +635,9 @@ def test_tmp_path_factory_fixes_rejects_symlink_attack( attacker_target = tmp_path / "attacker_controlled" # Simulate symlink attack attacker_target.mkdir(mode=0o700) - symlink_path = tmp_path = tmp_path/"pytest-of-testuser" + symlink_path = tmp_path = tmp_path / "pytest-of-testuser" symlink_path.symlink_to(attacker_target) - tmp_factory = TempPathFactory( - None, 3, "all", lambda *args: None, _ispytest=True) + tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) with pytest.raises(OSError, match="Symlink"): tmp_factory.getbasetemp() From 5b470cbeafc52acdac202a83100d4aae93af3b70 Mon Sep 17 00:00:00 2001 From: Mason Barden Date: Thu, 5 Mar 2026 20:29:49 -0500 Subject: [PATCH 3/4] restructure code to match requested format --- src/_pytest/tmpdir.py | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 10b97109fc0..b4d71555a42 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -147,6 +147,8 @@ def getbasetemp(self) -> Path: if self._basetemp is not None: return self._basetemp + basetemp: Path + if self._given_basetemp is not None: basetemp = self._given_basetemp if basetemp.exists(): @@ -157,48 +159,33 @@ def getbasetemp(self) -> Path: from_env = os.environ.get("PYTEST_DEBUG_TEMPROOT") temproot = Path(from_env or tempfile.gettempdir()).resolve() 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}") if rootdir.is_symlink(): raise OSError(f"Symlink attack detected at {rootdir}") + try: rootdir.mkdir(mode=0o700, exist_ok=True) except OSError: - # getuser() likely returned illegal characters for the platform, use unknown back off mechanism rootdir = temproot.joinpath("pytest-of-unknown") if rootdir.is_symlink(): - raise OSError(f"Symlink attack detected at {rootdir}") + raise OSError( + f"Symlink attack detected at {rootdir}") from None rootdir.mkdir(mode=0o700, exist_ok=True) + if rootdir.is_symlink(): raise OSError(f"Symlink attack detected at {rootdir}") - # 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. - uid = get_user_id() - if uid is not None: - rootdir_stat = os.lstat(rootdir) - 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) keep = self._retention_count if self._retention_policy == "none": keep = 0 - basetemp = make_numbered_dir_with_cleanup( + res = make_numbered_dir_with_cleanup( prefix="pytest-", root=rootdir, keep=keep, lock_timeout=LOCK_TIMEOUT, mode=0o700, ) - assert basetemp is not None, basetemp + basetemp = res self._basetemp = basetemp self._trace("new basetemp", basetemp) return basetemp From f98599e0492f9fa57a1ddd0a04326d5f163c1a5d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:31:25 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/tmpdir.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 6f623594217..9b666575941 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -19,7 +19,6 @@ from .pathlib import make_numbered_dir from .pathlib import make_numbered_dir_with_cleanup from .pathlib import rm_rf -from _pytest.compat import get_user_id from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import hookimpl @@ -167,8 +166,7 @@ def getbasetemp(self) -> Path: except OSError: rootdir = temproot.joinpath("pytest-of-unknown") if rootdir.is_symlink(): - raise OSError( - f"Symlink attack detected at {rootdir}") from None + raise OSError(f"Symlink attack detected at {rootdir}") from None rootdir.mkdir(mode=0o700, exist_ok=True) if rootdir.is_symlink():