From 7161cfec0da7ecbbbf136f7176e90ec36288c935 Mon Sep 17 00:00:00 2001
From: Ran Benita <ran@unusedvar.com>
Date: Tue, 31 Mar 2026 16:58:20 +0300
Subject: [PATCH] tmpdir: fix insecure temporary directory vulnerability
 (CVE-2025-71176)

A previous fix for insecure temporary directory issue
c49100cef8073c5de117199d17d632cfd8cb11c1 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
---
 changelog/14343.bugfix.rst |  1 +
 src/_pytest/tmpdir.py      | 26 ++++++++++++++++++++++++--
 testing/test_tmpdir.py     | 31 +++++++++++++++++++++++++++++++
 3 files changed, 56 insertions(+), 2 deletions(-)
 create mode 100644 changelog/14343.bugfix.rst

Index: pytest-8.3.5/changelog/14343.bugfix.rst
===================================================================
--- /dev/null
+++ pytest-8.3.5/changelog/14343.bugfix.rst
@@ -0,0 +1 @@
+Fixed use of insecure temporary directory (CVE-2025-71176).
Index: pytest-8.3.5/src/_pytest/tmpdir.py
===================================================================
--- pytest-8.3.5.orig/src/_pytest/tmpdir.py
+++ pytest-8.3.5/src/_pytest/tmpdir.py
@@ -8,6 +8,7 @@ import os
 from pathlib import Path
 import re
 from shutil import rmtree
+import stat
 import tempfile
 from typing import Any
 from typing import Dict
@@ -171,16 +172,37 @@ class TempPathFactory:
             # 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.
+            # Don't follow symlinks, otherwise we're open to symlink-swapping
+            # TOCTOU vulnerability.
+            # This check makes us vulnerable to a DoS - a user can `mkdir
+            # /tmp/pytest-of-otheruser` and then `otheruser` will fail this
+            # check. For now we don't consider it a real problem. otheruser can
+            # change their TMPDIR or --basetemp, and maybe give the prankster a
+            # good scolding.
             uid = get_user_id()
             if uid is not None:
-                rootdir_stat = rootdir.stat()
+                stat_follow_symlinks = (
+                    False if os.stat in os.supports_follow_symlinks else True
+                )
+                rootdir_stat = rootdir.stat(follow_symlinks=stat_follow_symlinks)
+                if stat.S_ISLNK(rootdir_stat.st_mode):
+                    raise OSError(
+                        f"The temporary directory {rootdir} is a symbolic link. "
+                        "Fix this and try again."
+                    )
                 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)
+                    chmod_follow_symlinks = (
+                        False if os.chmod in os.supports_follow_symlinks else True
+                    )
+                    rootdir.chmod(
+                        rootdir_stat.st_mode & ~0o077,
+                        follow_symlinks=chmod_follow_symlinks,
+                    )
             keep = self._retention_count
             if self._retention_policy == "none":
                 keep = 0
Index: pytest-8.3.5/testing/test_tmpdir.py
===================================================================
--- pytest-8.3.5.orig/testing/test_tmpdir.py
+++ pytest-8.3.5/testing/test_tmpdir.py
@@ -4,6 +4,7 @@ from __future__ import annotations
 import dataclasses
 import os
 from pathlib import Path
+import shutil
 import stat
 import sys
 from typing import Callable
@@ -619,3 +620,33 @@ def test_tmp_path_factory_fixes_up_world
 
     # After - fixed.
     assert (basetemp.parent.stat().st_mode & 0o077) == 0
+
+
+@pytest.mark.skipif(
+    not hasattr(os, "getuid") or os.stat not in os.supports_follow_symlinks,
+    reason="checks unix permissions and symlinks",
+)
+def test_tmp_path_factory_doesnt_follow_symlinks(
+    tmp_path: Path, monkeypatch: MonkeyPatch
+) -> None:
+    """Verify that if a /tmp/pytest-of-foo directory is a symbolic link,
+    it is rejected (#13669, CVE-2025-71176)."""
+    attacker_controlled = tmp_path / "attacker_controlled"
+    attacker_controlled.mkdir()
+
+    # Use the test's tmp_path as the system temproot (/tmp).
+    monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path))
+
+    # First just get the pytest-of-user path.
+    tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True)
+    pytest_of_user = tmp_factory.getbasetemp().parent
+    # Just for safety in the test, before we nuke it.
+    assert "pytest-of-" in str(pytest_of_user)
+    shutil.rmtree(pytest_of_user)
+
+    pytest_of_user.symlink_to(attacker_controlled)
+
+    # This now tries to use the directory when it's a symlink.
+    tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True)
+    with pytest.raises(OSError, match=r"temporary directory .* is a symbolic link"):
+        tmp_factory.getbasetemp()
