diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-06-27 01:25:29 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-06-27 01:25:29 +0900 |
| commit | 59a974236a168680d5de0bd570b597a582b3859a (patch) | |
| tree | 6aa2347ee8409b70c78bd64d2350c1646e405ab8 | |
| parent | fce9dc1b2940ee6699d2acf0c9e56456d9899581 (diff) | |
MCP server機能のセキュア化によるcwdの不整合を除去。
| -rw-r--r-- | README.md | 11 | ||||
| -rw-r--r-- | src/ollama_web/mcp.py | 69 | ||||
| -rw-r--r-- | tests/test_mcp_security_diagnostics.py | 118 |
3 files changed, 34 insertions, 164 deletions
@@ -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: |
