Skip to content

Commit d5319a7

Browse files
committed
fix(venv): run venv/installer via spawn_executable, no shell
ensure_venv and dependency installation built shell command strings and ran them through os:cmd, with quote/1 single-quote wrapping that did not escape embedded quotes -- a venv path/requirements/extras value could break out and inject. Run the executables via open_port({spawn_executable, Exe}, [{args, Args}, ...]) with literal argument lists instead. For uv, VIRTUAL_ENV is passed via the port {env, ...} option rather than a shell prefix. Adds py_venv_SUITE:test_venv_path_metacharacters.
1 parent c236bc6 commit d5319a7

3 files changed

Lines changed: 97 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
### Security
66

7+
- **No shell for venv/installer commands** - `py:ensure_venv` and dependency
8+
installation now run the executables via `open_port({spawn_executable, ...})` with an
9+
argument list instead of building a shell string for `os:cmd`. Venv paths, requirement
10+
files, and extras are passed literally, so shell metacharacters can't be injected. For
11+
`uv`, `VIRTUAL_ENV` is passed via the port `{env, ...}` option rather than a shell prefix.
712
- **Bounded shared state + safe stream/log builders** - `py_state` gained an optional
813
`max_state_entries` cap (default `infinity`, unchanged behavior) enforced with atomic
914
admission so Python-driven `state_set` can't exhaust node memory, and its size counter

src/py.erl

Lines changed: 73 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -900,14 +900,13 @@ create_venv(Path, Opts) ->
900900
undefined -> get_python_executable();
901901
P -> P
902902
end,
903-
Cmd = case Installer of
903+
case Installer of
904904
uv ->
905905
%% uv venv is faster, use --python to match the running interpreter
906-
io_lib:format("uv venv --python ~s ~s", [quote(Python), quote(Path)]);
906+
run_cmd(uv_exe(), ["venv", "--python", Python, Path], []);
907907
pip ->
908-
io_lib:format("~s -m venv ~s", [quote(Python), quote(Path)])
909-
end,
910-
run_cmd(lists:flatten(Cmd)).
908+
run_cmd(Python, ["-m", "venv", Path], [])
909+
end.
911910

912911
%% @private Get the Python executable path
913912
%% When embedded, sys.executable returns the embedding app (beam.smp)
@@ -927,30 +926,28 @@ get_python_executable() ->
927926
-spec install_deps(string(), string(), list()) -> ok | {error, term()}.
928927
install_deps(Path, RequirementsFile, Opts) ->
929928
Installer = detect_installer(Opts),
930-
PipPath = pip_path(Path, Installer),
929+
{Exe, BaseArgs, PortOpts} = pip_command(Path, Installer),
931930
Extras = proplists:get_value(extras, Opts, []),
932931

933-
%% Determine file type and build install command
934-
Cmd = case filename:extension(RequirementsFile) of
932+
%% Determine file type and build the install argument list (no shell).
933+
Args = case filename:extension(RequirementsFile) of
935934
".txt" ->
936-
%% requirements.txt
937-
io_lib:format("~s install -r ~s", [PipPath, quote(RequirementsFile)]);
935+
BaseArgs ++ ["install", "-r", RequirementsFile];
938936
".toml" ->
939-
%% pyproject.toml - install as editable
937+
%% pyproject.toml - install as editable.
940938
%% filename:dirname returns "." for files without directory component
941939
InstallPath = filename:dirname(RequirementsFile),
942940
case Extras of
943941
[] ->
944-
io_lib:format("~s install -e ~s", [PipPath, quote(InstallPath)]);
942+
BaseArgs ++ ["install", "-e", InstallPath];
945943
_ ->
946944
ExtrasStr = string:join(Extras, ","),
947-
io_lib:format("~s install -e \"~s[~s]\"", [PipPath, InstallPath, ExtrasStr])
945+
BaseArgs ++ ["install", "-e", InstallPath ++ "[" ++ ExtrasStr ++ "]"]
948946
end;
949947
_ ->
950-
%% Assume requirements.txt format
951-
io_lib:format("~s install -r ~s", [PipPath, quote(RequirementsFile)])
948+
BaseArgs ++ ["install", "-r", RequirementsFile]
952949
end,
953-
run_cmd(lists:flatten(Cmd)).
950+
run_cmd(Exe, Args, PortOpts).
954951

955952
%% @private Detect which installer to use (uv or pip)
956953
-spec detect_installer(list()) -> uv | pip.
@@ -965,40 +962,74 @@ detect_installer(Opts) ->
965962
Installer
966963
end.
967964

968-
%% @private Get pip/uv pip command path
969-
-spec pip_path(string(), uv | pip) -> string().
970-
pip_path(VenvPath, uv) ->
971-
%% uv pip uses venv from env var or --python flag
972-
"VIRTUAL_ENV=" ++ quote(VenvPath) ++ " uv pip";
973-
pip_path(VenvPath, pip) ->
974-
%% Use pip from the venv
975-
case os:type() of
965+
%% @private Resolve the installer into {Executable, BaseArgs, PortOpts}.
966+
%% For uv the venv is selected via the VIRTUAL_ENV port env option (not a shell
967+
%% prefix); for pip we use the venv's own pip binary.
968+
-spec pip_command(string(), uv | pip) -> {string(), [string()], list()}.
969+
pip_command(VenvPath, uv) ->
970+
{uv_exe(), ["pip"], [{env, [{"VIRTUAL_ENV", VenvPath}]}]};
971+
pip_command(VenvPath, pip) ->
972+
PipExe = case os:type() of
976973
{win32, _} ->
977974
filename:join([VenvPath, "Scripts", "pip"]);
978975
_ ->
979976
filename:join([VenvPath, "bin", "pip"])
977+
end,
978+
{PipExe, [], []}.
979+
980+
%% @private Full path to the uv executable (falls back to the bare name).
981+
-spec uv_exe() -> string().
982+
uv_exe() ->
983+
case os:find_executable("uv") of
984+
false -> "uv";
985+
P -> P
980986
end.
981987

