Skip to content

tmpdir: prevent symlink attacks and TOCTOU races (CVE-2025-71176)#14279

Open
laurac8r wants to merge 58 commits intopytest-dev:mainfrom
laurac8r:hotfix/cve
Open

tmpdir: prevent symlink attacks and TOCTOU races (CVE-2025-71176)#14279
laurac8r wants to merge 58 commits intopytest-dev:mainfrom
laurac8r:hotfix/cve

Conversation

@laurac8r
Copy link

@laurac8r laurac8r commented Mar 9, 2026

Summary

Replace the predictable pytest-of-<user> rootdir with a randomly-named rootdir created via tempfile.mkdtemp to prevent the entire class of predictable-name attacks (symlink races, DoS via pre-created files/dirs). As defense-in-depth, open the rootdir with os.open using O_NOFOLLOW and O_DIRECTORY flags and perform ownership/permission checks via fd-based fstat/fchmod to eliminate TOCTOU races.

Fixes CVE-2025-71176.

closes #13669

Changes

  • Randomly-named rootdir: Use tempfile.mkdtemp(prefix=f"pytest-of-{user}-", dir=temproot) instead of a fixed pytest-of-{user} directory, making pre-creation attacks infeasible.
  • _safe_open_dir context manager: Opens the rootdir with O_NOFOLLOW | O_DIRECTORY (where available), yields the fd, and guarantees cleanup. Symlinks are rejected at the os.open level.
  • fd-based ownership & permission checks: fstat and fchmod operate on the open file descriptor, eliminating the TOCTOU window between stat/chmod and the actual directory.
  • _cleanup_old_rootdirs: Garbage-collects stale randomly-named rootdirs from previous sessions, respecting the retention count. The current session's rootdir is never removed.
  • Comprehensive tests: Symlink rejection, wrong-owner rejection, missing O_NOFOLLOW/O_DIRECTORY fallback, predictable-path DoS immunity, _cleanup_old_rootdirs behavior, _safe_open_dir fd lifecycle, config validation, and session-finish edge cases.

  • Include documentation when adding new features.
  • Include new tests or update existing tests when applicable.
  • Allow maintainers to push and squash when merging my commits. Please uncheck this if you prefer to squash the commits yourself.

If this change fixes an issue, please:

  • Add text like closes #XYZW to the PR description and/or commits (where XYZW is the issue number). See the github docs for more information.

Important

Unsupervised agentic contributions are not accepted. See our AI/LLM-Assisted Contributions Policy.

  • If AI agents were used, they are credited in Co-authored-by commit trailers.

Unless your change is trivial or a small documentation fix (e.g., a typo or reword of a small section) please:

  • Create a new changelog file in the changelog directory, with a name like <ISSUE NUMBER>.<TYPE>.rst. See changelog/README.rst for details.

    Write sentences in the past or present tense, examples:

    • Improved verbose diff output with sequences.
    • Terminal summary statistics now use multiple colors.

    Also make sure to end the sentence with a ..

  • Add yourself to AUTHORS in alphabetical order.

laurac8r added 3 commits March 9, 2026 00:49
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
Co-authored-by: Windsurf, Gemini
@psf-chronographer psf-chronographer bot added the bot:chronographer:provided (automation) changelog entry is part of PR label Mar 9, 2026
@orlitzky
Copy link

orlitzky commented Mar 9, 2026

This certainly looks like an improvement, but it's still possible for any user to DoS the machine until a reboot with for x in $(cat /etc/passwd | cut -f1 -d:); do touch /tmp/pytest-of-$x; done;.

laurac8r and others added 6 commits March 10, 2026 22:55
Co-authored-by: 🇺🇦 Sviatoslav Sydorenko (Святослав Сидоренко) <[email protected]>
Co-authored-by: 🇺🇦 Sviatoslav Sydorenko (Святослав Сидоренко) <[email protected]>
# Conflicts:
#	changelog/13669.bugfix.rst
laura and others added 4 commits March 15, 2026 16:00
- Remove blank line before tmppath_result_key assignment
- Collapse multi-line Path(tempfile.mkdtemp(...)) calls to single lines
- Remove unused `getpass` import and `original_chmod` variable in tests
- Add explicit `str` type annotation to `path` variable in symlink test
- Add blank line before test_getbasetemp_uses_mkdtemp_rootdir function
- Collapse multi-line sorted() call in TestCleanupOldRootdirs to single line

Co-authored-by: Windsurf
…missions instead of no-op

Replace the `_noop_chmod` stub (which simply skipped our chmod call) with
`_widen_chmod`, which actively sets permissions to 0o755.  This makes the
test more realistic: `fchmod` must now *correct* an adversarially-widened
directory, not merely compensate for a missing tightening step.

Co-authored-by: Windsurf
@orlitzky
Copy link

Fixed!

The new approach looks good to me FWIW.

mkdtemp() guarantees that the directory will be 0700 and owned by the current user, so the pre-existing chmod/stat safeguards conveniently become redundant if this approach is adopted :)

@laurac8r
Copy link
Author

