]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-124651: Quote template strings in `venv` activation scripts (GH-124712)
authorY5 <124019959+y5c4l3@users.noreply.github.com>
Mon, 21 Oct 2024 20:48:04 +0000 (04:48 +0800)
committerGitHub <noreply@github.com>
Mon, 21 Oct 2024 20:48:04 +0000 (21:48 +0100)
This patch properly quotes template strings in `venv` activation
scripts. This mitigates potential command injection.

Lib/test/test_venv.py
Lib/venv/__init__.py
Lib/venv/scripts/common/activate
Lib/venv/scripts/common/activate.fish
Lib/venv/scripts/nt/activate.bat
Lib/venv/scripts/posix/activate.csh
Misc/NEWS.d/next/Library/2024-09-28-02-03-04.gh-issue-124651.bLBGtH.rst [new file with mode: 0644]

index 1ef08da326c18c2b7bf24c843431eaa1e7544689..6b2127bd31e40ab2cc9441274a1f4a5965a58319 100644 (file)
@@ -17,6 +17,7 @@ import subprocess
 import sys
 import sysconfig
 import tempfile
+import shlex
 from test.support import (captured_stdout, captured_stderr,
                           skip_if_broken_multiprocessing_synchronize, verbose,
                           requires_subprocess, is_android, is_apple_mobile,
@@ -110,6 +111,10 @@ class BaseTest(unittest.TestCase):
             result = f.read()
         return result
 
+    def assertEndsWith(self, string, tail):
+        if not string.endswith(tail):
+            self.fail(f"String {string!r} does not end with {tail!r}")
+
 class BasicTest(BaseTest):
     """Test venv module functionality."""
 
@@ -488,6 +493,82 @@ class BasicTest(BaseTest):
             'import sys; print(sys.executable)'])
         self.assertEqual(out.strip(), envpy.encode())
 
+    # gh-124651: test quoted strings
+    @unittest.skipIf(os.name == 'nt', 'contains invalid characters on Windows')
+    def test_special_chars_bash(self):
+        """
+        Test that the template strings are quoted properly (bash)
+        """
+        rmtree(self.env_dir)
+        bash = shutil.which('bash')
+        if bash is None:
+            self.skipTest('bash required for this test')
+        env_name = '"\';&&$e|\'"'
+        env_dir = os.path.join(os.path.realpath(self.env_dir), env_name)
+        builder = venv.EnvBuilder(clear=True)
+        builder.create(env_dir)
+        activate = os.path.join(env_dir, self.bindir, 'activate')
+        test_script = os.path.join(self.env_dir, 'test_special_chars.sh')
+        with open(test_script, "w") as f:
+            f.write(f'source {shlex.quote(activate)}\n'
+                    'python -c \'import sys; print(sys.executable)\'\n'
+                    'python -c \'import os; print(os.environ["VIRTUAL_ENV"])\'\n'
+                    'deactivate\n')
+        out, err = check_output([bash, test_script])
+        lines = out.splitlines()
+        self.assertTrue(env_name.encode() in lines[0])
+        self.assertEndsWith(lines[1], env_name.encode())
+
+    # gh-124651: test quoted strings
+    @unittest.skipIf(os.name == 'nt', 'contains invalid characters on Windows')
+    def test_special_chars_csh(self):
+        """
+        Test that the template strings are quoted properly (csh)
+        """
+        rmtree(self.env_dir)
+        csh = shutil.which('tcsh') or shutil.which('csh')
+        if csh is None:
+            self.skipTest('csh required for this test')
+        env_name = '"\';&&$e|\'"'
+        env_dir = os.path.join(os.path.realpath(self.env_dir), env_name)
+        builder = venv.EnvBuilder(clear=True)
+        builder.create(env_dir)
+        activate = os.path.join(env_dir, self.bindir, 'activate.csh')
+        test_script = os.path.join(self.env_dir, 'test_special_chars.csh')
+        with open(test_script, "w") as f:
+            f.write(f'source {shlex.quote(activate)}\n'
+                    'python -c \'import sys; print(sys.executable)\'\n'
+                    'python -c \'import os; print(os.environ["VIRTUAL_ENV"])\'\n'
+                    'deactivate\n')
+        out, err = check_output([csh, test_script])
+        lines = out.splitlines()
+        self.assertTrue(env_name.encode() in lines[0])
+        self.assertEndsWith(lines[1], env_name.encode())
+
+    # gh-124651: test quoted strings on Windows
+    @unittest.skipUnless(os.name == 'nt', 'only relevant on Windows')
+    def test_special_chars_windows(self):
+        """
+        Test that the template strings are quoted properly on Windows
+        """
+        rmtree(self.env_dir)
+        env_name = "'&&^$e"
+        env_dir = os.path.join(os.path.realpath(self.env_dir), env_name)
+        builder = venv.EnvBuilder(clear=True)
+        builder.create(env_dir)
+        activate = os.path.join(env_dir, self.bindir, 'activate.bat')
+        test_batch = os.path.join(self.env_dir, 'test_special_chars.bat')
+        with open(test_batch, "w") as f:
+            f.write('@echo off\n'
+                    f'"{activate}" & '
+                    f'{self.exe} -c "import sys; print(sys.executable)" & '
+                    f'{self.exe} -c "import os; print(os.environ[\'VIRTUAL_ENV\'])" & '
+                    'deactivate')
+        out, err = check_output([test_batch])
+        lines = out.splitlines()
+        self.assertTrue(env_name.encode() in lines[0])
+        self.assertEndsWith(lines[1], env_name.encode())
+
     @unittest.skipUnless(os.name == 'nt', 'only relevant on Windows')
     def test_unicode_in_batch_file(self):
         """
index a5d348ba4cf121cee0865572762fadb3ca658b3f..ca1af84e6705feeb42418b8bfbde003b9bdf1eef 100644 (file)
@@ -11,6 +11,7 @@ import subprocess
 import sys
 import sysconfig
 import types
+import shlex
 
 
 CORE_VENV_DEPS = ('pip',)
@@ -484,11 +485,41 @@ class EnvBuilder:
         :param context: The information for the environment creation request
                         being processed.
         """
