Compare commits

...

3 commits

Author SHA1 Message Date
Jeremiah Lowin
3465166106
fix: use raw strings for regex in pytest.raises match 2026-03-15 14:56:51 -04:00
Jeremiah Lowin
a40e6ad2f3
Scope validation to shell-backed install paths only 2026-03-15 14:34:02 -04:00
Jeremiah Lowin
cc2e3ab11f
fix: validate server names in install commands 2026-03-15 14:24:25 -04:00
5 changed files with 66 additions and 4 deletions

View file

@ -12,7 +12,7 @@ from rich import print
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
from .shared import process_common_args
from .shared import process_common_args, validate_server_name
logger = get_logger(__name__)
@ -124,6 +124,8 @@ def install_claude_code(
# Build the full command
full_command = env_config.build_command(["fastmcp", "run", server_spec])
validate_server_name(name)
# Build claude mcp add command
cmd_parts = [claude_cmd, "mcp", "add", name]

View file

@ -12,7 +12,7 @@ from rich import print
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
from .shared import process_common_args
from .shared import process_common_args, validate_server_name
logger = get_logger(__name__)
@ -129,6 +129,8 @@ def install_gemini_cli(
for key, value in env_vars.items():
cmd_parts.extend(["-e", f"{key}={value}"])
validate_server_name(name)
# Add server name and command
cmd_parts.extend([name, full_command[0], "--"])
cmd_parts.extend(full_command[1:])

View file

@ -2,6 +2,7 @@
import json
import os
import re
import subprocess
import sys
from pathlib import Path
@ -17,6 +18,26 @@ from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystem
logger = get_logger(__name__)
# Server names are passed as subprocess arguments to CLI tools like `claude`
# and `gemini`. On Windows these may resolve to .cmd/.bat wrappers that run
# through cmd.exe, where shell metacharacters (& | ; etc.) in arguments can
# cause command injection. Restrict names to safe characters.
_SAFE_NAME_RE = re.compile(r"^[\w\-. ]+$")
def validate_server_name(name: str) -> str:
"""Validate that a server name is safe for use as a subprocess argument.
Raises SystemExit if the name contains shell metacharacters.
"""
if not _SAFE_NAME_RE.match(name):
print(
f"[red]Invalid server name '[bold]{name}[/bold]': "
"names may only contain letters, numbers, hyphens, underscores, dots, and spaces.[/red]"
)
sys.exit(1)
return name
def parse_env_var(env_var: str) -> tuple[str, str]:
"""Parse environment variable string in format KEY=VALUE."""

View file

@ -1,6 +1,9 @@
from pathlib import Path
import pytest
from fastmcp.cli.install import install_app
from fastmcp.cli.install.shared import validate_server_name
from fastmcp.cli.install.stdio import install_stdio
@ -455,3 +458,37 @@ class TestInstallCommandParsing:
command, bound, _ = install_app.parse_args(cmd_args)
assert command is not None
assert str(bound.arguments["project"]) == str(Path("/path/to/project"))
class TestServerNameValidation:
"""Test server name validation rejects shell metacharacters."""
@pytest.mark.parametrize(
"name",
[
"my-server",
"my_server",
"My Server",
"server.v2",
"test123",
],
)
def test_valid_names(self, name: str):
assert validate_server_name(name) == name
@pytest.mark.parametrize(
"name",
[
"test&calc",
"test|whoami",
"test;ls",
"test$(id)",
"test`id`",
'test"quoted',
"test>file",
"test<file",
],
)
def test_rejects_shell_metacharacters(self, name: str):
with pytest.raises(SystemExit):
validate_server_name(name)

View file

@ -428,13 +428,13 @@ async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool):
async def test_forward_outside_context_raises_error():
"""Test that forward() raises error when called outside transform context."""
with pytest.raises(RuntimeError, match="forward\(\) can only be called"):
with pytest.raises(RuntimeError, match=r"forward\(\) can only be called"):
await forward(x=1)
async def test_forward_raw_outside_context_raises_error():
"""Test that forward_raw() raises error when called outside transform context."""
with pytest.raises(RuntimeError, match="forward_raw\(\) can only be called"):
with pytest.raises(RuntimeError, match=r"forward_raw\(\) can only be called"):
await forward_raw(x=1)