python3-virtualenv: fix CVE-2026-113721

This patch applies the upstream fix as referenced in [2], using the
commit shown in [1]. Replace existing symbolic and hard links before
writing generated files, and create the replacement files exclusively.
Prepare the stdlib venv executable directory before it writes files.

[1] https://github.com/pypa/virtualenv/commit/d25f28699eefb1fc34a251e5adf2b5de844c36cd
[2] https://nvd.nist.gov/vuln/detail/CVE-2026-113721

Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
Signed-off-by: Anuj Mittal <anuj.mittal@oss.qualcomm.com>
This commit is contained in:
Hetvi Thakar authored and Anuj Mittal committed 2026-11-09 09:07:13 +05:30
1 parent 90f0e58d3d
commit 15567524b8
2 files changed
+478

No files matched your search

@@ -0,0 +1,477 @@
From d25f28699eefb1fc34a251e5adf2b5de844c36cd Mon Sep 17 00:00:00 2001
From: Bernát Gábor <gaborjbernat@gmail.com>
Date: Sat, 17 Oct 2026 03:25:09 -0700
Subject: [PATCH] fix(create): replace generated file links (#3431)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
CVE: CVE-2026-113721
Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/d25f28699eefb1fc34a251e5adf2b5de844c36cd]
Backport Changes:
- Replace PyEnvCfg's older write_text() call instead of write_bytes();
retain _one_line() serialization and separate the new helper import
from the type-only imports, without the later util.text refactor.
- Add the Windows junction-constant guard absent from 21.2.1's helper
module and retain S_IWUSR for its existing deletion callback; do not
import the later read-only deletion refactor.
- Add the missing fs_supports_symlink test import and extend the older
test_util path-import list with write_file.
- Omit the release-only changelog fragment; the release documentation
directory is absent from the 21.2.1 source distribution.
Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
---
src/virtualenv/activation/via_template.py | 21 ++-
src/virtualenv/create/creator.py | 6 +-
src/virtualenv/create/pyenv_cfg.py | 4 +-
src/virtualenv/create/via_global_ref/api.py | 5 +-
src/virtualenv/create/via_global_ref/venv.py | 8 +-
.../embed/via_app_data/pip_install/base.py | 4 +
src/virtualenv/util/path/__init__.py | 4 +-
src/virtualenv/util/path/_sync.py | 46 ++++++-
tests/unit/create/test_creator.py | 127 +++++++++++++++++-
tests/unit/test_util.py | 11 +-
10 files changed, 213 insertions(+), 23 deletions(-)
diff --git a/src/virtualenv/activation/via_template.py b/src/virtualenv/activation/via_template.py
index 3af9dc5..3fba6ad 100644
--- a/src/virtualenv/activation/via_template.py
+++ b/src/virtualenv/activation/via_template.py
@@ -5,7 +5,9 @@ import re
import shlex
import sys
from abc import ABC, abstractmethod
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Final
+
+from virtualenv.util.path import write_file
from .activator import Activator
@@ -63,18 +65,13 @@ class ViaTemplateActivator(Activator, ABC):
def _generate(
self, replacements: dict[str, str], templates: Iterable[str], to_folder: Path, creator: Creator
) -> list[Path]:
- generated = []
+ generated: Final[list[Path]] = []
for template in templates:
- text = self.instantiate_template(replacements, template, creator)
- dest = to_folder / self.as_name(template)
- # remove the file if it already exists - this prevents permission
- # errors when the dest is not writable
- if dest.exists():
- dest.unlink()
- # Powershell assumes Windows 1252 encoding when reading files without BOM
- encoding = "utf-8-sig" if str(template).endswith(".ps1") else "utf-8"
- # use write_bytes to avoid platform specific line normalization (\n -> \r\n)
- dest.write_bytes(text.encode(encoding))
+ text: Final = self.instantiate_template(replacements, template, creator)
+ dest: Final = to_folder / self.as_name(template)
+ # PowerShell assumes Windows 1252 when the UTF-8 BOM is absent.
+ encoding: Final = "utf-8-sig" if str(template).endswith(".ps1") else "utf-8"
+ write_file(dest, text.encode(encoding))
generated.append(dest)
return generated
diff --git a/src/virtualenv/create/creator.py b/src/virtualenv/create/creator.py
index 9fa60d3..855d5a1 100644
--- a/src/virtualenv/create/creator.py
+++ b/src/virtualenv/create/creator.py
@@ -23,7 +23,7 @@ if TYPE_CHECKING:
from os.path import commonpath
-from virtualenv.util.path import safe_delete
+from virtualenv.util.path import safe_delete, write_file
from virtualenv.util.subprocess import LogCmd, run_cmd
from virtualenv.version import __version__
@@ -215,7 +215,7 @@ class Creator(ABC):
# For information about cache directory tags, see:
# https://bford.info/cachedir/
""").strip()
- cachedir_tag_file.write_text(cachedir_tag_text, encoding="utf-8")
+ write_file(cachedir_tag_file, cachedir_tag_text.encode("utf-8"))
def set_pyenv_cfg(self) -> None:
self.pyenv_cfg.content = OrderedDict()
@@ -237,7 +237,7 @@ class Creator(ABC):
# mark this folder to be ignored by VCS, handle https://www.python.org/dev/peps/pep-0610/#registered-vcs
git_ignore = self.dest / ".gitignore"
if not git_ignore.exists():
- git_ignore.write_text("# created by virtualenv automatically\n*\n", encoding="utf-8")
+ write_file(git_ignore, b"# created by virtualenv automatically\n*\n")
# Mercurial - does not support the .hgignore file inside a subdirectory directly, but only if included via the
# subinclude directive from root, at which point on might as well ignore the directory itself, see
# https://www.selenic.com/mercurial/hgignore.5.html for more details
diff --git a/src/virtualenv/create/pyenv_cfg.py b/src/virtualenv/create/pyenv_cfg.py
index 52411fb..c0fb345 100644
--- a/src/virtualenv/create/pyenv_cfg.py
+++ b/src/virtualenv/create/pyenv_cfg.py
@@ -5,6 +5,8 @@ import os
from collections import OrderedDict
from typing import TYPE_CHECKING, Final
+from virtualenv.util.path import write_file
+
if TYPE_CHECKING:
from pathlib import Path
@@ -67,7 +69,7 @@ class PyEnvCfg:
LOGGER.debug("\t%s", line)
text += line
text += "\n"
- self.path.write_text(text, encoding="utf-8")
+ write_file(self.path, text.encode("utf-8"))
def refresh(self) -> OrderedDict[str, str]:
self.content = self._read_values(self.path)
diff --git a/src/virtualenv/create/via_global_ref/api.py b/src/virtualenv/create/via_global_ref/api.py
index 5cb8e12..5382419 100644
--- a/src/virtualenv/create/via_global_ref/api.py
+++ b/src/virtualenv/create/via_global_ref/api.py
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
from virtualenv.create.creator import Creator, CreatorMeta
from virtualenv.info import fs_supports_symlink
+from virtualenv.util.path import write_file
if TYPE_CHECKING:
from argparse import ArgumentParser
@@ -116,10 +117,10 @@ class ViaGlobalRefApi(Creator, ABC):
if text:
pth = self.purelib / "_virtualenv.pth"
LOGGER.debug("create virtualenv import hook file %s", pth)
- pth.write_text("import _virtualenv", encoding="utf-8")
+ write_file(pth, b"import _virtualenv")
dest_path = self.purelib / "_virtualenv.py"
LOGGER.debug("create %s", dest_path)
- dest_path.write_text(text, encoding="utf-8")
+ write_file(dest_path, text.encode("utf-8"))
def env_patch_text(self) -> str:
"""Patch the distutils package to not be derailed by its configuration files."""
diff --git a/src/virtualenv/create/via_global_ref/venv.py b/src/virtualenv/create/via_global_ref/venv.py
index 2bfd1b4..8b704eb 100644
--- a/src/virtualenv/create/via_global_ref/venv.py
+++ b/src/virtualenv/create/via_global_ref/venv.py
@@ -9,7 +9,7 @@ from python_discovery import PythonInfo
from virtualenv.create.pyenv_cfg import _one_line as collapse_line_boundaries
from virtualenv.create.via_global_ref.store import handle_store_python
from virtualenv.util.error import ProcessCallFailedError
-from virtualenv.util.path import ensure_dir
+from virtualenv.util.path import ensure_dir, ensure_within
from virtualenv.util.subprocess import run_cmd
from .api import ViaGlobalRefApi, ViaGlobalRefMeta
@@ -50,6 +50,12 @@ class Venv(ViaGlobalRefApi):
return None
def create(self) -> None:
+ # stdlib venv writes interpreter files, configuration and activation scripts in place, so a planted symlink or
+ # hard link would receive them outside the environment
+ ensure_within(self.dest, self.bin_dir)
+ for path in (self.dest / "pyvenv.cfg", self.dest / ".gitignore", *self.bin_dir.iterdir()):
+ if path.is_symlink() or (path.is_file() and path.stat().st_nlink > 1):
+ path.unlink()
if self.can_be_inline:
self.create_inline()
else:
diff --git a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py
index 7697e73..086ae0c 100644
--- a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py
+++ b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py
@@ -219,6 +219,10 @@ class ScriptMakerCustom(ScriptMaker):
self, names: set[str], shebang: bytes, script_bytes: bytes, filenames: list[str], ext: str
) -> None:
names.add(f"{self._name}{self.version_info[0]}.{self.version_info[1]}")
+ for name in names:
+ path: Final = Path(self.target_dir) / (f"{name}.exe" if os.name == "nt" else name)
+ if path.is_symlink():
+ path.unlink()
super()._write_script(names, shebang, script_bytes, filenames, ext)
diff --git a/src/virtualenv/util/path/__init__.py b/src/virtualenv/util/path/__init__.py
index c144d8d..451630f 100644
--- a/src/virtualenv/util/path/__init__.py
+++ b/src/virtualenv/util/path/__init__.py
@@ -1,16 +1,18 @@
from __future__ import annotations
from ._permission import make_exe, set_tree
-from ._sync import copy, copytree, ensure_dir, safe_delete, symlink
+from ._sync import copy, copytree, ensure_dir, ensure_within, safe_delete, symlink, write_file
from ._win import get_short_path_name
__all__ = [
"copy",
"copytree",
"ensure_dir",
+ "ensure_within",
"get_short_path_name",
"make_exe",
"safe_delete",
"set_tree",
"symlink",
+ "write_file",
]
diff --git a/src/virtualenv/util/path/_sync.py b/src/virtualenv/util/path/_sync.py
index a94466f..fae3859 100644
--- a/src/virtualenv/util/path/_sync.py
+++ b/src/virtualenv/util/path/_sync.py
@@ -5,9 +5,12 @@ import os
import shutil
import sys
from pathlib import Path
-from stat import S_IWUSR, S_IXGRP, S_IXOTH, S_IXUSR
+from stat import S_ISDIR, S_ISLNK, S_IWUSR, S_IXGRP, S_IXOTH, S_IXUSR
from typing import Final
+if os.name == "nt":
+ from stat import IO_REPARSE_TAG_MOUNT_POINT
+
LOGGER = logging.getLogger(__name__)
@@ -17,6 +20,45 @@ def ensure_dir(path: Path) -> None:
os.makedirs(str(path))
+def ensure_within(root: Path, path: Path) -> None:
+ parts: Final = path.relative_to(root).parts
+ if ".." in parts:
+ msg: Final = f"environment directory {path} must remain inside {root}"
+ raise ValueError(msg)
+ ensure_dir(root)
+ folder = root
+ for part in parts:
+ folder /= part
+ try:
+ metadata: Final = folder.lstat()
+ except FileNotFoundError:
+ pass
+ else:
+ if S_ISLNK(metadata.st_mode):
+ LOGGER.warning("replace environment directory link %s", folder)
+ folder.unlink()
+ elif os.name == "nt" and metadata.st_reparse_tag == IO_REPARSE_TAG_MOUNT_POINT:
+ LOGGER.warning("replace environment directory junction %s", folder)
+ folder.rmdir()
+ try:
+ folder.mkdir()
+ except FileExistsError:
+ created: Final = folder.lstat()
+ if not S_ISDIR(created.st_mode) or (
+ os.name == "nt" and created.st_reparse_tag == IO_REPARSE_TAG_MOUNT_POINT
+ ):
+ msg: Final = f"environment directory is not a real directory: {folder}"
+ raise NotADirectoryError(msg) from None
+
+
+def write_file(path: Path, data: bytes) -> None:
+ if path.is_symlink() or path.exists():
+ path.unlink()
+ # Exclusive creation rejects links introduced after the old entry was removed.
+ with path.open("xb") as stream:
+ stream.write(data)
+
+
def ensure_safe_to_do(src: Path, dest: Path) -> None:
if src == dest:
msg = f"source and destination is the same {src}"
@@ -90,6 +132,8 @@ __all__ = [
"copy",
"copytree",
"ensure_dir",
+ "ensure_within",
"safe_delete",
"symlink",
+ "write_file",
]
diff --git a/tests/unit/create/test_creator.py b/tests/unit/create/test_creator.py
index bd0f071..bc02679 100644
--- a/tests/unit/create/test_creator.py
+++ b/tests/unit/create/test_creator.py
@@ -30,13 +30,101 @@ from virtualenv.create.pyenv_cfg import PyEnvCfg
from virtualenv.create.via_global_ref import api
from virtualenv.create.via_global_ref.builtin.cpython.common import is_mac_os_framework, is_macos_brew
from virtualenv.create.via_global_ref.builtin.cpython.cpython3 import CPython3Posix
-from virtualenv.info import IS_PYPY, IS_WIN, fs_is_case_sensitive
+from virtualenv.info import IS_GRAALPY, IS_PYPY, IS_WIN, fs_is_case_sensitive, fs_supports_symlink
from virtualenv.run import cli_run, session_via_cli
from virtualenv.run.plugin.creators import CreatorSelector
CURRENT = PythonInfo.current_system()
+@pytest.mark.parametrize("creator_name", [pytest.param("builtin", id="builtin"), pytest.param("venv", id="venv")])
+@pytest.mark.parametrize("existing", [pytest.param(False, id="dangling"), pytest.param(True, id="existing")])
+@pytest.mark.parametrize(
+ "generated",
+ [
+ pytest.param(name, id=name)
+ for name in ("pyvenv.cfg", "activate", "pip", "CACHEDIR.TAG", ".gitignore", "_virtualenv.py", "_virtualenv.pth")
+ ],
+)
+def test_creator_replaces_generated_file_links(
+ tmp_path: Path,
+ creator_name: str,
+ existing: bool,
+ generated: str,
+) -> None:
+ command: Final = [
+ str(tmp_path / "env"),
+ "--creator",
+ creator_name,
+ "--pip",
+ "embed",
+ "--no-periodic-update",
+ ]
+ if generated.startswith("_virtualenv"):
+ if (python39 := shutil.which("python3.9")) is None:
+ pytest.skip("Python 3.9 is unavailable")
+ command.extend(["--python", python39])
+ session: Final = session_via_cli(command)
+ session.creator.bin_dir.mkdir(parents=True)
+ target: Final = session.creator.dest / "original"
+ if existing:
+ target.write_bytes(b"original")
+ path = (
+ session.creator.bin_dir / ("pip.exe" if generated == "pip" and os.name == "nt" else generated)
+ if generated in {"activate", "pip"}
+ else session.creator.dest / generated
+ )
+ if generated.startswith("_virtualenv"):
+ path = session.creator.purelib / generated
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.symlink_to(target)
+ with session:
+ session.run()
+ if existing and (generated == "CACHEDIR.TAG" or (generated == ".gitignore" and creator_name == "builtin")):
+ assert path.is_symlink()
+ else:
+ assert not path.is_symlink()
+ assert (target.read_bytes() if existing else target.exists()) == (b"original" if existing else False)
+
+
+@pytest.mark.parametrize(
+ "kind",
+ [
+ pytest.param("file", id="file"),
+ pytest.param("symlink", id="symlink"),
+ pytest.param("dangling", id="dangling"),
+ pytest.param("junction", id="junction"),
+ ],
+)
+def test_venv_prepares_real_executable_directory(tmp_path: Path, kind: str) -> None:
+ session: Final = session_via_cli([str(tmp_path / "env"), "--creator", "venv", "--no-seed", "--activators="])
+ session.creator.dest.mkdir()
+ if kind == "file":
+ session.creator.bin_dir.write_bytes(b"file")
+ else:
+ target: Final = session.creator.dest / "empty"
+ if kind != "dangling":
+ target.mkdir()
+ if kind == "junction":
+ if not IS_WIN:
+ pytest.skip("requires Windows junctions")
+ subprocess.run(
+ ["cmd", "/c", "mklink", "/J", str(session.creator.bin_dir), str(target)],
+ capture_output=True,
+ check=True,
+ )
+ else:
+ if not fs_supports_symlink():
+ pytest.skip("requires directory symlinks")
+ session.creator.bin_dir.symlink_to(target, target_is_directory=True)
+ if kind == "file":
+ with pytest.raises(NotADirectoryError, match="environment directory is not a real directory"):
+ session.run()
+ else:
+ session.run()
+ assert session.creator.bin_dir.resolve() == session.creator.bin_dir
+
+
def test_os_path_sep_not_allowed(tmp_path, capsys) -> None:
target = str(tmp_path / f"a{os.pathsep}b")
err = _non_success_exit_code(capsys, target)
@@ -326,6 +414,43 @@ def test_venv_configuration_uses_selected_options(tmp_path: Path, global_package
assert {key: session.creator.pyenv_cfg.content.get(key) for key in expected} == expected
+@pytest.mark.skipif(not CURRENT.has_venv or not fs_supports_symlink(), reason="requires venv and symlinks")
+@pytest.mark.skipif(
+ is_mac_os_framework(CURRENT) or is_macos_brew(CURRENT) or (IS_GRAALPY and not IS_WIN),
+ reason="stdlib creator requires symlinks on this runtime",
+)
+def test_venv_recreate_with_copied_interpreter(tmp_path: Path) -> None:
+ args: Final = [str(tmp_path / "env"), "--creator", "venv", "--no-seed"]
+ cli_run([*args, "--symlinks"])
+ session: Final = cli_run([*args, "--copies"])
+ assert (session.creator.exe.is_file(), session.creator.exe.resolve().parent) == (
+ True,
+ session.creator.bin_dir.resolve(),
+ )
+
+
+@pytest.mark.skipif(not CURRENT.has_venv, reason="requires interpreter with venv")
+@pytest.mark.parametrize("mode", [pytest.param("--copies", id="copies"), pytest.param("--symlinks", id="symlinks")])
+def test_venv_replaces_hard_linked_files(tmp_path: Path, mode: str) -> None:
+ if mode == "--symlinks" and not fs_supports_symlink():
+ pytest.skip("requires symlinks")
+ if mode == "--copies" and (is_mac_os_framework(CURRENT) or is_macos_brew(CURRENT) or (IS_GRAALPY and not IS_WIN)):
+ pytest.skip("stdlib creator requires symlinks on this runtime")
+ session: Final = session_via_cli([str(tmp_path / "env"), "--creator", "venv", "--no-seed", "--activators=", mode])
+ session.creator.bin_dir.mkdir(parents=True)
+ names: Final = (
+ ("python.exe", "pythonw.exe") if IS_WIN else ("python", "python3", f"python3.{CURRENT.version_info.minor}")
+ )
+ planted: Final = [session.creator.dest / "pyvenv.cfg", *(session.creator.bin_dir / name for name in names)]
+ for path in planted:
+ (tmp_path / f"{path.name}.original").write_bytes(b"original")
+ os.link(tmp_path / f"{path.name}.original", path)
+ session.run()
+ assert [((tmp_path / f"{path.name}.original").read_bytes(), path.lstat().st_nlink) for path in planted] == [
+ (b"original", 1)
+ ] * len(planted)
+
+
@pytest.mark.skipif(not CURRENT.has_venv, reason="requires interpreter with venv")
def test_venv_fails_not_inline(tmp_path, capsys, mocker) -> None:
if hasattr(os, "geteuid") and os.geteuid() == 0:
diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py
index ea7030b..c4c448a 100644
--- a/tests/unit/test_util.py
+++ b/tests/unit/test_util.py
@@ -9,7 +9,7 @@ import pytest
from virtualenv.app_data import _cache_dir_with_migration, _default_app_data_dir
from virtualenv.util.lock import ReentrantFileLock
-from virtualenv.util.path import copy
+from virtualenv.util.path import copy, write_file
from virtualenv.util.subprocess import run_cmd
if TYPE_CHECKING:
@@ -60,6 +60,15 @@ def test_copy_replaces_contents(tmp_path: Path, directory: bool) -> None:
assert (destination / "entry" if directory else destination).read_bytes() == b"new"
+@pytest.mark.parametrize("existing", [pytest.param(False, id="new"), pytest.param(True, id="replace")])
+def test_write_file_replaces_content(tmp_path: Path, existing: bool) -> None:
+ path: Final = tmp_path / "generated"
+ if existing:
+ path.write_bytes(b"previous")
+ write_file(path, b"generated")
+ assert path.read_bytes() == b"generated"
+
+
def test_run_fail(tmp_path) -> None:
code, out, err = run_cmd([str(tmp_path)])
assert err
--
2.35.6
@@ -26,6 +26,7 @@ SRC_URI += " \
file://CVE-2026-113715.patch \
file://CVE-2026-113720.patch \
file://CVE-2026-113719.patch \
file://CVE-2026-113721.patch \
"
BBCLASSEXTEND = "native nativesdk"