-        text = text.replace('__VENV_DIR__', context.env_dir)
-        text = text.replace('__VENV_NAME__', context.env_name)
-        text = text.replace('__VENV_PROMPT__', context.prompt)
-        text = text.replace('__VENV_BIN_NAME__', context.bin_name)
-        text = text.replace('__VENV_PYTHON__', context.env_exe)
+        replacements = {
+            '__VENV_DIR__': context.env_dir,
+            '__VENV_NAME__': context.env_name,
+            '__VENV_PROMPT__': context.prompt,
+            '__VENV_BIN_NAME__': context.bin_name,
+            '__VENV_PYTHON__': context.env_exe,
+        }
+
+        def quote_ps1(s):
+            """
+            This should satisfy PowerShell quoting rules [1], unless the quoted
+            string is passed directly to Windows native commands [2].
+            [1]: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules
+            [2]: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_parsing#passing-arguments-that-contain-quote-characters
+            """
+            s = s.replace("'", "''")
+            return f"'{s}'"
+
+        def quote_bat(s):
+            return s
+
+        # gh-124651: need to quote the template strings properly
+        quote = shlex.quote
+        script_path = context.script_path
+        if script_path.endswith('.ps1'):
+            quote = quote_ps1
+        elif script_path.endswith('.bat'):
+            quote = quote_bat
+        else:
+            # fallbacks to POSIX shell compliant quote
+            quote = shlex.quote
+
+        replacements = {key: quote(s) for key, s in replacements.items()}
+        for key, quoted in replacements.items():
+            text = text.replace(key, quoted)
         return text
 
     def install_scripts(self, context, path):
@@ -538,6 +569,7 @@ class EnvBuilder:
                 with open(srcfile, 'rb') as f:
                     data = f.read()
                 try:
+                    context.script_path = srcfile
                     new_data = (
                         self.replace_variables(data.decode('utf-8'), context)
                             .encode('utf-8')
index 44f137672e9d2e4faf82fb1c69daf828e1039556..70673a265d41f803982f920b038693bc088aa0fc 100644 (file)
@@ -41,20 +41,20 @@ case "$(uname)" in
     CYGWIN*|MSYS*|MINGW*)
         # transform D:\path\to\venv to /d/path/to/venv on MSYS and MINGW
         # and to /cygdrive/d/path/to/venv on Cygwin
-        VIRTUAL_ENV=$(cygpath "__VENV_DIR__")
+        VIRTUAL_ENV=$(cygpath __VENV_DIR__)
         export VIRTUAL_ENV
         ;;
     *)
         # use the path as-is
-        export VIRTUAL_ENV="__VENV_DIR__"
+        export VIRTUAL_ENV=__VENV_DIR__
         ;;
 esac
 
 _OLD_VIRTUAL_PATH="$PATH"
-PATH="$VIRTUAL_ENV/__VENV_BIN_NAME__:$PATH"
+PATH="$VIRTUAL_ENV/"__VENV_BIN_NAME__":$PATH"
 export PATH
 
