python3-virtualenv: fix CVE-2026-113719

This patch applies the upstream fix as referenced in [2], using the
commit shown in [1]. Restrict seed-package metadata cleanup to entries
inside the environment, without deleting the environment itself or
directories containing the metadata. Unlink final symlinks without
following their targets.

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

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:04:50 +05:30
1 parent f11354004d
commit 90f0e58d3d
2 files changed
+208

No files matched your search

@@ -0,0 +1,207 @@
From e3cea6fa10a50fa29cf1fdddd772458fba076d5f Mon Sep 17 00:00:00 2001
From: Bernát Gábor <gaborjbernat@gmail.com>
Date: Sat, 17 Oct 2026 03:19:58 -0700
Subject: [PATCH] fix(seed): contain metadata cleanup (#3428)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CVE: CVE-2026-113719
Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/e3cea6fa10a50fa29cf1fdddd772458fba076d5f]
Backport Changes:
- Add the Final import required by the unchanged upstream regression
tests; the older test module does not already import it.
- Omit the upstream release changelog fragment, which is not shipped
in the 21.2.1 source archive.
Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
---
.../embed/via_app_data/pip_install/base.py | 43 +++++----
.../embed/test_bootstrap_link_via_app_data.py | 88 ++++++++++++++++++-
2 files changed, 115 insertions(+), 16 deletions(-)
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 89348aa..7697e73 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
@@ -9,7 +9,7 @@ from configparser import ConfigParser
from itertools import chain
from pathlib import Path
from tempfile import mkdtemp
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Final
from distlib.scripts import ScriptMaker, enquote_executable
@@ -154,35 +154,48 @@ class PipInstall(ABC):
paths = (p for p in in_folders if p.stem.split("-")[0] == dist_name and p.suffix == ".dist-info" and p.is_dir())
existing_dist = next(paths, None)
if existing_dist is not None:
- self._uninstall_dist(existing_dist)
+ self._uninstall_dist(existing_dist, self._creator.dest)
@staticmethod
- def _uninstall_dist(dist: Path) -> None:
- dist_base = dist.parent
+ def _uninstall_dist(dist: Path, env_dir: Path) -> None:
+ dist_base: Final = dist.parent
LOGGER.debug("uninstall existing distribution %s from %s", dist.stem, dist_base)
- top_txt = dist / "top_level.txt" # add top level packages at folder level
- paths = (
+ top_txt: Final = dist / "top_level.txt"
+ paths: Final = (
{dist.parent / i.strip() for i in top_txt.read_text(encoding="utf-8").splitlines()}
if top_txt.exists()
else set()
)
- paths.add(dist) # add the dist-info folder itself
+ paths.add(dist)
- base_dirs, record = paths.copy(), dist / "RECORD" # collect entries in record that we did not register yet
+ base_dirs: Final = paths.copy()
+ record: Final = dist / "RECORD"
for name in (
(i.split(",")[0] for i in record.read_text(encoding="utf-8").splitlines()) if record.exists() else ()
):
- path = dist_base / name
- if not any(p in base_dirs for p in path.parents): # only add if not already added as a base dir
+ path: Final = dist_base / name
+ if not any(p in base_dirs for p in path.parents):
paths.add(path)
- for path in sorted(paths): # actually remove stuff in a stable order
- if path.exists():
- if path.is_dir() and not path.is_symlink():
- safe_delete(path)
+ root: Final = env_dir.resolve()
+ metadata_parents: Final = set(dist.resolve().parents)
+ for entry in sorted(paths):
+ if entry.exists() or entry.is_symlink():
+ linked: Final = entry.is_symlink()
+ # Unlinking the final link leaves its target intact; parent links still affect containment.
+ resolved: Final = entry.parent.resolve() / entry.name if linked else entry.resolve()
+ if root not in resolved.parents or resolved in metadata_parents:
+ LOGGER.warning(
+ "skip removal of %s listed by %s: not inside environment %s or a parent of the metadata",
+ entry,
+ dist.name,
+ root,
+ )
+ elif entry.is_dir() and not linked:
+ safe_delete(entry)
else:
- path.unlink()
+ entry.unlink()
def clear(self) -> None:
if self._image_dir.exists():
diff --git a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py
index 502f32a..278a48e 100644
--- a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py
+++ b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py
@@ -6,7 +6,7 @@ import sys
from stat import S_IWGRP, S_IWOTH, S_IWUSR
from subprocess import Popen, check_call
from threading import Thread
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Final
import pytest
from python_discovery import PythonInfo
@@ -23,6 +23,92 @@ if TYPE_CHECKING:
from pytest_mock import MockerFixture
+@pytest.mark.parametrize(
+ ("metadata_file", "content"),
+ [
+ pytest.param("top_level.txt", "pip\n{dest}\n", id="top-level-env-root"),
+ pytest.param("top_level.txt", "pip\n\n", id="top-level-blank"),
+ pytest.param("RECORD", "{record}\n{outside},,", id="record-traversal"),
+ ],
+)
+def test_seed_metadata_cleanup_stays_inside_package(
+ tmp_path: Path, caplog: pytest.LogCaptureFixture, metadata_file: str, content: str
+) -> None:
+ args: Final = [str(tmp_path / "env"), "--pip", "embed", "--no-periodic-update"]
+ session: Final = cli_run(args, setup_logging=False)
+ purelib: Final = session.creator.purelib
+ markers: Final = [tmp_path / "keep.txt", session.creator.dest / "keep.txt", purelib / "keep.txt"]
+ for marker in markers:
+ marker.write_text("keep", encoding="utf-8")
+ dist_info: Final = next(purelib.glob("pip-*.dist-info"))
+ (dist_info / metadata_file).write_text(
+ content.format(
+ dest=session.creator.dest,
+ outside=os.path.relpath(markers[0], purelib),
+ record=(dist_info / "RECORD").read_text(encoding="utf-8"),
+ ),
+ encoding="utf-8",
+ )
+ cli_run(args, setup_logging=False)
+ assert (
+ [i.exists() for i in markers],
+ (purelib / "pip" / "__init__.py").is_file(),
+ "skip removal of" in caplog.text,
+ ) == (
+ [True, True, True],
+ True,
+ True,
+ )
+
+
+@pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable")
+def test_seed_metadata_unlinks_symlink_without_following(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
+ args: Final = [str(tmp_path / "env"), "--pip", "embed", "--no-periodic-update"]
+ session: Final = cli_run(args, setup_logging=False)
+ outside: Final = tmp_path / "outside"
+ outside.mkdir()
+ (outside / "keep.txt").write_text("keep", encoding="utf-8")
+ link: Final = session.creator.purelib / "linked"
+ link.symlink_to(outside, target_is_directory=True)
+ record: Final = next(session.creator.purelib.glob("pip-*.dist-info")) / "RECORD"
+ record.write_text(f"{record.read_text(encoding='utf-8')}\nlinked,,", encoding="utf-8")
+ cli_run(args, setup_logging=False)
+ assert (link.is_symlink(), (outside / "keep.txt").exists(), "skip removal of" in caplog.text) == (
+ False,
+ True,
+ False,
+ )
+
+
+@pytest.mark.parametrize(
+ "linked",
+ [
+ pytest.param(False, id="copy"),
+ pytest.param(
+ True, id="symlink", marks=pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable")
+ ),
+ ],
+)
+def test_seed_metadata_keeps_reseeding_supported(
+ tmp_path: Path, linked: bool, caplog: pytest.LogCaptureFixture
+) -> None:
+ args: Final = [
+ str(tmp_path / "env"),
+ "--pip",
+ "embed",
+ "--no-periodic-update",
+ *(["--symlink-app-data"] if linked else []),
+ ]
+ cli_run(args, setup_logging=False)
+ session: Final = cli_run(args, setup_logging=False)
+ package: Final = session.creator.purelib / "pip"
+ assert ((package / "__init__.py").is_file(), package.is_symlink(), "skip removal of" in caplog.text) == (
+ True,
+ linked,
+ False,
+ )
+
+
@pytest.mark.slow
@pytest.mark.parametrize("copies", [False, True] if fs_supports_symlink() else [True])
def test_seed_link_via_app_data(tmp_path, coverage_env, current_fastest, copies, for_py_version) -> None: # noqa: PLR0915
--
2.35.6
@@ -25,6 +25,7 @@ SRC_URI += " \
file://CVE-2026-115586.patch \
file://CVE-2026-113715.patch \
file://CVE-2026-113720.patch \
file://CVE-2026-113719.patch \
"
BBCLASSEXTEND = "native nativesdk"