python3-virtualenv: fix CVE-2026-102925

This patch applies the upstream fix as referenced in [2], using the
commit shown in [1]. Remove the extra template quotes around values
that are already quoted for Bash and Fish activation scripts.

[1] https://github.com/pypa/virtualenv/commit/4d5a105ec2a2723b8c7f4571bb68f4f71d01e3f6
[2] https://github.com/pypa/virtualenv/security/advisories/GHSA-p58f-9548-mpm2

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:53:52 +05:30
1 parent 2f89f21927
commit abb85935fe
2 files changed
+297

No files matched your search

@@ -0,0 +1,296 @@
From 4d5a105ec2a2723b8c7f4571bb68f4f71d01e3f6 Mon Sep 17 00:00:00 2001
From: Bernát Gábor <gaborjbernat@gmail.com>
Date: Fri, 18 Sep 2026 06:10:09 +0200
Subject: [PATCH] 🐛 fix(activation): stop path command injection in bash and
fish (#3252)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CVE: CVE-2026-102925
Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/4d5a105ec2a2723b8c7f4571bb68f4f71d01e3f6]
Backport Changes:
- Add the shutil and subprocess imports and FISH executable lookup
required by the upstream Fish regression tests; these are absent from
the 21.2.1 test module.
- Omit the release-only changelog fragment; the release documentation
directory is not included in the 21.2.1 source distribution.
Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
---
src/virtualenv/activation/bash/activate.sh | 2 +-
src/virtualenv/activation/fish/activate.fish | 4 +-
tests/unit/activation/test_bash.py | 85 ++++++++++++++------
tests/unit/activation/test_fish.py | 82 ++++++++++++-------
4 files changed, 117 insertions(+), 56 deletions(-)
diff --git a/src/virtualenv/activation/bash/activate.sh b/src/virtualenv/activation/bash/activate.sh
index b418101..18a78e5 100644
--- a/src/virtualenv/activation/bash/activate.sh
+++ b/src/virtualenv/activation/bash/activate.sh
@@ -61,7 +61,7 @@ deactivate () {
deactivate nondestructive
if [ ! -d __VIRTUAL_ENV__ ]; then
- echo "Virtual environment directory __VIRTUAL_ENV__ does not exist!" >&2
+ echo "Virtual environment directory" __VIRTUAL_ENV__ "does not exist!" >&2
CURRENT_PATH=$(realpath "${BASH_SOURCE[0]}")
CURRENT_DIR=$(dirname "${CURRENT_PATH}")
VIRTUAL_ENV="$(realpath "${CURRENT_DIR}/../")"
diff --git a/src/virtualenv/activation/fish/activate.fish b/src/virtualenv/activation/fish/activate.fish
index 33a8c7f..e409de9 100644
--- a/src/virtualenv/activation/fish/activate.fish
+++ b/src/virtualenv/activation/fish/activate.fish
@@ -72,13 +72,13 @@ if test -n __TCL_LIBRARY__
if set -q TCL_LIBRARY;
set -gx _OLD_VIRTUAL_TCL_LIBRARY $TCL_LIBRARY;
end
- set -gx TCL_LIBRARY '__TCL_LIBRARY__'
+ set -gx TCL_LIBRARY __TCL_LIBRARY__
end
if test -n __TK_LIBRARY__
if set -q TK_LIBRARY;
set -gx _OLD_VIRTUAL_TK_LIBRARY $TK_LIBRARY;
end
- set -gx TK_LIBRARY '__TK_LIBRARY__'
+ set -gx TK_LIBRARY __TK_LIBRARY__
end
# Prompt override provided?
diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py
index 7a1bd35..00ac55f 100644
--- a/tests/unit/activation/test_bash.py
+++ b/tests/unit/activation/test_bash.py
@@ -3,6 +3,7 @@ from __future__ import annotations
import shutil
import subprocess
from argparse import Namespace
+from typing import TYPE_CHECKING
import pytest
@@ -10,6 +11,10 @@ from virtualenv.activation import BashActivator
from virtualenv.info import IS_WIN
from virtualenv.run import cli_run
+if TYPE_CHECKING:
+ from collections.abc import Callable
+ from pathlib import Path
+
@pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash")
@pytest.mark.parametrize(
@@ -73,34 +78,68 @@ def test_bash_tkinter_generation(tmp_path, tcl_lib, tk_lib, present) -> None:
assert "export TCL_LIBRARY" in content
+@pytest.fixture
+def relocated_bash_venv(
+ tmp_path: Path, current_fastest: str
+) -> Callable[[str], tuple[subprocess.CompletedProcess[str], Path, Path]]:
+ def source_after_move(name: str) -> tuple[subprocess.CompletedProcess[str], Path, Path]:
+ original = tmp_path / name
+ cli_run([
+ "--without-pip",
+ str(original),
+ "--creator",
+ current_fastest,
+ "--no-periodic-update",
+ "--activators",
+ "bash",
+ ])
+ relocated = tmp_path / "relocated"
+ shutil.move(original, relocated)
+ work_dir = tmp_path / "workdir"
+ work_dir.mkdir()
+ result = subprocess.run(
+ ["bash", "-c", f'source "{relocated / "bin" / "activate"}" 2>/dev/null && echo "$VIRTUAL_ENV"'],
+ capture_output=True,
+ text=True,
+ cwd=str(work_dir),
+ encoding="utf-8",
+ check=False,
+ )
+ return result, relocated, work_dir
+
+ return source_after_move
+
+
@pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash")
-def test_bash_activate_relocation_resolves_virtual_env(tmp_path, current_fastest) -> None:
- original = tmp_path / "original"
- cli_run([
- "--without-pip",
- str(original),
- "--creator",
- current_fastest,
- "--no-periodic-update",
- "--activators",
- "bash",
- ])
- relocated = tmp_path / "relocated"
- shutil.move(original, relocated)
-
- work_dir = tmp_path / "workdir"
- work_dir.mkdir()
- activate_script = relocated / "bin" / "activate"
- result = subprocess.run(
- ["bash", "-c", f'source "{activate_script}" 2>/dev/null && echo "$VIRTUAL_ENV"'],
- capture_output=True,
- text=True,
- cwd=str(work_dir),
- )
+@pytest.mark.parametrize(
+ "name", [pytest.param("original", id="plain"), pytest.param("has(paren)and'quote", id="shell-metacharacters")]
+)
+def test_bash_activate_relocation_resolves_virtual_env(
+ relocated_bash_venv: Callable[[str], tuple[subprocess.CompletedProcess[str], Path, Path]], name: str
+) -> None:
+ result, relocated, _ = relocated_bash_venv(name)
+
assert result.returncode == 0
assert result.stdout.strip() == str(relocated)
+@pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash")
+@pytest.mark.parametrize(
+ "payload",
+ [
+ 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"),
+ ],
+)
+def test_bash_activate_relocation_does_not_run_path_commands(
+ relocated_bash_venv: Callable[[str], tuple[subprocess.CompletedProcess[str], Path, Path]], payload: str
+) -> None:
+ _, _, work_dir = relocated_bash_venv(payload)
+
+ assert not (work_dir / "PWNED").exists()
+
+
@pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash")
@pytest.mark.parametrize("hashing_enabled", [True, False])
def test_bash(raise_on_non_source_class, hashing_enabled, activation_tester) -> None:
diff --git a/tests/unit/activation/test_fish.py b/tests/unit/activation/test_fish.py
index 4d28b78..f726e7e 100644
--- a/tests/unit/activation/test_fish.py
+++ b/tests/unit/activation/test_fish.py
@@ -1,62 +1,84 @@
from __future__ import annotations
import os
+import shlex
+import shutil
+import subprocess
import sys
from argparse import Namespace
+from types import SimpleNamespace
+from typing import TYPE_CHECKING
import pytest
from virtualenv.activation import FishActivator
from virtualenv.info import IS_WIN
+if TYPE_CHECKING:
+ from collections.abc import Callable
+ from pathlib import Path
+
+FISH = shutil.which("fish")
+
+
+@pytest.fixture
+def rendered_activate_fish(tmp_path: Path) -> Callable[[str | None, str | None], Path]:
+ def render(tcl_lib: str | None, tk_lib: str | None) -> Path:
+ creator = SimpleNamespace(
+ dest=tmp_path,
+ bin_dir=tmp_path / "bin",
+ interpreter=SimpleNamespace(tcl_lib=tcl_lib, tk_lib=tk_lib),
+ pyenv_cfg={},
+ env_name="my-env",
+ )
+ creator.bin_dir.mkdir()
+ FishActivator(Namespace(prompt=None)).generate(creator)
+ return creator.bin_dir / "activate.fish"
+
+ return render
+
@pytest.mark.parametrize(
("tcl_lib", "tk_lib", "present"),
[
("/path/to/tcl", "/path/to/tk", True),
+ ("/Program Files/tcl", "/Program Files/tk", True),
(None, None, False),
],
)
-def test_fish_tkinter_generation(tmp_path, tcl_lib, tk_lib, present) -> None:
- # GIVEN
- class MockInterpreter:
- pass
-
- interpreter = MockInterpreter()
- interpreter.tcl_lib = tcl_lib
- interpreter.tk_lib = tk_lib
-
- class MockCreator:
- def __init__(self, dest) -> None:
- self.dest = dest
- self.bin_dir = dest / "bin"
- self.bin_dir.mkdir()
- self.interpreter = interpreter
- self.pyenv_cfg = {}
- self.env_name = "my-env"
-
- creator = MockCreator(tmp_path)
- options = Namespace(prompt=None)
- activator = FishActivator(options)
-
- # WHEN
- activator.generate(creator)
- content = (creator.bin_dir / "activate.fish").read_text(encoding="utf-8")
-
- # THEN
- # PKG_CONFIG_PATH is always set
+def test_fish_tkinter_generation(
+ rendered_activate_fish: Callable[[str | None, str | None], Path],
+ tcl_lib: str | None,
+ tk_lib: str | None,
+ present: bool,
+) -> None:
+ content = rendered_activate_fish(tcl_lib, tk_lib).read_text(encoding="utf-8")
+
assert 'set -gx _OLD_PKG_CONFIG_PATH "$PKG_CONFIG_PATH"' in content
assert 'set -gx PKG_CONFIG_PATH "$VIRTUAL_ENV/lib/pkgconfig:$PKG_CONFIG_PATH"' in content
assert "set -e _OLD_PKG_CONFIG_PATH" in content
if present:
- assert "set -gx TCL_LIBRARY '/path/to/tcl'" in content
- assert "set -gx TK_LIBRARY '/path/to/tk'" in content
+ assert f"set -gx TCL_LIBRARY {shlex.quote(tcl_lib)}\n" in content
+ assert f"set -gx TK_LIBRARY {shlex.quote(tk_lib)}\n" in content
else:
assert "if test -n ''\n if set -q TCL_LIBRARY;" in content
assert "if test -n ''\n if set -q TK_LIBRARY;" in content
+@pytest.mark.skipif(IS_WIN, reason="fish is not available on Windows")
+@pytest.mark.skipif(FISH is None, reason="fish is not installed")
+def test_fish_tkinter_path_does_not_run_commands(
+ rendered_activate_fish: Callable[[str | None, str | None], Path], tmp_path: Path
+) -> None:
+ marker = tmp_path / "PWNED"
+ script = rendered_activate_fish(f"/tcl/(touch {marker})/lib", "/tk/lib")
+
+ subprocess.run([FISH, "-c", f"source '{script}'"], capture_output=True, text=True, timeout=60, check=False)
+
+ assert not marker.exists()
+
+
@pytest.mark.skipif(IS_WIN, reason="we have not setup fish in CI yet")
def test_fish(activation_tester_class, activation_tester, monkeypatch, tmp_path) -> None:
monkeypatch.setenv("HOME", str(tmp_path))
--
2.35.6
@@ -14,6 +14,7 @@ SRC_URI += " \
file://CVE-2026-102930_p1.patch \
file://CVE-2026-102930_p2.patch \
file://CVE-2026-102930_p3.patch \
file://CVE-2026-102925.patch \
"
BBCLASSEXTEND = "native nativesdk"