python3-virtualenv: fix CVE-2026-113726

Backport the upstream Zsh prompt fix [1] identified by advisory [3].
Include prerequisite [2] to provide the prompt-escaping block and
regression-test fixture that the fix uses but 21.2.1 does not contain.

Escape prompt values before Zsh expands PS1, including when
PROMPT_SUBST is enabled after activation, and preserve literal percent
signs in the displayed environment name.

[1] https://github.com/pypa/virtualenv/commit/5b915367da82bd024b2a0d28d2f98007034b4aa2
[2] https://github.com/pypa/virtualenv/commit/a70c1dac43e2ee7e618132b572072395af5d8a9b
[3] https://github.com/pypa/virtualenv/security/advisories/GHSA-5vjq-rrrf-7h2q

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:57:54 +05:30
1 parent 936c732252
commit 46b8c6fa8f
3 files changed
+319

No files matched your search

@@ -0,0 +1,168 @@
From a70c1dac43e2ee7e618132b572072395af5d8a9b Mon Sep 17 00:00:00 2001
From: Bernát Gábor <gaborjbernat@gmail.com>
Date: Sat, 26 Sep 2026 09:35:09 -0700
Subject: [PATCH] 🐛 fix(activation): stop bash re-expanding the prompt in PS1
(#3326)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CVE: CVE-2026-113726
Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/a70c1dac43e2ee7e618132b572072395af5d8a9b]
Backport Changes:
- Update the 21.2.1 activation tester's print_os_env_var("PS1") call
to use upstream's rendered-prompt check instead of its later raw-printf
implementation.
- 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/bash/activate.sh | 11 ++-
tests/unit/activation/test_bash.py | 102 ++++++++++++++++++++-
2 files changed, 111 insertions(+), 2 deletions(-)
diff --git a/src/virtualenv/activation/bash/activate.sh b/src/virtualenv/activation/bash/activate.sh
index 18a78e5..76b9c39 100644
--- a/src/virtualenv/activation/bash/activate.sh
+++ b/src/virtualenv/activation/bash/activate.sh
@@ -115,7 +115,16 @@ fi
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT-}" ] ; then
_OLD_VIRTUAL_PS1="${PS1-}"
- PS1="(${VIRTUAL_ENV_PROMPT}) ${PS1-}"
+ _VIRTUAL_PROMPT="${VIRTUAL_ENV_PROMPT}"
+ # bash re-expands backslashes, dollar signs and backticks in PS1 on every redraw; the guard keeps the
+ # bash-only substitution away from POSIX shells such as dash, which reject it with "Bad substitution"
+ if [ -n "${BASH_VERSION-}" ]; then
+ _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\\/\\\\}"
+ _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\$/\\\$}"
+ _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\`/\\\`}"
+ fi
+ PS1="(${_VIRTUAL_PROMPT}) ${PS1-}"
+ unset _VIRTUAL_PROMPT
export PS1
fi
diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py
index df3aaf3..e3608f1 100644
--- a/tests/unit/activation/test_bash.py
+++ b/tests/unit/activation/test_bash.py
@@ -141,6 +141,101 @@ def test_bash_activate_relocation_does_not_run_path_commands(
assert not (work_dir / "PWNED").exists()
+@pytest.fixture
+def bash_prompt_after_activate(tmp_path: Path, current_fastest: str) -> Callable[[str | None, str], tuple[Path, str]]:
+ version = subprocess.run(
+ ["bash", "-c", 'printf "%s" "$((BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1]))"'],
+ capture_output=True,
+ text=True,
+ check=True,
+ ).stdout
+ if int(version) < 404:
+ pytest.skip("${PS1@P} needs bash 4.4 or later")
+
+ def build(prompt: str | None, env_name: str) -> tuple[Path, str]:
+ dest = tmp_path / env_name
+ args = [
+ "--without-pip",
+ str(dest),
+ "--creator",
+ current_fastest,
+ "--no-periodic-update",
+ "--activators",
+ "bash",
+ ]
+ if prompt is not None:
+ args += ["--prompt", prompt]
+ cli_run(args)
+ activate = dest / "bin" / "activate"
+ work_dir = tmp_path / "workdir"
+ work_dir.mkdir(exist_ok=True)
+ # the path is passed as $1 so the outer shell cannot expand a payload dir name while locating the script;
+ # ${PS1@P} then forces bash to render the prompt exactly as it does before each interactive command
+ result = subprocess.run(
+ ["bash", "--norc", "--noprofile", "-c", 'source "$1"; printf "%s" "${PS1@P}"', "bash", str(activate)],
+ capture_output=True,
+ text=True,
+ cwd=str(work_dir),
+ encoding="utf-8",
+ check=False,
+ )
+ return work_dir, result.stdout
+
+ return build
+
+
+@pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash")
+@pytest.mark.parametrize(
+ ("prompt", "env_name"),
+ [
+ pytest.param("x$(touch PWNED)y", "env", id="prompt-command-substitution"),
+ pytest.param("x`touch PWNED`y", "env", id="prompt-backticks"),
+ pytest.param(None, "x$(touch PWNED)y", id="dirname-command-substitution"),
+ ],
+)
+def test_bash_prompt_does_not_run_commands(
+ bash_prompt_after_activate: Callable[[str | None, str], tuple[Path, str]], prompt: str | None, env_name: str
+) -> None:
+ work_dir, _ = bash_prompt_after_activate(prompt, env_name)
+
+ assert not (work_dir / "PWNED").exists()
+
+
+@pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash")
+def test_bash_prompt_keeps_plain_name_visible(
+ bash_prompt_after_activate: Callable[[str | None, str], tuple[Path, str]],
+) -> None:
+ _, rendered = bash_prompt_after_activate("myenv", "env")
+
+ assert rendered.startswith("(myenv) ")
+
+
+@pytest.mark.skipif(IS_WIN or shutil.which("dash") is None, reason="needs dash as a POSIX shell")
+def test_bash_activate_sources_under_dash(tmp_path: Path, current_fastest: str) -> None:
+ dest = tmp_path / "env"
+ cli_run([
+ "--without-pip",
+ str(dest),
+ "--creator",
+ current_fastest,
+ "--no-periodic-update",
+ "--activators",
+ "bash",
+ "--prompt",
+ "x$y",
+ ])
+
+ result = subprocess.run(
+ ["dash", "-c", '. "$1" && printf "%s" "$VIRTUAL_ENV"', "dash", str(dest / "bin" / "activate")],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ check=False,
+ )
+
+ assert (result.returncode, result.stdout) == (0, str(dest))
+
+
@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:
@@ -161,6 +256,11 @@ def test_bash(raise_on_non_source_class, hashing_enabled, activation_tester) ->
return super().activate_call(script) + " || exit 1"
def print_prompt(self):
- return self.print_os_env_var("PS1")
+ # render PS1 the way bash draws it, since the activator escapes the prompt for that expansion; bash before
+ # 4.4 lacks ${PS1@P}, so undo the escapes the way its prompt expansion would
+ return (
+ 'if ((BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1] >= 404)); then printf "%s\\n" "${PS1@P}"; '
+ "else printf '%s\\n' \"$PS1\" | sed 's/\\\\\\([$`\\\\]\\)/\\1/g'; fi"
+ )
activation_tester(Bash)
--
2.35.6
@@ -0,0 +1,149 @@
From 5b915367da82bd024b2a0d28d2f98007034b4aa2 Mon Sep 17 00:00:00 2001
From: Bernát Gábor <gaborjbernat@gmail.com>
Date: Fri, 2 Oct 2026 06:40:23 -0700
Subject: [PATCH] Merge commit from fork
The bash activate script, which zsh also sources, escaped backslashes,
dollar signs and backticks in the environment name only under bash.
zsh with PROMPT_SUBST re-expands PS1 at every prompt draw, so a name
such as x$(touch PWNED)y ran as a command each time the prompt showed.
Apply the same escaping under zsh, also when PROMPT_SUBST is off since
it can be set after activation, and double % so zsh shows the name
instead of reading it as prompt escapes. The prompt tests now render
PS1 through zsh as well as bash.
CVE: CVE-2026-113726
Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/5b915367da82bd024b2a0d28d2f98007034b4aa2]
Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
---
src/virtualenv/activation/bash/activate.sh | 11 +++--
tests/unit/activation/test_bash.py | 57 ++++++++++++++--------
2 files changed, 45 insertions(+), 23 deletions(-)
diff --git a/src/virtualenv/activation/bash/activate.sh b/src/virtualenv/activation/bash/activate.sh
index 76b9c39..0a372ff 100644
--- a/src/virtualenv/activation/bash/activate.sh
+++ b/src/virtualenv/activation/bash/activate.sh
@@ -116,13 +116,18 @@ fi
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT-}" ] ; then
_OLD_VIRTUAL_PS1="${PS1-}"
_VIRTUAL_PROMPT="${VIRTUAL_ENV_PROMPT}"
- # bash re-expands backslashes, dollar signs and backticks in PS1 on every redraw; the guard keeps the
- # bash-only substitution away from POSIX shells such as dash, which reject it with "Bad substitution"
- if [ -n "${BASH_VERSION-}" ]; then
+ # bash, and zsh under PROMPT_SUBST, re-expand backslashes, dollar signs and backticks in PS1 on every redraw;
+ # zsh gets the escaping even without the option, since a user can set it after activation. The guards keep
+ # the substitution away from POSIX shells such as dash, which reject it with "Bad substitution"
+ if [ -n "${BASH_VERSION-}${ZSH_VERSION-}" ]; then
_VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\\/\\\\}"
_VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\$/\\\$}"
_VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\`/\\\`}"
fi
+ # zsh expands % sequences in PS1 as prompt escapes
+ if [ -n "${ZSH_VERSION-}" ]; then
+ _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\%/%%}"
+ fi
PS1="(${_VIRTUAL_PROMPT}) ${PS1-}"
unset _VIRTUAL_PROMPT
export PS1
diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py
index e3608f1..defd2ed 100644
--- a/tests/unit/activation/test_bash.py
+++ b/tests/unit/activation/test_bash.py
@@ -141,16 +141,21 @@ def test_bash_activate_relocation_does_not_run_path_commands(
assert not (work_dir / "PWNED").exists()
-@pytest.fixture
-def bash_prompt_after_activate(tmp_path: Path, current_fastest: str) -> Callable[[str | None, str], tuple[Path, str]]:
- version = subprocess.run(
- ["bash", "-c", 'printf "%s" "$((BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1]))"'],
- capture_output=True,
- text=True,
- check=True,
- ).stdout
- if int(version) < 404:
- pytest.skip("${PS1@P} needs bash 4.4 or later")
+@pytest.fixture(params=["bash", "zsh"])
+def prompt_after_activate(
+ request: pytest.FixtureRequest, tmp_path: Path, current_fastest: str
+) -> Callable[[str | None, str], tuple[Path, str]]:
+ if shutil.which(shell := request.param) is None:
+ pytest.skip(f"{shell} is not installed")
+ if shell == "bash":
+ version = subprocess.run(
+ ["bash", "-c", 'printf "%s" "$((BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1]))"'],
+ capture_output=True,
+ text=True,
+ check=True,
+ ).stdout
+ if int(version) < 404:
+ pytest.skip("${PS1@P} needs bash 4.4 or later")
def build(prompt: str | None, env_name: str) -> tuple[Path, str]:
dest = tmp_path / env_name
@@ -166,13 +171,17 @@ def bash_prompt_after_activate(tmp_path: Path, current_fastest: str) -> Callable
if prompt is not None:
args += ["--prompt", prompt]
cli_run(args)
- activate = dest / "bin" / "activate"
work_dir = tmp_path / "workdir"
work_dir.mkdir(exist_ok=True)
# the path is passed as $1 so the outer shell cannot expand a payload dir name while locating the script;
- # ${PS1@P} then forces bash to render the prompt exactly as it does before each interactive command
+ # ${PS1@P} renders the prompt exactly as bash does before each interactive command, and in zsh (e) applies
+ # what PROMPT_SUBST does at each draw before (%) expands the prompt escapes
+ render = {
+ "bash": ["bash", "--norc", "--noprofile", "-c", 'source "$1"; printf "%s" "${PS1@P}"'],
+ "zsh": ["zsh", "-f", "-c", 'setopt prompt_subst; source "$1"; print -rn -- "${(%)${(e)PS1}}"'],
+ }[shell]
result = subprocess.run(
- ["bash", "--norc", "--noprofile", "-c", 'source "$1"; printf "%s" "${PS1@P}"', "bash", str(activate)],
+ [*render, shell, str(dest / "bin" / "activate")],
capture_output=True,
text=True,
cwd=str(work_dir),
@@ -190,24 +199,32 @@ def bash_prompt_after_activate(tmp_path: Path, current_fastest: str) -> Callable
[
pytest.param("x$(touch PWNED)y", "env", id="prompt-command-substitution"),
pytest.param("x`touch PWNED`y", "env", id="prompt-backticks"),
+ pytest.param("x${$(touch PWNED)}y", "env", id="prompt-parameter-expansion"),
pytest.param(None, "x$(touch PWNED)y", id="dirname-command-substitution"),
],
)
-def test_bash_prompt_does_not_run_commands(
- bash_prompt_after_activate: Callable[[str | None, str], tuple[Path, str]], prompt: str | None, env_name: str
+def test_prompt_does_not_run_commands(
+ prompt_after_activate: Callable[[str | None, str], tuple[Path, str]], prompt: str | None, env_name: str
) -> None:
- work_dir, _ = bash_prompt_after_activate(prompt, env_name)
+ work_dir, _ = prompt_after_activate(prompt, env_name)
assert not (work_dir / "PWNED").exists()
@pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash")
-def test_bash_prompt_keeps_plain_name_visible(
- bash_prompt_after_activate: Callable[[str | None, str], tuple[Path, str]],
+@pytest.mark.parametrize(
+ "prompt",
+ [
+ pytest.param("myenv", id="plain"),
+ pytest.param("a%n$HOME\\b`c`", id="special-characters"),
+ ],
+)
+def test_prompt_renders_name_literally(
+ prompt_after_activate: Callable[[str | None, str], tuple[Path, str]], prompt: str
) -> None:
- _, rendered = bash_prompt_after_activate("myenv", "env")
+ _, rendered = prompt_after_activate(prompt, "env")
- assert rendered.startswith("(myenv) ")
+ assert rendered.startswith(f"({prompt}) ")
@pytest.mark.skipif(IS_WIN or shutil.which("dash") is None, reason="needs dash as a POSIX shell")
--
2.35.6
@@ -18,6 +18,8 @@ SRC_URI += " \
file://CVE-2026-113732_p1.patch \
file://CVE-2026-113732_p2.patch \
file://CVE-2026-113776.patch \
file://CVE-2026-113726-dependent.patch \
file://CVE-2026-113726.patch \
"
BBCLASSEXTEND = "native nativesdk"