python3-virtualenv: fix CVE-2026-113716

This patch applies the upstream fix as referenced in [2], using the
commit shown in [1]. Replace linked output-directory components before
creating interpreter, library and activation files. Reuse ensure_within
from the preceding CVE-2026-113721 backport [3].

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

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:08:23 +05:30
1 parent 15567524b8
commit d54f2315dd
2 files changed
+209

No files matched your search

@@ -0,0 +1,208 @@
From 554d9f9d4f7758f5cf53f993031e29787f1f74c5 Mon Sep 17 00:00:00 2001
From: Bernát Gábor <gaborjbernat@gmail.com>
Date: Sat, 17 Oct 2026 03:36:11 -0700
Subject: [PATCH] fix(create): replace linked output directories (#3429)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CVE: CVE-2026-113716
Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/554d9f9d4f7758f5cf53f993031e29787f1f74c5]
Backport Changes:
- Add the fs_supports_symlink import required by the upstream utility
tests; it is absent from the 21.2.1 test module.
- Extend the older test_util path import with ensure_within; the later
upstream module's safe_delete and symlink imports are unused here.
- Omit the release-only changelog fragment.
Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
---
.../builtin/via_global_self_do.py | 4 +-
src/virtualenv/create/via_global_ref/venv.py | 7 ++-
tests/unit/create/test_creator.py | 36 +++++++++++++
tests/unit/test_util.py | 54 ++++++++++++++++++-
4 files changed, 94 insertions(+), 7 deletions(-)
diff --git a/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py b/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py
index f86861e..9ada4c1 100644
--- a/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py
+++ b/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py
@@ -9,7 +9,7 @@ from virtualenv.create.via_global_ref.builtin.ref import (
RefMust,
RefWhen,
)
-from virtualenv.util.path import ensure_dir
+from virtualenv.util.path import ensure_within
from .builtin_way import VirtualenvBuiltin
@@ -92,7 +92,7 @@ class ViaGlobalRefVirtualenvBuiltin(ViaGlobalRefApi, VirtualenvBuiltin, ABC):
if any(i for i in dirs if i is not directory and directory.parts == i.parts[: len(directory.parts)]):
dirs.remove(directory)
for directory in sorted(dirs):
- ensure_dir(directory)
+ ensure_within(self.dest, directory)
self.set_pyenv_cfg()
self.pyenv_cfg.write()
diff --git a/src/virtualenv/create/via_global_ref/venv.py b/src/virtualenv/create/via_global_ref/venv.py
index 8b704eb..4e4fc77 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, ensure_within
+from virtualenv.util.path import ensure_within
from virtualenv.util.subprocess import run_cmd
from .api import ViaGlobalRefApi, ViaGlobalRefMeta
@@ -52,7 +52,8 @@ class Venv(ViaGlobalRefApi):
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 directory in (self.bin_dir, self.purelib, self.platlib):
+ ensure_within(self.dest, directory)
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()
@@ -60,8 +61,6 @@ class Venv(ViaGlobalRefApi):
self.create_inline()
else:
self.create_via_sub_process()
- for lib in self.libs: # ty: ignore[not-iterable]
- ensure_dir(lib)
if self.describe is not None:
self.describe.install_venv_shared_libs(self)
super().create()
diff --git a/tests/unit/create/test_creator.py b/tests/unit/create/test_creator.py
index bc02679..733a073 100644
--- a/tests/unit/create/test_creator.py
+++ b/tests/unit/create/test_creator.py
@@ -220,6 +220,42 @@ def test_venv_creator_accepts_plain_destination(tmp_path: Path) -> None:
assert (session.creator.dest / "pyvenv.cfg").is_file()
+@pytest.mark.parametrize(
+ "junction",
+ [
+ pytest.param(
+ False, id="symlink", marks=pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable")
+ ),
+ pytest.param(True, id="junction", marks=pytest.mark.skipif(os.name != "nt", reason="Windows junctions")),
+ ],
+)
+@pytest.mark.parametrize(
+ "creator_name",
+ [
+ pytest.param("builtin", id="builtin"),
+ pytest.param(
+ "venv",
+ id="venv",
+ marks=pytest.mark.skipif("venv" not in CURRENT_CREATORS, reason="stdlib venv is unavailable"),
+ ),
+ ],
+)
+@pytest.mark.parametrize("folder", [pytest.param("bin_dir", id="executables"), pytest.param("purelib", id="packages")])
+def test_creator_replaces_directory_links(tmp_path: Path, creator_name: str, folder: str, junction: bool) -> None:
+ session: Final = session_via_cli([str(tmp_path / "env"), "--creator", creator_name, "--no-seed"])
+ session.creator.dest.mkdir()
+ stored: Final = session.creator.dest / "stored"
+ stored.mkdir()
+ link: Final = session.creator.dest / getattr(session.creator, folder).relative_to(session.creator.dest).parts[0]
+ if junction:
+ subprocess.run(["cmd", "/c", "mklink", "/J", str(link), str(stored)], capture_output=True, check=True)
+ else:
+ link.symlink_to(stored, target_is_directory=True)
+ with session:
+ session.run()
+ assert (link.resolve(), session.creator.exe.is_file(), list(stored.iterdir())) == (link, True, [])
+
+
CREATE_METHODS = []
for k, v in CreatorSelector.for_interpreter(CURRENT).key_to_meta.items():
if k in CURRENT_CREATORS:
diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py
index c4c448a..099798a 100644
--- a/tests/unit/test_util.py
+++ b/tests/unit/test_util.py
@@ -2,14 +2,16 @@ from __future__ import annotations
import concurrent.futures
import os
+import subprocess
import traceback
from typing import TYPE_CHECKING, Final
import pytest
from virtualenv.app_data import _cache_dir_with_migration, _default_app_data_dir
+from virtualenv.info import fs_supports_symlink
from virtualenv.util.lock import ReentrantFileLock
-from virtualenv.util.path import copy, write_file
+from virtualenv.util.path import copy, ensure_within, write_file
from virtualenv.util.subprocess import run_cmd
if TYPE_CHECKING:
@@ -69,6 +71,56 @@ def test_write_file_replaces_content(tmp_path: Path, existing: bool) -> None:
assert path.read_bytes() == b"generated"
+@pytest.mark.parametrize("existing", [pytest.param(False, id="new"), pytest.param(True, id="existing")])
+def test_environment_directories_create_nested_path(tmp_path: Path, existing: bool) -> None:
+ folder: Final = tmp_path / "env" / "lib" / "python"
+ if existing:
+ folder.mkdir(parents=True)
+ ensure_within(tmp_path / "env", folder)
+ assert folder.is_dir()
+
+
+def test_environment_directories_reject_regular_file(tmp_path: Path) -> None:
+ folder: Final = tmp_path / "file"
+ folder.touch()
+ with pytest.raises(NotADirectoryError, match="not a real directory"):
+ ensure_within(tmp_path, folder)
+
+
+def test_environment_directories_reject_parent_components(tmp_path: Path) -> None:
+ with pytest.raises(ValueError, match="must remain inside"):
+ ensure_within(tmp_path, tmp_path / ".." / "folder")
+
+
+@pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable")
+@pytest.mark.parametrize(
+ "existing", [pytest.param(False, id="missing-target"), pytest.param(True, id="existing-target")]
+)
+def test_environment_directories_remove_links(tmp_path: Path, caplog: pytest.LogCaptureFixture, existing: bool) -> None:
+ target: Final = tmp_path / "stored"
+ if existing:
+ target.mkdir()
+ link: Final = tmp_path / "link"
+ link.symlink_to(target, target_is_directory=True)
+ ensure_within(tmp_path, link / "nested")
+ assert (
+ link.is_symlink(),
+ link.exists(),
+ target.exists(),
+ f"replace environment directory link {link}" in caplog.text,
+ ) == (False, True, existing, True)
+
+
+@pytest.mark.skipif(os.name != "nt", reason="Windows junctions")
+def test_environment_directories_report_replaced_junction(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
+ target: Final = tmp_path / "stored"
+ target.mkdir()
+ link: Final = tmp_path / "link"
+ subprocess.run(["cmd", "/c", "mklink", "/J", str(link), str(target)], check=True, capture_output=True)
+ ensure_within(tmp_path, link)
+ assert (link.resolve(), f"replace environment directory junction {link}" in caplog.text) == (link, True)
+
+
def test_run_fail(tmp_path) -> None:
code, out, err = run_cmd([str(tmp_path)])
assert err
--
2.35.6
@@ -27,6 +27,7 @@ SRC_URI += " \
file://CVE-2026-113720.patch \
file://CVE-2026-113719.patch \
file://CVE-2026-113721.patch \
file://CVE-2026-113716.patch \
"
BBCLASSEXTEND = "native nativesdk"