982-
%% @private Quote a path for shell
983-
-spec quote(string()) -> string().
984-
quote(S) ->
985-
"'" ++ S ++ "'".
986-
987-
%% @private Run a shell command and return ok or error
988-
-spec run_cmd(string()) -> ok | {error, term()}.
989-
run_cmd(Cmd) ->
990-
%% Use os:cmd but check for errors
991-
Result = os:cmd(Cmd ++ " 2>&1; echo \"::exitcode::$?\""),
992-
%% Parse exit code from end of output
993-
case string:split(Result, "::exitcode::", trailing) of
994-
[Output, ExitCodeStr] ->
995-
case string:trim(ExitCodeStr) of
996-
"0" -> ok;
997-
Code -> {error, {exit_code, list_to_integer(Code), string:trim(Output)}}
988+
%% @private Run an executable with an argv list (no shell) and return ok or error.
989+
-spec run_cmd(string(), [string()], list()) -> ok | {error, term()}.
990+
run_cmd(Exe, Args, ExtraOpts) ->
991+
case resolve_exe(Exe) of
992+
{error, _} = Err ->
993+
Err;
994+
ExeFull ->
995+
try open_port({spawn_executable, ExeFull},
996+
[exit_status, stderr_to_stdout, binary, {args, Args} | ExtraOpts]) of
997+
Port -> collect_port(Port, [])
998+
catch
999+
error:Reason -> {error, {spawn_failed, Exe, Reason}}
1000+
end
1001+
end.
1002+
1003+
%% @private Resolve an executable name/path to a full path (spawn_executable does
1004+
%% not search PATH).
1005+
-spec resolve_exe(string()) -> string() | {error, term()}.
1006+
resolve_exe(Exe) ->
1007+
case filename:pathtype(Exe) of
1008+
absolute ->
1009+
case filelib:is_file(Exe) of
1010+
true -> Exe;
1011+
false -> {error, {executable_not_found, Exe}}
9981012
end;
9991013
_ ->
1000-
%% Fallback - assume success if no error marker
1001-
ok
1014+
case os:find_executable(Exe) of
1015+
false -> {error, {executable_not_found, Exe}};
1016+
Found -> Found
1017+
end
1018+
end.
1019+
1020+
%% @private Collect a spawned port's output and exit status.
1021+
-spec collect_port(port(), [binary()]) -> ok | {error, term()}.
1022+
collect_port(Port, Acc) ->
1023+
receive
1024+
{Port, {data, Data}} ->
1025+
collect_port(Port, [Data | Acc]);
1026+
{Port, {exit_status, 0}} ->
1027+
ok;
1028+
{Port, {exit_status, Code}} ->
1029+
{error, {exit_code, Code, iolist_to_binary(lists:reverse(Acc))}}
1030+
after 300000 ->
1031+
try port_close(Port) catch _:_ -> ok end,
1032+
{error, timeout}
10021033
end.
10031034

10041035
%% @private Convert to string

test/py_venv_SUITE.erl

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
test_ensure_venv_force_recreate/1,
3737
test_activate_venv/1,
3838
test_deactivate_venv/1,
39-
test_venv_info/1
39+
test_venv_info/1,
40+
test_venv_path_metacharacters/1
4041
]).
4142

4243
all() ->
@@ -51,7 +52,8 @@ groups() ->
5152
test_ensure_venv_force_recreate,
5253
test_activate_venv,
5354
test_deactivate_venv,
54-
test_venv_info
55+
test_venv_info,
56+
test_venv_path_metacharacters
5557
]}].
5658

5759
init_per_suite(Config) ->
@@ -123,6 +125,21 @@ test_ensure_venv_creates_venv(Config) ->
123125
true = maps:get(<<"active">>, Info),
124126
ok.
125127

128+
%% @doc A venv path containing a shell metacharacter (a single quote) must be
129+
%% treated literally by spawn_executable, not interpreted by a shell. Pre-fix
130+
%% this went through os:cmd with quote/1, where the embedded quote broke out of
131+
%% the quoting (and could inject). (A space is avoided here only because it
132+
%% breaks the venv's own pip shebang, which is unrelated to the shell fix.)
133+
test_venv_path_metacharacters(Config) ->
134+
TempDir = ?config(temp_dir, Config),
135+
VenvPath = filename:join(TempDir, "venv'q$x"),
136+
ReqFile = filename:join(TempDir, "requirements_meta.txt"),
137+
ok = file:write_file(ReqFile, <<"# empty\n">>),
138+
%% Created and used at the exact literal path (a shell would have broken it).
139+
ok = py:ensure_venv(VenvPath, ReqFile, [{installer, pip}]),
140+
true = filelib:is_file(filename:join(VenvPath, "pyvenv.cfg")),
141+
ok.
142+
126143
test_ensure_venv_activates_existing(Config) ->
127144
TempDir = ?config(temp_dir, Config),
128145
VenvPath = filename:join(TempDir, "venv"),

0 commit comments

Comments
 (0)