the pre-existing chmod/stat safeguards conveniently become redundant if this approach is adopted :)

TY Michael, and let's leave it to another enthusiastic coder ;) and close the book on this CVE

@laurac8r laurac8r requested a review from webknjaz March 17, 2026 00:52
@orlitzky
Copy link

In light of another recent tmpdir exploit related to directory cleanup, I think we could bullet-proof the cleanup routine even more by ensuring that https://docs.python.org/3/library/shutil.html#shutil.rmtree.avoids_symlink_attacks is true before running rmtree().

It would be nice if we could confirm that the user/owner agree on the directories to be removed, and if we could remove them by file descriptor (to avoid TOCTOU again), but I don't see an obvious way to do that.

Copy link
Member

@nicoddemus nicoddemus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @laurac8r for the PR and, @webknjaz and @orlitzky for the reviews.

mode=0o700,
)
# Clean up old rootdirs from previous sessions.
_cleanup_old_rootdirs(temproot, rootdir_prefix, keep, current=rootdir)
Copy link
Member

@nicoddemus nicoddemus Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that before we used to keep 3 directories around, but on this branch we always end up with 4 directories, which is a bit confusing if configured to keep 3 directories around (the default).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thing I noticed is now that we always generate a "root" temporary dir on every run:

pytest-of-Bruno-xe7su5xh, pytest-of-Bruno-vwyhe35x, etc, which is fine as it is the whole point of this PR.

However, each directory also has a pytest-0 and pytest-current (a symlink to pytest-0), which no longer makes sense in this scenario.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the extra directory seems to have been removed, but we still produce a symlink:

def test(tmp_path): 
    print(tmp_path)
	

def test2(tmp_path): 
    print(tmp_path)

This produces:

ls /tmp/pytest-of-bruno-pw9tlny6
test0  test20  test2current  testcurrent

We will always have a single directory now, so the symlinks are no longer necessary.



@contextlib.contextmanager
def _safe_open_dir(path: Path) -> Generator[int]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is only used in one place:

with _safe_open_dir(rootdir) as dir_fd:
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)
keep = self._retention_count
if self._retention_policy == "none":
keep = 0
basetemp = make_numbered_dir_with_cleanup(

I think we should instead have a more focused function: _verify_ownership_and_tighthen_permission(path, user_id), instead of splitting the logic that we ultimately want in two separate places.

Comment on lines +642 to +643
symlink_warnings = [x for x in w if "avoids_symlink_attacks" in str(x.message)]
assert len(symlink_warnings) == 0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This lets us see the warning in case something goes wrong:

Suggested change
symlink_warnings = [x for x in w if "avoids_symlink_attacks" in str(x.message)]
assert len(symlink_warnings) == 0
symlink_warnings = [x for x in w if "avoids_symlink_attacks" in str(x.message)]
assert symlink_warnings == []



class TestSafeRmtree:
"""Tests for safe_rmtree and the avoids_symlink_attacks guard."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""Tests for safe_rmtree and the avoids_symlink_attacks guard."""
"""Tests for safe_rmtree and the avoids_symlink_attacks guard (#13669)."""

assert (basetemp.parent.stat().st_mode & 0o077) == 0


def test_tmp_path_factory_from_config_rejects_negative_count(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we might be going overboard with the number of tests here. Could you verify if it is possible to remove/merge some of them?

@@ -0,0 +1,9 @@
Fixed a symlink attack vulnerability ([CVE-2025-71176](https://github.com/pytest-dev/pytest/issues/13669)) in
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This description is detailed, but we should begin it with the user facing change:

Temporary directories created by :fixture:`tmp_path` used to be created inside a `pytest-of-{user}` root directory within the system's temporary directory. Each session would create a new subdirectory with an incrementing counter -- `pytest-0`, `pytest-1`, etc. -- along with a `pytest-current` symlink pointing to the current one.

This left the system vulnerable to symlink attacks, so pytest has changed how these directories are created.

Now, each session creates a single `pytest-of-{user}-{random}` directory directly inside the system's temporary directory. This makes the directories unpredictable and safe from symlink attacks and `TOCTOU <https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use>`__ races.

Also note that this entry should be written in ReStructuredText, not Markdown.

@salmankadaya
Copy link

Could you please let me know when the new release containing this fix will be available?

@nicoddemus
Copy link
Member

Could you please let me know when the new release containing this fix will be available?

It will be available on the next release, you can subscribe for release notifications here on GitHub. 👍

Copy link
Member

@nicoddemus nicoddemus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks I think we are almost there.

Also please take a look at the failing test, seems it is just missing an encoding argument in test_removes_real_directory.

Comment on lines +38 to +70
def _verify_ownership_and_tighten_permissions(path: Path, user_id: int) -> None:
"""Verify directory ownership and ensure private permissions (0o700).

Opens *path* with ``O_NOFOLLOW`` / ``O_DIRECTORY`` (when available) so
that symlinks are never followed, then uses fd-based ``fstat`` /
``fchmod`` to eliminate TOCTOU races (CVE-2025-71176).

Raises:
OSError: If *path* cannot be safely opened (e.g. it is a symlink)
or is not owned by *user_id*.
"""
open_flags = os.O_RDONLY
for _flag in ("O_NOFOLLOW", "O_DIRECTORY"):
open_flags |= getattr(os, _flag, 0)
try:
dir_fd = os.open(str(path), open_flags)
except OSError as e:
raise OSError(
f"The temporary directory {path} could not be "
"safely opened (it may be a symlink). "
"Remove the symlink or directory and try again."
) from e
try:
st = os.fstat(dir_fd)
if st.st_uid != user_id:
raise OSError(
f"The temporary directory {path} is not owned by the current user. "
"Fix this and try again."
)
if (st.st_mode & 0o077) != 0:
os.fchmod(dir_fd, st.st_mode & ~0o077)
finally:
os.close(dir_fd)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs describe the implementation, which is considered a bad practice.

Please move these remarks to comments in the body.

Suggested change
def _verify_ownership_and_tighten_permissions(path: Path, user_id: int) -> None:
"""Verify directory ownership and ensure private permissions (0o700).
Opens *path* with ``O_NOFOLLOW`` / ``O_DIRECTORY`` (when available) so
that symlinks are never followed, then uses fd-based ``fstat`` /
``fchmod`` to eliminate TOCTOU races (CVE-2025-71176).
Raises:
OSError: If *path* cannot be safely opened (e.g. it is a symlink)
or is not owned by *user_id*.
"""
open_flags = os.O_RDONLY
for _flag in ("O_NOFOLLOW", "O_DIRECTORY"):
open_flags |= getattr(os, _flag, 0)
try:
dir_fd = os.open(str(path), open_flags)
except OSError as e:
raise OSError(
f"The temporary directory {path} could not be "
"safely opened (it may be a symlink). "
"Remove the symlink or directory and try again."
) from e
try:
st = os.fstat(dir_fd)
if st.st_uid != user_id:
raise OSError(
f"The temporary directory {path} is not owned by the current user. "
"Fix this and try again."
)
if (st.st_mode & 0o077) != 0:
os.fchmod(dir_fd, st.st_mode & ~0o077)
finally:
os.close(dir_fd)
def _verify_ownership_and_tighten_permissions(path: Path, user_id: int) -> None:
"""Verify directory ownership and ensure private permissions (0o700),
taking care to avoid TOCTOU races.
Raises:
OSError: If *path* cannot be safely opened (e.g. it is a symlink)
or is not owned by *user_id*.
"""
open_flags = os.O_RDONLY
# Never follow symlinks (when these flags are available).
for _flag in ("O_NOFOLLOW", "O_DIRECTORY"):
open_flags |= getattr(os, _flag, 0)
try:
dir_fd = os.open(str(path), open_flags)
except OSError as e:
raise OSError(
f"The temporary directory {path} could not be "
"safely opened (it may be a symlink). "
"Remove the symlink or directory and try again."
) from e
try:
# Use fd-based functions to eliminate TOCTOU races (CVE-2025-71176).
st = os.fstat(dir_fd)
if st.st_uid != user_id:
raise OSError(
f"The temporary directory {path} is not owned by the current user. "
"Fix this and try again."
)
if (st.st_mode & 0o077) != 0:
os.fchmod(dir_fd, st.st_mode & ~0o077)
finally:
os.close(dir_fd)

Comment on lines +81 to +83
Uses ``os.scandir`` with ``follow_symlinks=False`` so that symlinks
planted under *temproot* are never followed during enumeration —
defense-in-depth against symlink attacks (CVE-2025-71176).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably better as a comment in the code, as is describing an implementation detail.

keep=keep,
lock_timeout=LOCK_TIMEOUT,
mode=0o700,
# keep-1 because the current session's rootdir (excluded from
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

mode=0o700,
)
# Clean up old rootdirs from previous sessions.
_cleanup_old_rootdirs(temproot, rootdir_prefix, keep, current=rootdir)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the extra directory seems to have been removed, but we still produce a symlink:

def test(tmp_path): 
    print(tmp_path)
	

def test2(tmp_path): 
    print(tmp_path)

This produces:

ls /tmp/pytest-of-bruno-pw9tlny6
test0  test20  test2current  testcurrent

We will always have a single directory now, so the symlinks are no longer necessary.

@@ -0,0 +1,5 @@
Temporary directories created by :fixture:`tmp_path` used to be created inside a ``pytest-of-{user}`` root directory within the system's temporary directory. Each session would create a new subdirectory with an incrementing counter -- ``pytest-0``, ``pytest-1``, etc. -- along with a ``pytest-current`` symlink pointing to the current one.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to duplicate the entry for the PR. This one can should be removed.

Comment on lines +194 to +197
Verifies that ``shutil.rmtree.avoids_symlink_attacks`` is True (the
platform provides a symlink-attack-resistant implementation) before
proceeding. On platforms without this guarantee an explicit symlink
check is performed and a warning is emitted.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Describing the implementation, better as comment in the body.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided (automation) changelog entry is part of PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Vulnerable tmpdir handling (CVE-2025-71176)

5 participants