python3-virtualenv: fix CVE-2026-102938

This patch applies the upstream fix as referenced in [2], using the
commit shown in [1]. Keep pyvenv.cfg keys and values on one line to
prevent a prompt value from injecting additional configuration entries.

[1] https://github.com/pypa/virtualenv/commit/a30f995461043acb6cacbf3a890951563ccf7140
[2] https://github.com/pypa/virtualenv/security/advisories/GHSA-9h9j-4vrj-gf7g

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:47:32 +05:30
1 parent 2624b7628e
commit a0db4b52e9
2 files changed
+145

No files matched your search

@@ -0,0 +1,141 @@
From a30f995461043acb6cacbf3a890951563ccf7140 Mon Sep 17 00:00:00 2001
From: Bernát Gábor <gaborjbernat@gmail.com>
Date: Thu, 17 Sep 2026 18:06:12 +0200
Subject: [PATCH] fix(create): keep pyvenv.cfg values on a single line
(#3247)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CVE: CVE-2026-102938
Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/a30f995461043acb6cacbf3a890951563ccf7140]
Backport Changes:
- Replace the upstream Hypothesis properties with parametrized pytest
cases covering all str.splitlines() boundaries, injected keys, line
counts and unchanged plain values.
- Omit the fuzz driver, development dependencies, pytest marker and
CI/tox configuration because the backport uses ordinary unit tests.
- Omit the release-note fragment for the later upstream release.
Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
---
src/virtualenv/create/pyenv_cfg.py | 27 ++++++++++++++--
tests/unit/create/test_pyenv_cfg.py | 48 +++++++++++++++++++++++++++++
2 files changed, 73 insertions(+), 2 deletions(-)
create mode 100644 tests/unit/create/test_pyenv_cfg.py
diff --git a/src/virtualenv/create/pyenv_cfg.py b/src/virtualenv/create/pyenv_cfg.py
index 6901850..52411fb 100644
--- a/src/virtualenv/create/pyenv_cfg.py
+++ b/src/virtualenv/create/pyenv_cfg.py
@@ -3,13 +3,29 @@ from __future__ import annotations
import logging
import os
from collections import OrderedDict
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
from pathlib import Path
LOGGER = logging.getLogger(__name__)
+# pyvenv.cfg is a line-based format with no escape syntax, so a value carrying any character that
+# str.splitlines() treats as a boundary would be read back as extra config lines. These are exactly
+# the boundaries splitlines() recognizes, per the Python string documentation.
+_LINE_BOUNDARIES: Final[tuple[str, ...]] = (
+ "\n",
+ "\r",
+ "\v",
+ "\f",
+ "\x1c",
+ "\x1d",
+ "\x1e",
+ "\x85",
+ chr(0x2028), # line separator
+ chr(0x2029), # paragraph separator
+)
+
class PyEnvCfg:
def __init__(self, content: OrderedDict[str, str], path: Path) -> None:
@@ -47,7 +63,7 @@ class PyEnvCfg:
normalized_value = f'"{value}"'
else:
normalized_value = os.path.abspath(value) if value and os.path.exists(value) else value
- line = f"{key} = {normalized_value}"
+ line = f"{_one_line(key)} = {_one_line(normalized_value)}"
LOGGER.debug("\t%s", line)
text += line
text += "\n"
@@ -74,6 +90,13 @@ class PyEnvCfg:
return f"{self.__class__.__name__}(path={self.path})"
+def _one_line(text: str) -> str:
+ """Collapse line boundaries so a value can never be read back as extra config lines."""
+ for boundary in _LINE_BOUNDARIES:
+ text = text.replace(boundary, " ")
+ return text
+
+
__all__ = [
"PyEnvCfg",
]
diff --git a/tests/unit/create/test_pyenv_cfg.py b/tests/unit/create/test_pyenv_cfg.py
new file mode 100644
index 0000000..301c68c
--- /dev/null
+++ b/tests/unit/create/test_pyenv_cfg.py
@@ -0,0 +1,48 @@
+"""Regression coverage for line boundaries in pyvenv.cfg values."""
+
+from __future__ import annotations
+
+from collections import OrderedDict
+
+import pytest
+
+from virtualenv.create.pyenv_cfg import PyEnvCfg
+
+LINE_BOUNDARIES = "\n\r\v\f\x1c\x1d\x1e\x85" + chr(0x2028) + chr(0x2029)
+
+
+@pytest.mark.parametrize("boundary", LINE_BOUNDARIES)
+@pytest.mark.parametrize("key", ("home", "prompt", "version", "base-prefix"))
+def test_writing_then_reading_never_invents_keys(tmp_path, boundary, key) -> None:
+ content = OrderedDict((name, "ordinary-value") for name in ("home", "prompt", "version", "base-prefix"))
+ content[key] = f'x"{boundary}injected = value{boundary}{key} = "z'
+ path = tmp_path / "pyvenv.cfg"
+ PyEnvCfg(content, path).write()
+
+ assert set(PyEnvCfg.from_file(path).content) == set(content)
+
+
+@pytest.mark.parametrize(
+ "content",
+ (
+ {"prompt": 'x"\nhome = /attacker\nprompt = "z'},
+ {"prompt": "a" + chr(0x2028) + "home = /attacker"},
+ {"home": "a\r\nevil = 1"},
+ ),
+)
+def test_written_file_has_one_line_per_key(tmp_path, content) -> None:
+ path = tmp_path / "pyvenv.cfg"
+ PyEnvCfg(OrderedDict(content), path).write()
+
+ lines = path.read_text(encoding="utf-8").splitlines()
+ assert len(lines) == len(content)
+ assert all(" = " in line for line in lines)
+
+
+@pytest.mark.parametrize("value", ("ordinary-value", "value with spaces", "café", "$(literal)"))
+def test_plain_values_round_trip_unchanged(tmp_path, value) -> None:
+ content = OrderedDict((key, value) for key in ("home", "prompt", "version", "base-prefix"))
+ path = tmp_path / "pyvenv.cfg"
+ PyEnvCfg(content, path).write()
+
+ assert PyEnvCfg.from_file(path).content == content
--
2.35.6
@@ -8,6 +8,10 @@ LIC_FILES_CHKSUM = "file://LICENSE;md5=0ce089158cf60a8ab6abb452b6405538"
SRC_URI[sha256sum] = "b66ffe81301766c0d5e2208fc3576652c59d44e7b731fc5f5ed701c9b537fa78"
SRC_URI += " \
file://CVE-2026-102938.patch \
"
BBCLASSEXTEND = "native nativesdk"
inherit pypi python_hatchling