summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-06-27 01:25:29 +0900
committertanaka takashi <isazaka@gmail.com>2026-06-27 01:25:29 +0900
commit59a974236a168680d5de0bd570b597a582b3859a (patch)
tree6aa2347ee8409b70c78bd64d2350c1646e405ab8
parentfce9dc1b2940ee6699d2acf0c9e56456d9899581 (diff)
MCP server機能のセキュア化によるcwdの不整合を除去。
-rw-r--r--README.md11
-rw-r--r--src/ollama_web/mcp.py69
-rw-r--r--tests/test_mcp_security_diagnostics.py118
3 files changed, 34 insertions, 164 deletions
diff --git a/README.md b/README.md
index b91efb0..d22c2e8 100644
--- a/README.md
+++ b/README.md
@@ -102,17 +102,15 @@ ollama-web
"mcpServers": {
"calc": {
"command": "C:\\Users\\you\\src\\python\\ollama-web\\.venv\\Scripts\\python.exe",
- "args": ["mcp_servers/calc_server.py"]
+ "args": ["scripts/calc_server.py"]
}
}
}
```
-`OLLAMA_WEB_MCP_STDIO_ALLOWLIST` で許可するのは、`args` に渡すスクリプトではなく `command` の実行ファイルです。たとえば `python.exe` で `data/mcp_servers/my_server.py` を起動する場合も、allowlist へ登録するのは `python.exe` の絶対パスです。
+`OLLAMA_WEB_MCP_STDIO_ALLOWLIST` で許可するのは、`args` に渡すスクリプトではなく `command` の実行ファイルです。たとえば `python.exe` で `scripts/my_server.py` を起動する場合も、allowlist へ登録するのは `python.exe` の絶対パスです。
-MCP server プログラム本体は `OLLAMA_WEB_DATA_DIR` 配下に配置してください。既定では `data/mcp_servers/` などです。`cwd` 未指定時は `OLLAMA_WEB_DATA_DIR` が作業ディレクトリになるため、上の例の `args` は `data/mcp_servers/calc_server.py` を指します。
-
-`cwd` を明示する場合も、`OLLAMA_WEB_DATA_DIR` 配下のみ指定できます。`scripts/` 配下のファイルは開発・動作確認用のサンプルです。実際のMCP設定では、必要なサーバープログラムを `data/mcp_servers/` などへ配置してから指定してください。
+`cwd` 未指定時は ollama-webの起動パスが作業ディレクトリとなります。
#### Streamable HTTP MCP を使う場合
@@ -162,8 +160,7 @@ Tool 名に `delete`、`write`、`run`、`shell`、`secret` など危険操作ã‚
MCP 設定 API の GET 応答では、`env` / `headers` 内の secret らしい値は `***` にマスクされます。Tool の実行結果やエラーも LLM へ渡す前に不信データとして隔離されます。
-`scripts/` 以下には動作確認用の MCP サーバーが用意されています。実際にWeb UIからstdio MCPとして登録する場合は、使いたいサーバープログラムを `data/mcp_servers/` など `OLLAMA_WEB_DATA_DIR` 配下へ配置してから設定してください。
-次のコマンドは開発者がターミナルで直接動かす確認用です。`scripts/` 配下のファイルをそのまま `mcpServers.json` のstdio設定に指定しても、Web UIからは起動できません。
+`scripts/` 以下には動作確認用の MCP サーバーが用意されています。
```bash
# stdio モードで起動
diff --git a/src/ollama_web/mcp.py b/src/ollama_web/mcp.py
index aa3d202..3437a62 100644
--- a/src/ollama_web/mcp.py
+++ b/src/ollama_web/mcp.py
@@ -38,8 +38,6 @@ _DANGEROUS_TOOL_WORDS = (
"token",
"secret",
)
-_STDIO_INLINE_CODE_FLAGS = {"-c", "-m", "-e", "--eval"}
-_STDIO_SCRIPT_SUFFIXES = {".py", ".js", ".mjs", ".cjs", ".ts"}
_SECRET_KEY_RE = re.compile(r"(?i)(authorization|api[_-]?key|token|secret|cookie|session)")
_SECRET_VALUE_PATTERNS = (
re.compile(r"(?i)Bearer\s+[A-Za-z0-9._~+/=-]+"),
@@ -139,61 +137,28 @@ def _allowed_https_hosts() -> set[str]:
return {host.lower() for host in settings.mcp_https_allowlist if host}
-def validate_stdio_server(server: dict[str, Any], data_dir: str | Path | None = None) -> None:
- """Validate stdio MCP config before saving or launching."""
+def validate_stdio_server(server: dict[str, Any], _data_dir: str | Path | None = None) -> None:
+ """Validate stdio MCP config before saving or launching.
+
+ Only the executable command itself is restricted by the allowlist. ``cwd``
+ and ``args`` follow the standard MCP stdio shape without additional path
+ sandboxing, so arbitrary working directories and script/argument paths can
+ be used.
+ """
command = Path(str(server.get("command", ""))).resolve()
if str(command) not in _allowed_stdio_commands():
raise ValueError("stdio MCP command is not allowlisted")
- root = Path(data_dir or settings.data_dir).resolve()
- cwd_path = _resolve_stdio_cwd(server.get("cwd"), root)
- _validate_stdio_args(server.get("args", []), cwd_path, root)
+def _resolve_stdio_cwd(cwd: Any) -> str | None:
+ """Return a stdio working directory path.
-def _resolve_stdio_cwd(cwd: Any, root: Path | None = None) -> Path:
- data_root = (root or Path(settings.data_dir)).resolve()
+ ``None`` means the subprocess should use the current process working
+ directory, matching common MCP stdio behaviour.
+ """
if not cwd:
- return data_root
-
- path = Path(str(cwd))
- cwd_path = path.resolve() if path.is_absolute() else (data_root / path).resolve()
- try:
- cwd_path.relative_to(data_root)
- except ValueError as exc:
- raise ValueError("stdio MCP cwd must stay inside the data directory") from exc
- return cwd_path
-
-
-def _validate_stdio_args(args: Any, cwd: Path, root: Path) -> None:
- if not isinstance(args, list):
- raise ValueError("stdio MCP args must be a list")
-
- for raw_arg in args:
- arg = str(raw_arg)
- if arg in _STDIO_INLINE_CODE_FLAGS:
- raise ValueError(f"stdio MCP inline/module execution is not allowed: {arg}")
- if _looks_like_local_script_arg(arg):
- target = Path(arg)
- if not target.is_absolute():
- target = cwd / target
- try:
- target.resolve().relative_to(root)
- except ValueError as exc:
- raise ValueError(
- "stdio MCP script arguments must stay inside the data directory"
- ) from exc
-
-
-def _looks_like_local_script_arg(arg: str) -> bool:
- if "://" in arg or arg.startswith("-"):
- return False
- path = Path(arg)
- return (
- path.suffix.lower() in _STDIO_SCRIPT_SUFFIXES
- or "/" in arg
- or "\\" in arg
- or path.is_absolute()
- )
+ return None
+ return str(Path(str(cwd)).resolve())
def _is_local_http_host(host: str | None) -> bool:
@@ -402,7 +367,7 @@ async def _list_tools_stdio(
command = str(server["command"])
args = [str(a) for a in server.get("args", [])]
env = server.get("env")
- cwd = str(_resolve_stdio_cwd(server.get("cwd")))
+ cwd = _resolve_stdio_cwd(server.get("cwd"))
params = StdioServerParameters(
command=command,
@@ -535,7 +500,7 @@ async def _call_tool_stdio(
command = str(server["command"])
args = [str(a) for a in server.get("args", [])]
env = server.get("env")
- cwd = str(_resolve_stdio_cwd(server.get("cwd")))
+ cwd = _resolve_stdio_cwd(server.get("cwd"))
params = StdioServerParameters(
command=command,
diff --git a/tests/test_mcp_security_diagnostics.py b/tests/test_mcp_security_diagnostics.py
index 2461119..1fa15bd 100644
--- a/tests/test_mcp_security_diagnostics.py
+++ b/tests/test_mcp_security_diagnostics.py
@@ -108,111 +108,17 @@ def test_diagnostic_collect_mcp_tools_launches_configured_stdio_process() -> Non
assert not marker.exists()
-def test_diagnostic_stdio_rejects_script_args_outside_data_dir(
- client: TestClient,
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- monkeypatch.setattr(settings, "mcp_stdio_allowlist", [sys.executable])
- repo_script = Path("scripts/calc_server.py").resolve()
- payload = {
- "mcpServers": {
- "repo_script": {
- "command": sys.executable,
- "args": [str(repo_script)],
- }
- }
- }
-
- res = client.put(
- "/api/mcp/servers",
- json=payload,
- headers={"X-CSRF-Token": _csrf(client)},
- )
-
- assert res.status_code == 400
- assert "script arguments must stay inside the data directory" in json.dumps(res.json())
-
-
-def test_diagnostic_stdio_allows_script_args_inside_data_dir(
- client: TestClient,
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- monkeypatch.setattr(settings, "mcp_stdio_allowlist", [sys.executable])
- payload = {
- "mcpServers": {
- "data_script": {
- "command": sys.executable,
- "args": ["mcp_servers/calc_server.py"],
- }
- }
- }
-
- res = client.put(
- "/api/mcp/servers",
- json=payload,
- headers={"X-CSRF-Token": _csrf(client)},
- )
-
- assert res.status_code == 200
-
-
-def test_diagnostic_stdio_defaults_cwd_to_data_dir(
- client: TestClient,
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- monkeypatch.setattr(settings, "mcp_stdio_allowlist", [sys.executable])
- payload = {
- "mcpServers": {
- "data_script": {
- "command": sys.executable,
- "args": ["mcp_servers/calc_server.py"],
- }
- }
- }
-
- res = client.put(
- "/api/mcp/servers",
- json=payload,
- headers={"X-CSRF-Token": _csrf(client)},
- )
-
- assert res.status_code == 200
-
-
-def test_diagnostic_stdio_resolves_relative_cwd_from_data_dir(
- client: TestClient,
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- monkeypatch.setattr(settings, "mcp_stdio_allowlist", [sys.executable])
- payload = {
- "mcpServers": {
- "nested_data_script": {
- "command": sys.executable,
- "args": ["calc_server.py"],
- "cwd": "mcp_servers",
- }
- }
- }
-
- res = client.put(
- "/api/mcp/servers",
- json=payload,
- headers={"X-CSRF-Token": _csrf(client)},
- )
-
- assert res.status_code == 200
-
-
-def test_diagnostic_stdio_rejects_relative_cwd_outside_data_dir(
+def test_diagnostic_stdio_accepts_arbitrary_cwd_and_args(
client: TestClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
+ """stdio cwd and args are not sandboxed; only the command allowlist matters."""
monkeypatch.setattr(settings, "mcp_stdio_allowlist", [sys.executable])
payload = {
"mcpServers": {
"outside": {
"command": sys.executable,
- "args": ["calc_server.py"],
+ "args": ["-c", "print('not an mcp server file')"],
"cwd": "..",
}
}
@@ -224,21 +130,23 @@ def test_diagnostic_stdio_rejects_relative_cwd_outside_data_dir(
headers={"X-CSRF-Token": _csrf(client)},
)
- assert res.status_code == 400
- assert "cwd must stay inside the data directory" in json.dumps(res.json())
+ assert res.status_code == 200
+ server = res.json()["mcpServers"]["outside"]
+ assert server["cwd"] == ".."
+ assert server["args"] == ["-c", "print('not an mcp server file')"]
-def test_diagnostic_stdio_rejects_inline_python_execution(
+def test_diagnostic_stdio_omits_cwd_when_not_configured(
client: TestClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
+ """If cwd is absent it is not forced to the data directory."""
monkeypatch.setattr(settings, "mcp_stdio_allowlist", [sys.executable])
payload = {
"mcpServers": {
- "inline_code": {
+ "no_cwd": {
"command": sys.executable,
- "args": ["-c", "print('not an mcp server file')"],
- "cwd": settings.data_dir,
+ "args": ["scripts/calc_server.py"],
}
}
}
@@ -249,8 +157,8 @@ def test_diagnostic_stdio_rejects_inline_python_execution(
headers={"X-CSRF-Token": _csrf(client)},
)
- assert res.status_code == 400
- assert "inline/module execution is not allowed" in json.dumps(res.json())
+ assert res.status_code == 200
+ assert "cwd" not in res.json()["mcpServers"]["no_cwd"]
def test_diagnostic_mcp_tool_metadata_is_limited_before_ollama() -> None: