Skip to content

Commit c9571a5

Browse files
mceply5c4l3vstinner
committed
[CVE-2024-9287] properly quote path names provided when creating a venv
This patch properly quotes template strings in venv activation scripts. This mitigates potential command injection. Fixes: bsc#1232241 (CVE-2024-9287) Fixes: gh#python#124651 Co-authored-by: y5c4l3 <[email protected]> Co-authored-by: Victor Stinner <[email protected]> From-PR: gh#python/cpython!124712 Patch: CVE-2024-9287-venv_path_unquoted.patch
1 parent bfc7d56 commit c9571a5

File tree

6 files changed

+129
-14
lines changed

6 files changed

+129
-14
lines changed

Lib/test/test_venv.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@
99
import os
1010
import os.path
1111
import re
12+
import shutil
1213
import struct
1314
import subprocess
1415
import sys
1516
import tempfile
17+
import shlex
1618
from test.support import (captured_stdout, captured_stderr, requires_zlib,
1719
can_symlink, EnvironmentVarGuard, rmtree)
1820
import unittest
@@ -80,6 +82,10 @@ def get_text_file_contents(self, *args):
8082
result = f.read()
8183
return result
8284

85+
def assertEndsWith(self, string, tail):
86+
if not string.endswith(tail):
87+
self.fail(f"String {string!r} does not end with {tail!r}")
88+
8389
class BasicTest(BaseTest):
8490
"""Test venv module functionality."""
8591

@@ -293,6 +299,82 @@ def test_executable_symlinks(self):
293299
'import sys; print(sys.executable)'])
294300
self.assertEqual(out.strip(), envpy.encode())
295301

302+
# gh-124651: test quoted strings
303+
@unittest.skipIf(os.name == 'nt', 'contains invalid characters on Windows')
304+
def test_special_chars_bash(self):
305+
"""
306+
Test that the template strings are quoted properly (bash)
307+
"""
308+
rmtree(self.env_dir)
309+
bash = shutil.which('bash')
310+
if bash is None:
311+
self.skipTest('bash required for this test')
312+
env_name = '"\';&&$e|\'"'
313+
env_dir = os.path.join(os.path.realpath(self.env_dir), env_name)
314+
builder = venv.EnvBuilder(clear=True)
315+
builder.create(env_dir)
316+
activate = os.path.join(env_dir, self.bindir, 'activate')
317+
test_script = os.path.join(self.env_dir, 'test_special_chars.sh')
318+
with open(test_script, "w") as f:
319+
f.write(f'source {shlex.quote(activate)}\n'
320+
'python -c \'import sys; print(sys.executable)\'\n'
321+
'python -c \'import os; print(os.environ["VIRTUAL_ENV"])\'\n'
322+
'deactivate\n')
323+
out, err = check_output([bash, test_script])
324+
lines = out.splitlines()
325+
self.assertTrue(env_name.encode() in lines[0])
326+
self.assertEndsWith(lines[1], env_name.encode())
327+
328+
# gh-124651: test quoted strings
329+
@unittest.skipIf(os.name == 'nt', 'contains invalid characters on Windows')
330+
def test_special_chars_csh(self):
331+
"""
332+
Test that the template strings are quoted properly (csh)
333+
"""
334+
rmtree(self.env_dir)
335+
csh = shutil.which('tcsh') or shutil.which('csh')
336+
if csh is None:
337+
self.skipTest('csh required for this test')
338+
env_name = '"\';&&$e|\'"'
339+
env_dir = os.path.join(os.path.realpath(self.env_dir), env_name)
340+
builder = venv.EnvBuilder(clear=True)
341+
builder.create(env_dir)
342+
activate = os.path.join(env_dir, self.bindir, 'activate.csh')
343+
test_script = os.path.join(self.env_dir, 'test_special_chars.csh')
344+
with open(test_script, "w") as f:
345+
f.write(f'source {shlex.quote(activate)}\n'
346+
'python -c \'import sys; print(sys.executable)\'\n'
347+
'python -c \'import os; print(os.environ["VIRTUAL_ENV"])\'\n'
348+
'deactivate\n')
349+
out, err = check_output([csh, test_script])
350+
lines = out.splitlines()
351+
self.assertTrue(env_name.encode() in lines[0])
352+
self.assertEndsWith(lines[1], env_name.encode())
353+
354+
# gh-124651: test quoted strings on Windows
355+
@unittest.skipUnless(os.name == 'nt', 'only relevant on Windows')
356+
def test_special_chars_windows(self):
357+
"""
358+
Test that the template strings are quoted properly on Windows
359+
"""
360+
rmtree(self.env_dir)
361+
env_name = "'&&^$e"
362+
env_dir = os.path.join(os.path.realpath(self.env_dir), env_name)
363+
builder = venv.EnvBuilder(clear=True)
364+
builder.create(env_dir)
365+
activate = os.path.join(env_dir, self.bindir, 'activate.bat')
366+
test_batch = os.path.join(self.env_dir, 'test_special_chars.bat')
367+
with open(test_batch, "w") as f:
368+
f.write('@echo off\n'
369+
f'"{activate}" & '
370+
f'{self.exe} -c "import sys; print(sys.executable)" & '
371+
f'{self.exe} -c "import os; print(os.environ[\'VIRTUAL_ENV\'])" & '
372+
'deactivate')
373+
out, err = check_output([test_batch])
374+
lines = out.splitlines()
375+
self.assertTrue(env_name.encode() in lines[0])
376+
self.assertEndsWith(lines[1], env_name.encode())
377+
296378
@unittest.skipUnless(os.name == 'nt', 'only relevant on Windows')
297379
def test_unicode_in_batch_file(self):
298380
"""

Lib/venv/__init__.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import subprocess
1111
import sys
1212
import types
13+
import shlex
1314

1415
logger = logging.getLogger(__name__)
1516

@@ -280,11 +281,41 @@ def replace_variables(self, text, context):
280281
:param context: The information for the environment creation request
281282
being processed.
282283
"""
283-
text = text.replace('__VENV_DIR__', context.env_dir)
284-
text = text.replace('__VENV_NAME__', context.env_name)
285-
text = text.replace('__VENV_PROMPT__', context.prompt)
286-
text = text.replace('__VENV_BIN_NAME__', context.bin_name)
287-
text = text.replace('__VENV_PYTHON__', context.env_exe)
284+
replacements = {
285+
'__VENV_DIR__': context.env_dir,
286+
'__VENV_NAME__': context.env_name,
287+
'__VENV_PROMPT__': context.prompt,
288+
'__VENV_BIN_NAME__': context.bin_name,
289+
'__VENV_PYTHON__': context.env_exe,
290+
}
291+
292+
def quote_ps1(s):
293+
"""
294+
This should satisfy PowerShell quoting rules [1], unless the quoted
295+
string is passed directly to Windows native commands [2].
296+
[1]: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules
297+
[2]: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_parsing#passing-arguments-that-contain-quote-characters
298+
"""
299+
s = s.replace("'", "''")
300+
return f"'{s}'"
301+
302+
def quote_bat(s):
303+
return s
304+
305+
# gh-124651: need to quote the template strings properly
306+
quote = shlex.quote
307+
script_path = context.script_path
308+
if script_path.endswith('.ps1'):
309+
quote = quote_ps1
310+
elif script_path.endswith('.bat'):
311+
quote = quote_bat
312+
else:
313+
# fallbacks to POSIX shell compliant quote
314+
quote = shlex.quote
315+
316+
replacements = {key: quote(s) for key, s in replacements.items()}
317+
for key, quoted in replacements.items():
318+
text = text.replace(key, quoted)
288319
return text
289320

290321
def install_scripts(self, context, path):
@@ -321,6 +352,7 @@ def install_scripts(self, context, path):
321352
with open(srcfile, 'rb') as f:
322353
data = f.read()
323354
if not srcfile.endswith('.exe'):
355+
context.script_path = srcfile
324356
try:
325357
data = data.decode('utf-8')
326358
data = self.replace_variables(data, context)

Lib/venv/scripts/common/activate

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,11 @@ deactivate () {
3737
# unset irrelevant variables
3838
deactivate nondestructive
3939

40-
VIRTUAL_ENV="__VENV_DIR__"
40+
VIRTUAL_ENV=__VENV_DIR__
4141
export VIRTUAL_ENV
4242

4343
_OLD_VIRTUAL_PATH="$PATH"
44-
PATH="$VIRTUAL_ENV/__VENV_BIN_NAME__:$PATH"
44+
PATH="$VIRTUAL_ENV/"__VENV_BIN_NAME__":$PATH"
4545
export PATH
4646

4747
# unset PYTHONHOME if set
@@ -55,7 +55,7 @@ fi
5555
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
5656
_OLD_VIRTUAL_PS1="${PS1:-}"
5757
if [ "x__VENV_PROMPT__" != x ] ; then
58-
PS1="__VENV_PROMPT__${PS1:-}"
58+
PS1=__VENV_PROMPT__"${PS1:-}"
5959
else
6060
if [ "`basename \"$VIRTUAL_ENV\"`" = "__" ] ; then
6161
# special case for Aspen magic directories

Lib/venv/scripts/posix/activate.csh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@ alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PA
88
# Unset irrelevant variables.
99
deactivate nondestructive
1010

11-
setenv VIRTUAL_ENV "__VENV_DIR__"
11+
setenv VIRTUAL_ENV __VENV_DIR__
1212

1313
set _OLD_VIRTUAL_PATH="$PATH"
14-
setenv PATH "$VIRTUAL_ENV/__VENV_BIN_NAME__:$PATH"
14+
setenv PATH "$VIRTUAL_ENV/"__VENV_BIN_NAME__":$PATH"
1515

1616

1717
set _OLD_VIRTUAL_PROMPT="$prompt"
1818

1919
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
2020
if ("__VENV_NAME__" != "") then
21-
set env_name = "__VENV_NAME__"
21+
set env_name = __VENV_NAME__
2222
else
2323
if (`basename "VIRTUAL_ENV"` == "__") then
2424
# special case for Aspen magic directories

Lib/venv/scripts/posix/activate.fish

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ end
2929
# unset irrelevant variables
3030
deactivate nondestructive
3131

32-
set -gx VIRTUAL_ENV "__VENV_DIR__"
32+
set -gx VIRTUAL_ENV __VENV_DIR__
3333

3434
set -gx _OLD_VIRTUAL_PATH $PATH
35-
set -gx PATH "$VIRTUAL_ENV/__VENV_BIN_NAME__" $PATH
35+
set -gx PATH "$VIRTUAL_ENV/"__VENV_BIN_NAME__ $PATH
3636

3737
# unset PYTHONHOME if set
3838
if set -q PYTHONHOME
@@ -53,7 +53,7 @@ if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
5353

5454
# Prompt override?
5555
if test -n "__VENV_PROMPT__"
56-
printf "%s%s" "__VENV_PROMPT__" (set_color normal)
56+
printf "%s%s" __VENV_PROMPT__ (set_color normal)
5757
else
5858
# ...Otherwise, prepend env
5959
set -l _checkbase (basename "$VIRTUAL_ENV")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Properly quote template strings in :mod:`venv` activation scripts.

0 commit comments

Comments
 (0)