Skip to content

Commit 86ddded

Browse files
authored
Fix #2768: Quote template strings in activation scripts (#2771)
1 parent 6bb3f62 commit 86ddded

File tree

17 files changed

+106
-39
lines changed

17 files changed

+106
-39
lines changed

docs/changelog/2768.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Properly quote string placeholders in activation script templates to mitigate
2+
potential command injection - by :user:`y5c4l3`.

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ lint.ignore = [
133133
"S404", # Using subprocess is alright
134134
"S603", # subprocess calls are fine
135135
]
136+
lint.per-file-ignores."src/virtualenv/activation/python/activate_this.py" = [
137+
"F821", # ignore undefined template string placeholders
138+
]
136139
lint.per-file-ignores."tests/**/*.py" = [
137140
"D", # don't care about documentation in tests
138141
"FBT", # don't care about booleans as positional arguments in tests

src/virtualenv/activation/bash/activate.sh

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,18 @@ deactivate () {
4545
# unset irrelevant variables
4646
deactivate nondestructive
4747

48-
VIRTUAL_ENV='__VIRTUAL_ENV__'
48+
VIRTUAL_ENV=__VIRTUAL_ENV__
4949
if ([ "$OSTYPE" = "cygwin" ] || [ "$OSTYPE" = "msys" ]) && $(command -v cygpath &> /dev/null) ; then
5050
VIRTUAL_ENV=$(cygpath -u "$VIRTUAL_ENV")
5151
fi
5252
export VIRTUAL_ENV
5353

5454
_OLD_VIRTUAL_PATH="$PATH"
55-
PATH="$VIRTUAL_ENV/__BIN_NAME__:$PATH"
55+
PATH="$VIRTUAL_ENV/"__BIN_NAME__":$PATH"
5656
export PATH
5757

58-
if [ "x__VIRTUAL_PROMPT__" != x ] ; then
59-
VIRTUAL_ENV_PROMPT="__VIRTUAL_PROMPT__"
58+
if [ "x"__VIRTUAL_PROMPT__ != x ] ; then
59+
VIRTUAL_ENV_PROMPT=__VIRTUAL_PROMPT__
6060
else
6161
VIRTUAL_ENV_PROMPT=$(basename "$VIRTUAL_ENV")
6262
fi

src/virtualenv/activation/batch/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ def templates(self):
1515
yield "deactivate.bat"
1616
yield "pydoc.bat"
1717

18+
@staticmethod
19+
def quote(string):
20+
return string
21+
1822
def instantiate_template(self, replacements, template, creator):
1923
# ensure the text has all newlines as \r\n - required by batch
2024
base = super().instantiate_template(replacements, template, creator)

src/virtualenv/activation/cshell/activate.csh

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PA
1010
# Unset irrelevant variables.
1111
deactivate nondestructive
1212

13-
setenv VIRTUAL_ENV '__VIRTUAL_ENV__'
13+
setenv VIRTUAL_ENV __VIRTUAL_ENV__
1414

1515
set _OLD_VIRTUAL_PATH="$PATH:q"
16-
setenv PATH "$VIRTUAL_ENV:q/__BIN_NAME__:$PATH:q"
16+
setenv PATH "$VIRTUAL_ENV:q/"__BIN_NAME__":$PATH:q"
1717

1818

1919

20-
if ('__VIRTUAL_PROMPT__' != "") then
21-
setenv VIRTUAL_ENV_PROMPT '__VIRTUAL_PROMPT__'
20+
if (__VIRTUAL_PROMPT__ != "") then
21+
setenv VIRTUAL_ENV_PROMPT __VIRTUAL_PROMPT__
2222
else
2323
setenv VIRTUAL_ENV_PROMPT "$VIRTUAL_ENV:t:q"
2424
endif

src/virtualenv/activation/fish/activate.fish

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,20 +58,20 @@ end
5858
# Unset irrelevant variables.
5959
deactivate nondestructive
6060

61-
set -gx VIRTUAL_ENV '__VIRTUAL_ENV__'
61+
set -gx VIRTUAL_ENV __VIRTUAL_ENV__
6262

6363
# https://github.com/fish-shell/fish-shell/issues/436 altered PATH handling
6464
if test (echo $FISH_VERSION | head -c 1) -lt 3
6565
set -gx _OLD_VIRTUAL_PATH (_bashify_path $PATH)
6666
else
6767
set -gx _OLD_VIRTUAL_PATH $PATH
6868
end
69-
set -gx PATH "$VIRTUAL_ENV"'/__BIN_NAME__' $PATH
69+
set -gx PATH "$VIRTUAL_ENV"'/'__BIN_NAME__ $PATH
7070

7171
# Prompt override provided?
7272
# If not, just use the environment name.
73-
if test -n '__VIRTUAL_PROMPT__'
74-
set -gx VIRTUAL_ENV_PROMPT '__VIRTUAL_PROMPT__'
73+
if test -n __VIRTUAL_PROMPT__
74+
set -gx VIRTUAL_ENV_PROMPT __VIRTUAL_PROMPT__
7575
else
7676
set -gx VIRTUAL_ENV_PROMPT (basename "$VIRTUAL_ENV")
7777
end

src/virtualenv/activation/nushell/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ class NushellActivator(ViaTemplateActivator):
77
def templates(self):
88
yield "activate.nu"
99

10+
@staticmethod
11+
def quote(string):
12+
"""
13+
Nushell supports raw strings like: r###'this is a string'###.
14+
15+
This method finds the maximum continuous sharps in the string and then
16+
quote it with an extra sharp.
17+
"""
18+
max_sharps = 0
19+
current_sharps = 0
20+
for char in string:
21+
if char == "#":
22+
current_sharps += 1
23+
max_sharps = max(current_sharps, max_sharps)
24+
else:
25+
current_sharps = 0
26+
wrapping = "#" * (max_sharps + 1)
27+
return f"r{wrapping}'{string}'{wrapping}"
28+
1029
def replacements(self, creator, dest_folder): # noqa: ARG002
1130
return {
1231
"__VIRTUAL_PROMPT__": "" if self.flag_prompt is None else self.flag_prompt,

src/virtualenv/activation/nushell/activate.nu

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ export-env {
3232
}
3333
}
3434

35-
let virtual_env = '__VIRTUAL_ENV__'
36-
let bin = '__BIN_NAME__'
35+
let virtual_env = __VIRTUAL_ENV__
36+
let bin = __BIN_NAME__
3737

3838
let is_windows = ($nu.os-info.family) == 'windows'
3939
let path_name = (if (has-env 'Path') {
@@ -47,10 +47,10 @@ export-env {
4747
let new_path = ($env | get $path_name | prepend $venv_path)
4848

4949
# If there is no default prompt, then use the env name instead
50-
let virtual_env_prompt = (if ('__VIRTUAL_PROMPT__' | is-empty) {
50+
let virtual_env_prompt = (if (__VIRTUAL_PROMPT__ | is-empty) {
5151
($virtual_env | path basename)
5252
} else {
53-
'__VIRTUAL_PROMPT__'
53+
__VIRTUAL_PROMPT__
5454
})
5555

5656
let new_env = {

src/virtualenv/activation/powershell/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ class PowerShellActivator(ViaTemplateActivator):
77
def templates(self):
88
yield "activate.ps1"
99

10+
@staticmethod
11+
def quote(string):
12+
"""
13+
This should satisfy PowerShell quoting rules [1], unless the quoted
14+
string is passed directly to Windows native commands [2].
15+
16+
[1]: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules
17+
[2]: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_parsing#passing-arguments-that-contain-quote-characters
18+
""" # noqa: D205
19+
string = string.replace("'", "''")
20+
return f"'{string}'"
21+
1022

1123
__all__ = [
1224
"PowerShellActivator",

src/virtualenv/activation/powershell/activate.ps1

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,16 @@ deactivate -nondestructive
3737
$VIRTUAL_ENV = $BASE_DIR
3838
$env:VIRTUAL_ENV = $VIRTUAL_ENV
3939

40-
if ("__VIRTUAL_PROMPT__" -ne "") {
41-
$env:VIRTUAL_ENV_PROMPT = "__VIRTUAL_PROMPT__"
40+
if (__VIRTUAL_PROMPT__ -ne "") {
41+
$env:VIRTUAL_ENV_PROMPT = __VIRTUAL_PROMPT__
4242
}
4343
else {
4444
$env:VIRTUAL_ENV_PROMPT = $( Split-Path $env:VIRTUAL_ENV -Leaf )
4545
}
4646

4747
New-Variable -Scope global -Name _OLD_VIRTUAL_PATH -Value $env:PATH
4848

49-
$env:PATH = "$env:VIRTUAL_ENV/__BIN_NAME____PATH_SEP__" + $env:PATH
49+
$env:PATH = "$env:VIRTUAL_ENV/" + __BIN_NAME__ + __PATH_SEP__ + $env:PATH
5050
if (!$env:VIRTUAL_ENV_DISABLE_PROMPT) {
5151
function global:_old_virtual_prompt {
5252
""

src/virtualenv/activation/python/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@ class PythonActivator(ViaTemplateActivator):
1010
def templates(self):
1111
yield "activate_this.py"
1212

13+
@staticmethod
14+
def quote(string):
15+
return repr(string)
16+
1317
def replacements(self, creator, dest_folder):
1418
replacements = super().replacements(creator, dest_folder)
1519
lib_folders = OrderedDict((os.path.relpath(str(i), str(dest_folder)), None) for i in creator.libs)
16-
lib_folders = os.pathsep.join(lib_folders.keys()).replace("\\", "\\\\") # escape Windows path characters
20+
lib_folders = os.pathsep.join(lib_folders.keys())
1721
replacements.update(
1822
{
1923
"__LIB_FOLDERS__": lib_folders,

src/virtualenv/activation/python/activate_this.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,18 @@
2020
raise AssertionError(msg) from exc
2121

2222
bin_dir = os.path.dirname(abs_file)
23-
base = bin_dir[: -len("__BIN_NAME__") - 1] # strip away the bin part from the __file__, plus the path separator
23+
base = bin_dir[: -len(__BIN_NAME__) - 1] # strip away the bin part from the __file__, plus the path separator
2424

2525
# prepend bin to PATH (this file is inside the bin directory)
2626
os.environ["PATH"] = os.pathsep.join([bin_dir, *os.environ.get("PATH", "").split(os.pathsep)])
2727
os.environ["VIRTUAL_ENV"] = base # virtual env is right above bin directory
28-
os.environ["VIRTUAL_ENV_PROMPT"] = "__VIRTUAL_PROMPT__" or os.path.basename(base) # noqa: SIM222
28+
os.environ["VIRTUAL_ENV_PROMPT"] = __VIRTUAL_PROMPT__ or os.path.basename(base)
2929

3030
# add the virtual environments libraries to the host python import mechanism
3131
prev_length = len(sys.path)
32-
for lib in "__LIB_FOLDERS__".split(os.pathsep):
32+
for lib in __LIB_FOLDERS__.split(os.pathsep):
3333
path = os.path.realpath(os.path.join(bin_dir, lib))
34-
site.addsitedir(path.decode("utf-8") if "__DECODE_PATH__" else path)
34+
site.addsitedir(path.decode("utf-8") if __DECODE_PATH__ else path)
3535
sys.path[:] = sys.path[prev_length:] + sys.path[0:prev_length]
3636

3737
sys.real_prefix = sys.prefix

src/virtualenv/activation/via_template.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import os
4+
import shlex
45
import sys
56
from abc import ABC, abstractmethod
67

@@ -21,6 +22,16 @@ class ViaTemplateActivator(Activator, ABC):
2122
def templates(self):
2223
raise NotImplementedError
2324

25+
@staticmethod
26+
def quote(string):
27+
"""
28+
Quote strings in the activation script.
29+
30+
:param string: the string to quote
31+
:return: quoted string that works in the activation script
32+
"""
33+
return shlex.quote(string)
34+
2435
def generate(self, creator):
2536
dest_folder = creator.bin_dir
2637
replacements = self.replacements(creator, dest_folder)
@@ -63,7 +74,7 @@ def instantiate_template(self, replacements, template, creator):
6374
text = binary.decode("utf-8", errors="strict")
6475
for key, value in replacements.items():
6576
value_uni = self._repr_unicode(creator, value)
66-
text = text.replace(key, value_uni)
77+
text = text.replace(key, self.quote(value_uni))
6778
return text
6879

6980
@staticmethod

tests/conftest.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,11 @@ def is_inside_ci():
275275

276276
@pytest.fixture(scope="session")
277277
def special_char_name():
278-
base = "e-$ èрт🚒♞中片-j"
278+
base = "'\";&&e-$ èрт🚒♞中片-j"
279+
if IS_WIN:
280+
# get rid of invalid characters on Windows
281+
base = base.replace('"', "")
282+
base = base.replace(";", "")
279283
# workaround for pypy3 https://bitbucket.org/pypy/pypy/issues/3147/venv-non-ascii-support-windows
280284
encoding = "ascii" if IS_WIN else sys.getfilesystemencoding()
281285
# let's not include characters that the file system cannot encode)

tests/unit/activation/conftest.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import sys
77
from os.path import dirname, normcase
88
from pathlib import Path
9-
from shlex import quote
109
from subprocess import Popen
1110

1211
import pytest
@@ -154,7 +153,7 @@ def assert_output(self, out, raw, tmp_path):
154153
assert out[-1] == "None", raw
155154

156155
def quote(self, s):
157-
return quote(s)
156+
return self.of_class.quote(s)
158157

159158
def python_cmd(self, cmd):
160159
return f"{os.path.basename(sys.executable)} -c {self.quote(cmd)}"

tests/unit/activation/test_batch.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
from __future__ import annotations
22

3-
from shlex import quote
4-
53
import pytest
64

75
from virtualenv.activation import BatchActivator
@@ -26,10 +24,12 @@ def _get_test_lines(self, activate_script):
2624
return ["@echo off", *super()._get_test_lines(activate_script)]
2725

2826
def quote(self, s):
29-
"""double quotes needs to be single, and single need to be double"""
30-
return "".join(("'" if c == '"' else ('"' if c == "'" else c)) for c in quote(s))
27+
if '"' in s or " " in s:
28+
text = s.replace('"', r"\"")
29+
return f'"{text}"'
30+
return s
3131

3232
def print_prompt(self):
33-
return "echo %PROMPT%"
33+
return 'echo "%PROMPT%"'
3434

3535
activation_tester(Batch)

tests/unit/activation/test_powershell.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

33
import sys
4-
from shlex import quote
54

65
import pytest
76

@@ -21,11 +20,6 @@ def __init__(self, session) -> None:
2120
self.activate_cmd = "."
2221
self.script_encoding = "utf-8-sig"
2322

24-
def quote(self, s):
25-
"""powershell double quote needed for quotes within single quotes"""
26-
text = quote(s)
27-
return text.replace('"', '""') if sys.platform == "win32" else text
28-
2923
def _get_test_lines(self, activate_script):
3024
return super()._get_test_lines(activate_script)
3125

@@ -35,4 +29,19 @@ def invoke_script(self):
3529
def print_prompt(self):
3630
return "prompt"
3731

32+
def quote(self, s):
33+
"""
34+
Tester will pass strings to native commands on Windows so extra
35+
parsing rules are used. Check `PowerShellActivator.quote` for more
36+
details.
37+
"""
38+
text = PowerShellActivator.quote(s)
39+
return text.replace('"', '""') if sys.platform == "win32" else text
40+
41+
def activate_call(self, script):
42+
# Commands are called without quotes in PowerShell
43+
cmd = self.activate_cmd
44+
scr = self.quote(str(script))
45+
return f"{cmd} {scr}".strip()
46+
3847
activation_tester(PowerShell)

0 commit comments

Comments
 (0)