-
-
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
ed4a728
206731a
975b944
d456ad4
a624cc1
c025681
f2c6f23
d58ba2a
9d501ec
45bdce9
879767b
ab3d9e4
40e8fdd
64aa0f1
e1ab060
c8be3c5
3a27865
e403fbf
0e3581c
f9918cf
d1d6cae
24003ce
064b26e
124027e
654e3dd
9a586f6
43aa2c8
e696db0
65fc036
6437860
1d1cf8e
a0c8ace
1d4fee4
81de30b
9f82169
9a7726e
a8b8456
04baaa3
bb1371b
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,9 @@ | ||
| Fixed a symlink attack vulnerability ([CVE-2025-71176](https://github.com/pytest-dev/pytest/issues/13669)) in | ||
|
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. 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. |
||
| the [tmp_path](https://github.com/pytest-dev/pytest/blob/295d9da900a0dbe8b4093d6a6bc977cd567aa4b0/src/_pytest/tmpdir.py#L258) | ||
| fixture's base directory handling. | ||
|
|
||
| The ``pytest-of-<user>`` directory under the system temp root is now opened | ||
| with [O_NOFOLLOW](https://man7.org/linux/man-pages/man2/open.2.html#:~:text=not%20have%20one.-,O_NOFOLLOW,-If%20the%20trailing) | ||
| and verified using | ||
| file-descriptor-based [fstat](https://linux.die.net/man/2/fstat)/[fchmod](https://linux.die.net/man/2/fchmod), | ||
| preventing symlink attacks and [TOCTOU](https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use) races. | ||
laurac8r marked this conversation as resolved.
Show resolved
Hide resolved
|
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,11 +4,11 @@ | |||||||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| from collections.abc import Generator | ||||||||||||||||||||||||||||
| import contextlib | ||||||||||||||||||||||||||||
| import dataclasses | ||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||||||||||
| import re | ||||||||||||||||||||||||||||
| from shutil import rmtree | ||||||||||||||||||||||||||||
| import tempfile | ||||||||||||||||||||||||||||
| from typing import Any | ||||||||||||||||||||||||||||
| from typing import final | ||||||||||||||||||||||||||||
|
|
@@ -19,6 +19,7 @@ | |||||||||||||||||||||||||||
| from .pathlib import make_numbered_dir | ||||||||||||||||||||||||||||
| from .pathlib import make_numbered_dir_with_cleanup | ||||||||||||||||||||||||||||
| from .pathlib import rm_rf | ||||||||||||||||||||||||||||
| from .pathlib import safe_rmtree | ||||||||||||||||||||||||||||
| from _pytest.compat import get_user_id | ||||||||||||||||||||||||||||
| from _pytest.config import Config | ||||||||||||||||||||||||||||
| from _pytest.config import ExitCode | ||||||||||||||||||||||||||||
|
|
@@ -37,6 +38,70 @@ | |||||||||||||||||||||||||||
| RetentionType = Literal["all", "failed", "none"] | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| @contextlib.contextmanager | ||||||||||||||||||||||||||||
| def _safe_open_dir(path: Path) -> Generator[int]: | ||||||||||||||||||||||||||||
|
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. This function is only used in one place: Lines 242 to 254 in bb1371b
I think we should instead have a more focused function: |
||||||||||||||||||||||||||||
| """Open a directory without following symlinks and yield its file descriptor. | ||||||||||||||||||||||||||||
| Uses O_NOFOLLOW and O_DIRECTORY (when available) to prevent symlink | ||||||||||||||||||||||||||||
| attacks (CVE-2025-71176). The fd-based operations (fstat, fchmod) | ||||||||||||||||||||||||||||
| also eliminate TOCTOU races. | ||||||||||||||||||||||||||||
| Args: | ||||||||||||||||||||||||||||
| path: Directory to open. | ||||||||||||||||||||||||||||
| Yields: | ||||||||||||||||||||||||||||
| An open file descriptor for the directory. | ||||||||||||||||||||||||||||
| Raises: | ||||||||||||||||||||||||||||
| OSError: If the path cannot be safely opened (e.g. it is a symlink). | ||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||
| 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: | ||||||||||||||||||||||||||||
| yield dir_fd | ||||||||||||||||||||||||||||
| finally: | ||||||||||||||||||||||||||||
| os.close(dir_fd) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def _cleanup_old_rootdirs( | ||||||||||||||||||||||||||||
| temproot: Path, prefix: str, keep: int, current: Path | ||||||||||||||||||||||||||||
| ) -> None: | ||||||||||||||||||||||||||||
| """Remove old randomly-named rootdirs, keeping the *keep* most recent. | ||||||||||||||||||||||||||||
| *current* is excluded so the running session's rootdir is never removed. | ||||||||||||||||||||||||||||
| Errors are silently ignored (other sessions may hold locks, etc.). | ||||||||||||||||||||||||||||
| 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). | ||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||
| candidates = sorted( | ||||||||||||||||||||||||||||
| ( | ||||||||||||||||||||||||||||
| Path(entry.path) | ||||||||||||||||||||||||||||
| for entry in os.scandir(temproot) | ||||||||||||||||||||||||||||
| if entry.is_dir(follow_symlinks=False) | ||||||||||||||||||||||||||||
| and entry.name.startswith(prefix) | ||||||||||||||||||||||||||||
| and Path(entry.path) != current | ||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||
| key=lambda p: p.lstat().st_mtime, | ||||||||||||||||||||||||||||
| reverse=True, | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
| except OSError: | ||||||||||||||||||||||||||||
|
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 this try/except? Please add a comment for future reference. |
||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||
| for old in candidates[keep:]: | ||||||||||||||||||||||||||||
| safe_rmtree(old, ignore_errors=True) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| @final | ||||||||||||||||||||||||||||
| @dataclasses.dataclass | ||||||||||||||||||||||||||||
| class TempPathFactory: | ||||||||||||||||||||||||||||
|
|
@@ -157,29 +222,32 @@ def getbasetemp(self) -> Path: | |||||||||||||||||||||||||||
| 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}") | ||||||||||||||||||||||||||||
| # Use a randomly-named rootdir created via mkdtemp to avoid | ||||||||||||||||||||||||||||
| # the entire class of predictable-name attacks (symlink races, | ||||||||||||||||||||||||||||
| # DoS via pre-created files/dirs, etc.). See #13669. | ||||||||||||||||||||||||||||
| rootdir_prefix = f"pytest-of-{user}-" | ||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||
| rootdir.mkdir(mode=0o700, exist_ok=True) | ||||||||||||||||||||||||||||
| rootdir = Path(tempfile.mkdtemp(prefix=rootdir_prefix, dir=temproot)) | ||||||||||||||||||||||||||||
| except OSError: | ||||||||||||||||||||||||||||
| # getuser() likely returned illegal characters for the platform, use unknown back off mechanism | ||||||||||||||||||||||||||||
| rootdir = temproot.joinpath("pytest-of-unknown") | ||||||||||||||||||||||||||||
| rootdir.mkdir(mode=0o700, exist_ok=True) | ||||||||||||||||||||||||||||
| # 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. | ||||||||||||||||||||||||||||
| # getuser() likely returned illegal characters for the | ||||||||||||||||||||||||||||
| # platform, fall back to a safe prefix. | ||||||||||||||||||||||||||||
| rootdir_prefix = "pytest-of-unknown-" | ||||||||||||||||||||||||||||
| rootdir = Path(tempfile.mkdtemp(prefix=rootdir_prefix, dir=temproot)) | ||||||||||||||||||||||||||||
| # mkdtemp applies the umask; ensure 0o700 unconditionally. | ||||||||||||||||||||||||||||
| os.chmod(rootdir, 0o700) | ||||||||||||||||||||||||||||
| # Defense-in-depth: verify ownership and tighten permissions | ||||||||||||||||||||||||||||
| # via fd-based ops to eliminate TOCTOU races (CVE-2025-71176). | ||||||||||||||||||||||||||||
| uid = get_user_id() | ||||||||||||||||||||||||||||
| if uid is not None: | ||||||||||||||||||||||||||||
| rootdir_stat = rootdir.stat() | ||||||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||
|
|
@@ -190,6 +258,8 @@ def getbasetemp(self) -> Path: | |||||||||||||||||||||||||||
| lock_timeout=LOCK_TIMEOUT, | ||||||||||||||||||||||||||||
| mode=0o700, | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
| # Clean up old rootdirs from previous sessions. | ||||||||||||||||||||||||||||
| _cleanup_old_rootdirs(temproot, rootdir_prefix, keep, current=rootdir) | ||||||||||||||||||||||||||||
|
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. 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).
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. Another thing I noticed is now that we always generate a "root" temporary dir on every run:
However, each directory also has a |
||||||||||||||||||||||||||||
| assert basetemp is not None, basetemp | ||||||||||||||||||||||||||||
| self._basetemp = basetemp | ||||||||||||||||||||||||||||
| self._trace("new basetemp", basetemp) | ||||||||||||||||||||||||||||
|
|
@@ -274,7 +344,7 @@ def tmp_path( | |||||||||||||||||||||||||||
| if policy == "failed" and result_dict.get("call", True): | ||||||||||||||||||||||||||||
| # We do a "best effort" to remove files, but it might not be possible due to some leaked resource, | ||||||||||||||||||||||||||||
| # permissions, etc, in which case we ignore it. | ||||||||||||||||||||||||||||
| rmtree(path, ignore_errors=True) | ||||||||||||||||||||||||||||
| safe_rmtree(path, ignore_errors=True) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| del request.node.stash[tmppath_result_key] | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
@@ -297,7 +367,7 @@ def pytest_sessionfinish(session, exitstatus: int | ExitCode): | |||||||||||||||||||||||||||
| if basetemp.is_dir(): | ||||||||||||||||||||||||||||
| # We do a "best effort" to remove files, but it might not be possible due to some leaked resource, | ||||||||||||||||||||||||||||
| # permissions, etc, in which case we ignore it. | ||||||||||||||||||||||||||||
| rmtree(basetemp, ignore_errors=True) | ||||||||||||||||||||||||||||
| safe_rmtree(basetemp, ignore_errors=True) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # Remove dead symlinks. | ||||||||||||||||||||||||||||
| if basetemp.is_dir(): | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.