Skip to content

Commit d199d49

Browse files
xr843claude
andcommitted
fix(security): remove shell=True to prevent command injection on Windows
Replaces shell=True subprocess calls with explicit argument lists to prevent command injection vulnerabilities, particularly on Windows where shell metacharacters can be exploited. In _get_npx_command(), replaced subprocess.run() with shutil.which() to locate the npx executable without spawning a shell process. This is both safer and more efficient. In dev(), removed the conditional shell=True for Windows since _get_npx_command() already resolves the correct executable name (e.g. npx.cmd), making shell dispatch unnecessary. Fixes #1257 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 98f8ef2 commit d199d49

File tree

2 files changed

+18
-22
lines changed

2 files changed

+18
-22
lines changed

src/mcp/cli/cli.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import importlib.metadata
44
import importlib.util
55
import os
6+
import shutil
67
import subprocess
78
import sys
89
from pathlib import Path
@@ -39,16 +40,14 @@
3940
)
4041

4142

42-
def _get_npx_command():
43+
def _get_npx_command() -> str | None:
4344
"""Get the correct npx command for the current platform."""
4445
if sys.platform == "win32":
45-
# Try both npx.cmd and npx.exe on Windows
46+
# Use shutil.which() to safely locate the executable on Windows
47+
# without invoking a shell, preventing command injection risks.
4648
for cmd in ["npx.cmd", "npx.exe", "npx"]:
47-
try:
48-
subprocess.run([cmd, "--version"], check=True, capture_output=True, shell=True)
49+
if shutil.which(cmd) is not None:
4950
return cmd
50-
except subprocess.CalledProcessError:
51-
continue
5251
return None
5352
return "npx" # On Unix-like systems, just use npx
5453

@@ -271,12 +270,13 @@ def dev(
271270
)
272271
sys.exit(1)
273272

274-
# Run the MCP Inspector command with shell=True on Windows
275-
shell = sys.platform == "win32"
273+
# Run the MCP Inspector command without shell=True to prevent
274+
# command injection via shell metacharacters (see #1257).
275+
# _get_npx_command() already resolves the correct executable
276+
# (e.g. npx.cmd on Windows), so shell dispatch is unnecessary.
276277
process = subprocess.run(
277278
[npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd,
278279
check=True,
279-
shell=shell,
280280
env=dict(os.environ.items()), # Convert to list of tuples for env update
281281
)
282282
sys.exit(process.returncode)

tests/cli/test_utils.py

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import shutil
12
import subprocess
23
import sys
34
from pathlib import Path
@@ -76,26 +77,21 @@ def test_get_npx_unix_like(monkeypatch: pytest.MonkeyPatch):
7677

7778

7879
def test_get_npx_windows(monkeypatch: pytest.MonkeyPatch):
79-
"""Should return one of the npx candidates on Windows."""
80+
"""Should return one of the npx candidates on Windows via shutil.which()."""
8081
candidates = ["npx.cmd", "npx.exe", "npx"]
8182

82-
def fake_run(cmd: list[str], **kw: Any) -> subprocess.CompletedProcess[bytes]:
83-
if cmd[0] in candidates:
84-
return subprocess.CompletedProcess(cmd, 0)
85-
else: # pragma: no cover
86-
raise subprocess.CalledProcessError(1, cmd[0])
83+
def fake_which(cmd: str) -> str | None:
84+
if cmd in candidates:
85+
return f"/fake/path/{cmd}"
86+
return None # pragma: no cover
8787

8888
monkeypatch.setattr(sys, "platform", "win32")
89-
monkeypatch.setattr(subprocess, "run", fake_run)
89+
monkeypatch.setattr(shutil, "which", fake_which)
9090
assert _get_npx_command() in candidates
9191

9292

9393
def test_get_npx_returns_none_when_npx_missing(monkeypatch: pytest.MonkeyPatch):
94-
"""Should give None if every candidate fails."""
94+
"""Should give None if every candidate is absent from PATH."""
9595
monkeypatch.setattr(sys, "platform", "win32", raising=False)
96-
97-
def always_fail(*args: Any, **kwargs: Any) -> subprocess.CompletedProcess[bytes]:
98-
raise subprocess.CalledProcessError(1, args[0])
99-
100-
monkeypatch.setattr(subprocess, "run", always_fail)
96+
monkeypatch.setattr(shutil, "which", lambda cmd: None)
10197
assert _get_npx_command() is None

0 commit comments

Comments
 (0)