-VIRTUAL_ENV_PROMPT="__VENV_PROMPT__"
+VIRTUAL_ENV_PROMPT=__VENV_PROMPT__
 export VIRTUAL_ENV_PROMPT
 
 # unset PYTHONHOME if set
@@ -67,7 +67,7 @@ fi
 
 if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
     _OLD_VIRTUAL_PS1="${PS1:-}"
-    PS1="(__VENV_PROMPT__) ${PS1:-}"
+    PS1="("__VENV_PROMPT__") ${PS1:-}"
     export PS1
 fi
 
index 25c42756789bbca149013775c73ddfebeb5958a8..284a7469c99b5769215de855d7dc80d63a1ba874 100644 (file)
@@ -33,11 +33,11 @@ end
 # Unset irrelevant variables.
 deactivate nondestructive
 
-set -gx VIRTUAL_ENV "__VENV_DIR__"
+set -gx VIRTUAL_ENV __VENV_DIR__
 
 set -gx _OLD_VIRTUAL_PATH $PATH
-set -gx PATH "$VIRTUAL_ENV/__VENV_BIN_NAME__" $PATH
-set -gx VIRTUAL_ENV_PROMPT "__VENV_PROMPT__"
+set -gx PATH "$VIRTUAL_ENV/"__VENV_BIN_NAME__ $PATH
+set -gx VIRTUAL_ENV_PROMPT __VENV_PROMPT__
 
 # Unset PYTHONHOME if set.
 if set -q PYTHONHOME
@@ -57,7 +57,7 @@ if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
         set -l old_status $status
 
         # Output the venv prompt; color taken from the blue of the Python logo.
-        printf "%s(%s)%s " (set_color 4B8BBE) "__VENV_PROMPT__" (set_color normal)
+        printf "%s(%s)%s " (set_color 4B8BBE) __VENV_PROMPT__ (set_color normal)
 
         # Restore the return status of the previous command.
         echo "exit $old_status" | .
index dd5ea8eb67b90a7246eeeced4187d2f023b4b508..35533e4b551155a166724e9ee498ed15ec6c75d9 100644 (file)
@@ -8,7 +8,7 @@ if defined _OLD_CODEPAGE (
     "%SystemRoot%\System32\chcp.com" 65001 > nul
 )
 
-set VIRTUAL_ENV=__VENV_DIR__
+set "VIRTUAL_ENV=__VENV_DIR__"
 
 if not defined PROMPT set PROMPT=$P$G
 
@@ -24,8 +24,8 @@ set PYTHONHOME=
 if defined _OLD_VIRTUAL_PATH set PATH=%_OLD_VIRTUAL_PATH%
 if not defined _OLD_VIRTUAL_PATH set _OLD_VIRTUAL_PATH=%PATH%
 
-set PATH=%VIRTUAL_ENV%\__VENV_BIN_NAME__;%PATH%
-set VIRTUAL_ENV_PROMPT=__VENV_PROMPT__
+set "PATH=%VIRTUAL_ENV%\__VENV_BIN_NAME__;%PATH%"
+set "VIRTUAL_ENV_PROMPT=__VENV_PROMPT__"
 
 :END
 if defined _OLD_CODEPAGE (
index b5db4a0f847e06c931ba64d24570ef417d93e2ce..2a3fa835476ab9b44551831ab8b7ad43e86df64b 100644 (file)
@@ -9,17 +9,17 @@ alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PA
 # Unset irrelevant variables.
 deactivate nondestructive
 
-setenv VIRTUAL_ENV "__VENV_DIR__"
+setenv VIRTUAL_ENV __VENV_DIR__
 
 set _OLD_VIRTUAL_PATH="$PATH"
-setenv PATH "$VIRTUAL_ENV/__VENV_BIN_NAME__:$PATH"
-setenv VIRTUAL_ENV_PROMPT "__VENV_PROMPT__"
+setenv PATH "$VIRTUAL_ENV/"__VENV_BIN_NAME__":$PATH"
+setenv VIRTUAL_ENV_PROMPT __VENV_PROMPT__
 
 
 set _OLD_VIRTUAL_PROMPT="$prompt"
 
 if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
-    set prompt = "(__VENV_PROMPT__) $prompt:q"
+    set prompt = "("__VENV_PROMPT__") $prompt:q"
 endif
 
 alias pydoc python -m pydoc
diff --git a/Misc/NEWS.d/next/Library/2024-09-28-02-03-04.gh-issue-124651.bLBGtH.rst b/Misc/NEWS.d/next/Library/2024-09-28-02-03-04.gh-issue-124651.bLBGtH.rst
new file mode 100644 (file)
index 0000000..17fc917
--- /dev/null
@@ -0,0 +1 @@
+Properly quote template strings in :mod:`venv` activation scripts.