python3-virtualenv: fix CVE-2026-113776

This patch applies the upstream fix as referenced in [2], using the
commit shown in [1]. Replace activation-template placeholders in a
single pass so placeholder names inside substituted paths or prompts
cannot be expanded again as script content.

[1] https://github.com/pypa/virtualenv/commit/602b26623a724827ff171bf11fef4ad186d01f74
[2] https://github.com/pypa/virtualenv/security/advisories/GHSA-8rjx-v5ww-45pp

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 08:56:42 +05:30
1 parent ad862d2df7
commit 936c732252
2 files changed
+134

No files matched your search

@@ -0,0 +1,133 @@
From 602b26623a724827ff171bf11fef4ad186d01f74 Mon Sep 17 00:00:00 2001
From: Bernát Gábor <gaborjbernat@gmail.com>
Date: Wed, 30 Sep 2026 23:01:05 -0700
Subject: [PATCH] Merge commit from fork
instantiate_template replaced one placeholder at a time across the whole
template, so a placeholder name inside an already quoted value, such as a
parent directory named __VIRTUAL_NAME__, was rewritten from inside the
finished literal. The second value's quotes closed the first, and the rest
of the path ran as code in bash, fish, xonsh and activate_this.py; in csh
the prompt display tokens broke activate.csh the same way.
Build every token's final text first and substitute them in one regex pass,
with the csh display tokens joining that pass through substitutions().
CVE: CVE-2026-113776
Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/602b26623a724827ff171bf11fef4ad186d01f74]
Backport Changes:
- Omit the Csh display-token override and its specific test: 21.2.1 has
no separate display-token substitution or prompt-literal helpers.
Its Csh activator uses the corrected shared template substitution.
- Exercise upstream's placeholder-in-parent-directory regression through
the existing Bash relocation fixture, since 21.2.1 lacks the later
prompt-test fixture. Add direct one-pass substitution checks for the
existing Bash, Csh, Fish and Python activators, and retain the 21.2.1
special-character fixture's other characters.
Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
---
src/virtualenv/activation/via_template.py | 21 +++++++++++++++++----
tests/conftest.py | 3 ++-
tests/unit/activation/test_activator.py | 16 ++++++++++++++++
tests/unit/activation/test_bash.py | 1 +
4 files changed, 36 insertions(+), 5 deletions(-)
diff --git a/src/virtualenv/activation/via_template.py b/src/virtualenv/activation/via_template.py
index 926c2b0..3af9dc5 100644
--- a/src/virtualenv/activation/via_template.py
+++ b/src/virtualenv/activation/via_template.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
+import re
import shlex
import sys
from abc import ABC, abstractmethod
@@ -84,10 +85,22 @@ class ViaTemplateActivator(Activator, ABC):
# read content as binary to avoid platform specific line normalization (\n -> \r\n)
binary = read_binary(self.__module__, template)
text = binary.decode("utf-8", errors="strict")
- for key, value in replacements.items():
- value_uni = self._repr_unicode(creator, value)
- text = text.replace(key, self.quote(value_uni))
- return text
+ substitutions = self.substitutions(replacements, creator)
+ # one pass over the template, so a placeholder name inside an already quoted value (a path component named
+ # __VIRTUAL_NAME__) stays data instead of being rewritten by a later key from inside the finished literal
+ pattern = re.compile("|".join(re.escape(key) for key in sorted(substitutions, key=len, reverse=True)))
+ return pattern.sub(lambda match: substitutions[match.group()], text)
+
+ def substitutions(self, replacements: dict[str, str], creator: Creator) -> dict[str, str]:
+ """Map each placeholder to the final text that replaces it in the template.
+
+ :param replacements: the raw placeholder values
+ :param creator: the creator of the environment
+
+ :returns: the placeholder to substituted text mapping
+
+ """
+ return {key: self.quote(self._repr_unicode(creator, value)) for key, value in replacements.items()}
@staticmethod
def _repr_unicode(creator: Creator, value: str) -> str: # noqa: ARG004
diff --git a/tests/conftest.py b/tests/conftest.py
index 3ec4147..f27c450 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -279,7 +279,8 @@ def is_inside_ci():
@pytest.fixture(scope="session")
def special_char_name():
- base = "'\";&&e-$ èрт🚒♞中片-j"
+ # __VIRTUAL_NAME__ is a template placeholder, and must stay data when the path embeds it
+ base = "'\";&&e-$ __VIRTUAL_NAME__èрт🚒♞中片-j"
if IS_WIN:
# get rid of invalid characters on Windows
base = base.replace('"', "")
diff --git a/tests/unit/activation/test_activator.py b/tests/unit/activation/test_activator.py
index 21be96d..03481c2 100644
--- a/tests/unit/activation/test_activator.py
+++ b/tests/unit/activation/test_activator.py
@@ -4,9 +4,25 @@ from argparse import Namespace
import pytest
+from virtualenv.activation import BashActivator, CShellActivator, FishActivator, PythonActivator
from virtualenv.activation.activator import Activator
+@pytest.mark.parametrize("activator_class", [BashActivator, CShellActivator, FishActivator, PythonActivator])
+def test_template_substitution_does_not_expand_replacement_values(monkeypatch, activator_class) -> None:
+ template = b"__VIRTUAL_ENV__ __VIRTUAL_NAME__"
+ monkeypatch.setattr("virtualenv.activation.via_template.read_binary", lambda *_: template)
+ activator = activator_class(Namespace(prompt=None))
+ replacements = {
+ "__VIRTUAL_ENV__": "A__VIRTUAL_NAME__B/x'$(touch PWNED)'y",
+ "__VIRTUAL_NAME__": "x'$(touch PWNED)'y",
+ }
+
+ actual = activator.instantiate_template(replacements, "unused", None)
+
+ assert actual == " ".join(activator.quote(value) for value in replacements.values())
+
+
@pytest.mark.graalpy
def test_activator_prompt_cwd(monkeypatch, tmp_path) -> None:
class FakeActivator(Activator):
diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py
index 00ac55f..df3aaf3 100644
--- a/tests/unit/activation/test_bash.py
+++ b/tests/unit/activation/test_bash.py
@@ -130,6 +130,7 @@ def test_bash_activate_relocation_resolves_virtual_env(
pytest.param("x'$(id > PWNED)'y", id="command-substitution"),
pytest.param("x'`id > PWNED`'y", id="backticks"),
pytest.param("a';id > PWNED;'b", id="statement-separator"),
+ pytest.param("A__VIRTUAL_NAME__B/x'$(touch PWNED)'y", id="placeholder-in-parent-dir"),
],
)
def test_bash_activate_relocation_does_not_run_path_commands(
--
2.35.6
@@ -17,6 +17,7 @@ SRC_URI += " \
file://CVE-2026-102925.patch \
file://CVE-2026-113732_p1.patch \
file://CVE-2026-113732_p2.patch \
file://CVE-2026-113776.patch \
"
BBCLASSEXTEND = "native nativesdk"