Skip to content

Commit 7161cfe

Browse files
committed
tmpdir: fix insecure temporary directory vulnerability (CVE-2025-71176)
A previous fix for insecure temporary directory issue c49100c wasn't sufficient because it followed symlinks. Stop following symlinks, and reject if a symlink; we know it shouldn't be. Fix #14279. [0] https://www.openwall.com/lists/oss-security/2026/01/21/5
1 parent 2a74cdf commit 7161cfe

File tree

3 files changed

+56
-2
lines changed

3 files changed

+56
-2
lines changed

changelog/14343.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed use of insecure temporary directory (CVE-2025-71176).

src/_pytest/tmpdir.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from pathlib import Path
1010
import re
1111
from shutil import rmtree
12+
import stat
1213
import tempfile
1314
from typing import Any
1415
from typing import final
@@ -170,16 +171,37 @@ def getbasetemp(self) -> Path:
170171
# Also, to keep things private, fixup any world-readable temp
171172
# rootdir's permissions. Historically 0o755 was used, so we can't
172173
# just error out on this, at least for a while.
174+
# Don't follow symlinks, otherwise we're open to symlink-swapping
175+
# TOCTOU vulnerability.
176+
# This check makes us vulnerable to a DoS - a user can `mkdir
177+
# /tmp/pytest-of-otheruser` and then `otheruser` will fail this
178+
# check. For now we don't consider it a real problem. otheruser can
179+
# change their TMPDIR or --basetemp, and maybe give the prankster a
180+
# good scolding.
173181
uid = get_user_id()
174182
if uid is not None:
175-
rootdir_stat = rootdir.stat()
183+
stat_follow_symlinks = (
184+
False if os.stat in os.supports_follow_symlinks else True
185+
)
186+
rootdir_stat = rootdir.stat(follow_symlinks=stat_follow_symlinks)
187+
if stat.S_ISLNK(rootdir_stat.st_mode):
188+
raise OSError(
189+
f"The temporary directory {rootdir} is a symbolic link. "
190+
"Fix this and try again."
191+
)
176192
if rootdir_stat.st_uid != uid:
177193
raise OSError(
178194
f"The temporary directory {rootdir} is not owned by the current user. "
179195
"Fix this and try again."
180196
)
181197
if (rootdir_stat.st_mode & 0o077) != 0:
182-
os.chmod(rootdir, rootdir_stat.st_mode & ~0o077)
198+
chmod_follow_symlinks = (
199+
False if os.chmod in os.supports_follow_symlinks else True
200+
)
201+
rootdir.chmod(
202+
rootdir_stat.st_mode & ~0o077,
203+
follow_symlinks=chmod_follow_symlinks,
204+
)
183205
keep = self._retention_count
184206
if self._retention_policy == "none":
185207
keep = 0

testing/test_tmpdir.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import dataclasses
66
import os
77
from pathlib import Path
8+
import shutil
89
import stat
910
import sys
1011
from typing import cast
@@ -619,3 +620,33 @@ def test_tmp_path_factory_fixes_up_world_readable_permissions(
619620

620621
# After - fixed.
621622
assert (basetemp.parent.stat().st_mode & 0o077) == 0
623+
624+
625+
@pytest.mark.skipif(
626+
not hasattr(os, "getuid") or os.stat not in os.supports_follow_symlinks,
627+
reason="checks unix permissions and symlinks",
628+
)
629+
def test_tmp_path_factory_doesnt_follow_symlinks(
630+
tmp_path: Path, monkeypatch: MonkeyPatch
631+
) -> None:
632+
"""Verify that if a /tmp/pytest-of-foo directory is a symbolic link,
633+
it is rejected (#13669, CVE-2025-71176)."""
634+
attacker_controlled = tmp_path / "attacker_controlled"
635+
attacker_controlled.mkdir()
636+
637+
# Use the test's tmp_path as the system temproot (/tmp).
638+
monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path))
639+
640+
# First just get the pytest-of-user path.
641+
tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True)
642+
pytest_of_user = tmp_factory.getbasetemp().parent
643+
# Just for safety in the test, before we nuke it.
644+
assert "pytest-of-" in str(pytest_of_user)
645+
shutil.rmtree(pytest_of_user)
646+
647+
pytest_of_user.symlink_to(attacker_controlled)
648+
649+
# This now tries to use the directory when it's a symlink.
650+
tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True)
651+
with pytest.raises(OSError, match=r"temporary directory .* is a symbolic link"):
652+
tmp_factory.getbasetemp()

0 commit comments

Comments
 (0)