From 02bf829a7a001d2a0a167229f291431c232e4e0c Mon Sep 17 00:00:00 2001 From: Ovtcharov Date: Wed, 5 Aug 2026 14:37:24 -0700 Subject: [PATCH] fix(mcp): enforce --auth-token on the MCP bridge instead of ignoring it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gaia mcp start --auth-token ` printed "Authentication enabled" and then dropped the token: it was never passed to `start_server`, and no request handler looked at the Authorization header. Every endpoint answered 200 identically with no token, the right token, or a wrong one, so anyone who exposed the bridge past loopback had an open tool-invocation endpoint while believing it was protected. `docs/reference/cli.mdx` published the guarantee too ("require Authorization: Bearer on every request"), so the false assurance was documented, not merely implied. The flag has been inert since v0.11.0. Thread the token from the CLI into the bridge and enforce it in the handler: 401 for a missing or malformed Authorization header, 403 for a wrong token, compared with `secrets.compare_digest` so the check is constant-time. The gate runs before the request body is read and before any tool dispatches. `/health` stays public — liveness probes and `gaia mcp status` depend on it, and it exposes only counts, never agent or tool names. The token is also no longer passed on the child's command line in `--background` mode, where `ps` made it readable by any local user; it goes through `GAIA_MCP_AUTH_TOKEN`, which is now the documented way to supply it. The `mcp status` / `test` / `agent` client commands gained `--auth-token` so they can still reach a protected bridge, and report an actionable message on 401/403. Default behaviour is unchanged: with no token configured the bridge stays open and the wildcard-bind warning still fires. --- docs/reference/cli.mdx | 42 +++- src/gaia/cli.py | 104 ++++++++-- src/gaia/mcp/mcp_bridge.py | 145 ++++++++++++-- tests/unit/test_mcp_bridge_auth.py | 296 +++++++++++++++++++++++++++++ tests/unit/test_mcp_bridge_bind.py | 13 +- 5 files changed, 566 insertions(+), 34 deletions(-) create mode 100644 tests/unit/test_mcp_bridge_auth.py diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 30c2a8a3a..5462623a6 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -1517,19 +1517,59 @@ gaia mcp test --query "Hello from GAIA MCP!" |--------|---------|-------------| | `--host` | `localhost` | Bind address | | `--port` | `8765` | Port for the MCP bridge | -| `--auth-token` | _none_ | If set, require `Authorization: Bearer ` on every request | +| `--auth-token` | `$GAIA_MCP_AUTH_TOKEN` | Require `Authorization: Bearer ` on every request except `/health` | | `--no-streaming` | off | Disable SSE streaming; reply synchronously | | `--background` | off | Detach; use `gaia mcp stop` to terminate | | `--log-file` | stdout | Write logs to this path (useful with `--background`) | | `--verbose` | off | Verbose logging | | `--ctx-size` | `32768` | Context window hint passed to Lemonade for loaded models | +##### Authentication + +The bridge is **unauthenticated by default**. Anything that can reach the port +can enumerate and invoke its tools, which is why `--host` defaults to +`localhost`. Pass `--auth-token` before exposing it anywhere else: + +```bash +export GAIA_MCP_AUTH_TOKEN=$(python -c "import secrets; print(secrets.token_urlsafe(32))") +gaia mcp start --host 0.0.0.0 --port 8765 +``` + +Prefer the environment variable over `--auth-token `: on Linux and macOS +command-line arguments are visible to any local user via `ps`. + +Clients then send the token on every call: + +```bash +curl -H "Authorization: Bearer $GAIA_MCP_AUTH_TOKEN" http://localhost:8765/status +``` + +| Request | Response | +|---------|----------| +| Valid `Bearer` token | Normal response | +| Missing or malformed `Authorization` header | `401` | +| Well-formed header, wrong token | `403` | + +`GET /health` stays public so container and orchestrator liveness probes keep +working; it reports only liveness plus agent/tool **counts**. Every other +endpoint — `/status`, `/tools`, `/chat`, `/llm`, `/jira`, `/summarize`, and the +JSON-RPC endpoint — requires the token. `gaia mcp status`, `gaia mcp test`, and +`gaia mcp agent` accept `--auth-token` (also defaulting to +`$GAIA_MCP_AUTH_TOKEN`) so they can reach a protected bridge. + + +The bridge speaks plain HTTP. A bearer token stops anonymous callers but does +not encrypt traffic — put it behind a TLS-terminating reverse proxy, or keep it +on loopback, before sending a token across an untrusted network. + + #### `gaia mcp test` options | Option | Default | Description | |--------|---------|-------------| | `--query` | _required_ | Text to send to the server | | `--tool` | `gaia.chat` | Which MCP tool to invoke when testing | +| `--auth-token` | `$GAIA_MCP_AUTH_TOKEN` | Bearer token, if the bridge requires one | #### `gaia mcp agent` diff --git a/src/gaia/cli.py b/src/gaia/cli.py index 719943a95..46877b5b6 100644 --- a/src/gaia/cli.py +++ b/src/gaia/cli.py @@ -2748,7 +2748,12 @@ def build_parser(): ) # Note: --base-url is inherited from parent_parser mcp_start_parser.add_argument( - "--auth-token", help="Optional authentication token for secure connections" + "--auth-token", + help=( + "Require 'Authorization: Bearer ' on every request except " + "/health. Defaults to $GAIA_MCP_AUTH_TOKEN. Without it the bridge " + "is unauthenticated." + ), ) mcp_start_parser.add_argument( "--no-streaming", action="store_true", help="Disable streaming responses" @@ -2783,6 +2788,10 @@ def build_parser(): mcp_status_parser.add_argument( "--port", type=int, default=8765, help="Port to check (default: 8765)" ) + mcp_status_parser.add_argument( + "--auth-token", + help="Bearer token if the bridge requires one (default: $GAIA_MCP_AUTH_TOKEN)", + ) # MCP stop command _ = mcp_subparsers.add_parser("stop", help="Stop background MCP bridge server") @@ -2803,6 +2812,10 @@ def build_parser(): mcp_test_parser.add_argument( "--tool", default="gaia.chat", help="Tool to test (default: gaia.chat)" ) + mcp_test_parser.add_argument( + "--auth-token", + help="Bearer token if the bridge requires one (default: $GAIA_MCP_AUTH_TOKEN)", + ) # MCP agent command mcp_agent_parser = mcp_subparsers.add_parser( @@ -2825,6 +2838,10 @@ def build_parser(): mcp_agent_parser.add_argument( "--context", help="Optional additional context about the request" ) + mcp_agent_parser.add_argument( + "--auth-token", + help="Bearer token if the bridge requires one (default: $GAIA_MCP_AUTH_TOKEN)", + ) # MCP Docker command (per-agent MCP server) mcp_docker_parser = mcp_subparsers.add_parser( @@ -8137,6 +8154,18 @@ def handle_mcp_command(args): print(f"❌ Unknown MCP action: {args.mcp_action}") +# Kept in sync with gaia.mcp.mcp_bridge.AUTH_TOKEN_ENV_VAR by +# tests/unit/test_mcp_bridge_auth.py — duplicated here so the client-side +# `mcp status` / `mcp test` paths don't have to import the heavy bridge module. +MCP_AUTH_TOKEN_ENV = "GAIA_MCP_AUTH_TOKEN" + + +def _mcp_auth_headers(args): + """Bearer headers for reaching a token-protected MCP bridge, else {}.""" + token = getattr(args, "auth_token", None) or os.environ.get(MCP_AUTH_TOKEN_ENV) + return {"Authorization": f"Bearer {token}"} if token else {} + + def handle_mcp_start(args): """Start the MCP bridge server (HTTP-native implementation).""" log = get_logger(__name__) @@ -8203,8 +8232,6 @@ def handle_mcp_start(args): # Add optional arguments if provided if args.base_url: cmd_args.extend(["--base-url", args.base_url]) - if args.auth_token: - cmd_args.extend(["--auth-token", args.auth_token]) if args.no_streaming: cmd_args.append("--no-streaming") if getattr(args, "verbose", False): @@ -8212,9 +8239,20 @@ def handle_mcp_start(args): if getattr(args, "no_lemonade_check", False): cmd_args.append("--no-lemonade-check") + # Hand the token over the environment, not argv — argv is world + # readable via `ps` on Linux/macOS. + child_env = os.environ.copy() + bg_token = args.auth_token or os.environ.get(MCP_AUTH_TOKEN_ENV) or None + if bg_token: + child_env[MCP_AUTH_TOKEN_ENV] = bg_token + print("🚀 Starting GAIA MCP Bridge in background") print(f"📍 Host: {args.host}:{args.port}") print(f"📄 Log file: {log_file_path}") + if bg_token: + print("🔒 Authentication enabled (Bearer token required)") + else: + print("🔓 Authentication disabled - pass --auth-token to require one") # Write initial banner BEFORE starting subprocess (prevents truncation issues) import datetime @@ -8244,6 +8282,7 @@ def handle_mcp_start(args): stderr=subprocess.STDOUT, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, cwd=os.getcwd(), + env=child_env, text=True, ) else: @@ -8255,6 +8294,7 @@ def handle_mcp_start(args): stderr=subprocess.STDOUT, start_new_session=True, cwd=os.getcwd(), + env=child_env, text=True, ) except Exception: @@ -8284,8 +8324,11 @@ def handle_mcp_start(args): log.info("Starting GAIA MCP Bridge on %s:%s", args.host, args.port) print(f"🚀 Starting GAIA MCP Bridge on {args.host}:{args.port}") - if args.auth_token: - print("🔒 Authentication enabled") + auth_token = args.auth_token or os.environ.get(MCP_AUTH_TOKEN_ENV) or None + if auth_token: + print("🔒 Authentication enabled (Bearer token required; /health public)") + else: + print("🔓 Authentication disabled - pass --auth-token to require one") print(f"🔗 GAIA LLM server: {args.base_url}") print(f"📡 Streaming: {'disabled' if args.no_streaming else 'enabled'}") @@ -8297,7 +8340,11 @@ def handle_mcp_start(args): # Start HTTP-native MCP bridge verbose = getattr(args, "verbose", False) start_mcp_http( - host=args.host, port=args.port, base_url=args.base_url, verbose=verbose + host=args.host, + port=args.port, + base_url=args.base_url, + verbose=verbose, + auth_token=auth_token, ) except KeyboardInterrupt: @@ -8396,8 +8443,12 @@ def handle_mcp_status(args): # First try the new /status endpoint status_url = f"http://{args.host}:{args.port}/status" + auth_headers = _mcp_auth_headers(args) try: - with urllib.request.urlopen(status_url, timeout=3) as response: + status_req = urllib.request.Request( + status_url, headers=auth_headers + ) + with urllib.request.urlopen(status_req, timeout=3) as response: data = json.loads(response.read().decode()) if data.get("status") == "healthy": @@ -8440,6 +8491,13 @@ def handle_mcp_status(args): else: print("⚠️ Server is running but may not be healthy") except urllib.error.HTTPError as e: + if e.code in (401, 403): + print("🔒 MCP server requires authentication") + print( + " Pass --auth-token or set " + f"{MCP_AUTH_TOKEN_ENV} to inspect it" + ) + return if e.code == 404: # Fall back to /health for older versions health_url = f"http://{args.host}:{args.port}/health" @@ -8514,7 +8572,12 @@ def handle_mcp_test(args): url = f"http://{args.host}:{args.port}/" data = json.dumps(rpc_request).encode("utf-8") req = urllib.request.Request( - url, data=data, headers={"Content-Type": "application/json"} + url, + data=data, + headers={ + "Content-Type": "application/json", + **_mcp_auth_headers(args), + }, ) with urllib.request.urlopen(req, timeout=30) as response: @@ -8543,7 +8606,14 @@ def handle_mcp_test(args): print("❌ Unexpected response format") except urllib.error.HTTPError as e: - print(f"❌ HTTP Error: {e.code} {e.reason}") + if e.code in (401, 403): + print(f"🔒 MCP server rejected the request ({e.code})") + print( + " The bridge was started with --auth-token. Pass the same " + f"token via --auth-token, or set {MCP_AUTH_TOKEN_ENV}." + ) + else: + print(f"❌ HTTP Error: {e.code} {e.reason}") except urllib.error.URLError as e: print(f"❌ Connection error: {e.reason}") except json.JSONDecodeError as e: @@ -8603,7 +8673,12 @@ def handle_mcp_agent(args): url = f"http://{args.host}:{args.port}/" data = json.dumps(rpc_request).encode("utf-8") req = urllib.request.Request( - url, data=data, headers={"Content-Type": "application/json"} + url, + data=data, + headers={ + "Content-Type": "application/json", + **_mcp_auth_headers(args), + }, ) print("🔄 Agent is analyzing request and orchestrating tools...") @@ -8661,7 +8736,14 @@ def handle_mcp_agent(args): print("❌ Unexpected response format") except urllib.error.HTTPError as e: - print(f"❌ HTTP Error: {e.code} {e.reason}") + if e.code in (401, 403): + print(f"🔒 MCP server rejected the request ({e.code})") + print( + " The bridge was started with --auth-token. Pass the same " + f"token via --auth-token, or set {MCP_AUTH_TOKEN_ENV}." + ) + else: + print(f"❌ HTTP Error: {e.code} {e.reason}") except urllib.error.URLError as e: print(f"❌ Connection error: {e.reason}") except json.JSONDecodeError as e: diff --git a/src/gaia/mcp/mcp_bridge.py b/src/gaia/mcp/mcp_bridge.py index d8aee7c0f..65a77b2c4 100644 --- a/src/gaia/mcp/mcp_bridge.py +++ b/src/gaia/mcp/mcp_bridge.py @@ -11,6 +11,7 @@ import io import json import os +import secrets import shutil import sys import tempfile @@ -39,6 +40,17 @@ # Global verbose flag for request logging VERBOSE = False +# Environment variable used to hand the bridge its auth token without exposing +# it in the process command line. +AUTH_TOKEN_ENV_VAR = "GAIA_MCP_AUTH_TOKEN" + +# Paths reachable without credentials even when a token is configured. /health +# returns only liveness plus agent/tool counts, so orchestrator probes and +# `gaia mcp status` keep working. Matching is exact — every other path, +# including unknown ones, is authenticated. (CORS preflight is also exempt, but +# via do_OPTIONS: browsers never send Authorization on a preflight.) +PUBLIC_PATHS = frozenset({"/health"}) + class MultipartCollector: def __init__(self): @@ -122,10 +134,12 @@ def __init__( port: int = 8765, base_url: str = None, verbose: bool = False, + auth_token: str = None, ): self.host = host self.port = port self.base_url = base_url or "http://localhost:13305/api/v1" + self.auth_token = auth_token or None self.agents = {} self.tools = {} self.llm_client = None @@ -487,11 +501,76 @@ def log_request_details(self, method, path, body=None): if body: logger.debug(f"Request body: {json.dumps(body, indent=2)}") + def _check_auth(self): + """Classify the request's credentials. + + Returns ``None`` when the request may proceed, otherwise an + ``(http_status, message)`` pair to send back. + """ + if not self.bridge.auth_token: + return None + + header = self.headers.get("Authorization", "") + if not header: + return 401, "Missing Authorization header. Expected: Bearer " + + scheme, _, presented = header.partition(" ") + if scheme.lower() != "bearer" or not presented: + return 401, "Malformed Authorization header. Expected: Bearer " + + # compare_digest keeps the check constant-time so a network caller + # can't recover the token byte-by-byte from response timing. Compare as + # bytes — the str form rejects non-ASCII input with a TypeError. + if not secrets.compare_digest( + presented.encode("utf-8", "surrogateescape"), + self.bridge.auth_token.encode("utf-8", "surrogateescape"), + ): + return 403, "Invalid authentication token" + + return None + + def _drain_request_body(self): + """Consume any pending request body so the client can read our reply.""" + try: + length = int(self.headers.get("Content-Length", 0)) + except (TypeError, ValueError): + return + while length > 0: + chunk = self.rfile.read(min(length, 65536)) + if not chunk: + break + length -= len(chunk) + + def _reject_unauthenticated(self, path): + """Send 401/403 for a credential-less request. True when rejected.""" + if path in PUBLIC_PATHS: + return False + + failure = self._check_auth() + if failure is None: + return False + + status, message = failure + client_addr = self.client_address[0] if self.client_address else "unknown" + logger.warning( + "Rejected unauthenticated MCP request: %s %s from %s (%s)", + self.command, + path, + client_addr, + message, + ) + self._drain_request_body() + self.send_json(status, {"error": message}) + return True + def do_GET(self): """Handle GET requests.""" self.log_request_details("GET", self.path) parsed = urlparse(self.path) + if self._reject_unauthenticated(parsed.path): + return + if parsed.path == "/health": self.send_json( 200, @@ -548,9 +627,13 @@ def do_GET(self): def do_POST(self): """Handle POST requests - main MCP endpoint.""" - content_length = int(self.headers.get("Content-Length", 0)) - parsed = urlparse(self.path) + + # Authenticate before the body is read or any tool runs. + if self._reject_unauthenticated(parsed.path): + return + + content_length = int(self.headers.get("Content-Length", 0)) ctype = self.headers.get("content-type", "") if ctype.startswith("application/json") and content_length > 0: @@ -688,7 +771,7 @@ def do_OPTIONS(self): self.send_response(200) self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type") self.end_headers() def send_sse_headers(self): @@ -736,39 +819,50 @@ def log_message(self, format, *args): super().log_message(format, *args) -def resolve_bind_host(host): +def resolve_bind_host(host, authenticated=False): """Map the requested host to the address the socket actually binds. - The bridge is unauthenticated, so "localhost" must never widen beyond - loopback. On non-Windows it resolves to 127.0.0.1 (Python may otherwise - bind ::1, which curl can't reach by default). Binding all interfaces - requires the caller to pass a wildcard address explicitly, and is loudly - logged because it exposes the bridge to the whole network. + "localhost" must never widen beyond loopback. On non-Windows it resolves to + 127.0.0.1 (Python may otherwise bind ::1, which curl can't reach by + default). Binding all interfaces requires the caller to pass a wildcard + address explicitly, and is logged — as a warning when no auth token is + configured, since then anyone on the network can invoke the bridge's tools. """ if host == "localhost" and sys.platform != "win32": return "127.0.0.1" if host in ("0.0.0.0", "::"): # nosec B104 - explicit caller opt-in only - logger.warning( - "MCP bridge binding to ALL network interfaces (%s). The bridge is " - "UNAUTHENTICATED - anyone on the network can invoke its tools. " - "Use --host localhost unless network exposure is intentional.", - host, - ) + if authenticated: + logger.info( + "MCP bridge binding to ALL network interfaces (%s) with " + "authentication enabled.", + host, + ) + else: + logger.warning( + "MCP bridge binding to ALL network interfaces (%s). The bridge is " + "UNAUTHENTICATED - anyone on the network can invoke its tools. " + "Pass --auth-token, or use --host localhost unless network " + "exposure is intentional.", + host, + ) return host -def start_server(host="localhost", port=8765, base_url=None, verbose=False): +def start_server( + host="localhost", port=8765, base_url=None, verbose=False, auth_token=None +): """Start the HTTP MCP server.""" # Fix Windows Unicode if sys.platform == "win32": sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") - bind_host = resolve_bind_host(host) + auth_token = auth_token or os.environ.get(AUTH_TOKEN_ENV_VAR) or None + bind_host = resolve_bind_host(host, authenticated=bool(auth_token)) logger.info(f"Creating MCP bridge for {host}:{port}") # Create bridge with verbose flag - bridge = GAIAMCPBridge(host, port, base_url, verbose=verbose) + bridge = GAIAMCPBridge(host, port, base_url, verbose=verbose, auth_token=auth_token) # Create handler with bridge def handler(*args, **kwargs): @@ -792,6 +886,10 @@ def handler(*args, **kwargs): print(f"LLM Backend: {bridge.base_url}") print(f"Agents: {list(bridge.agents.keys())}") print(f"Tools: {list(bridge.tools.keys())}") + if bridge.auth_token: + print("Auth: 🔒 Bearer token required (/health stays public)") + else: + print("Auth: ⚠️ none - every endpoint is open to any client that can reach it") if verbose: print("\n🔍 Verbose Mode: ENABLED") print(" All requests will be logged to console and gaia.log") @@ -837,9 +935,18 @@ def main(): parser.add_argument( "--verbose", action="store_true", help="Enable verbose logging for all requests" ) + parser.add_argument( + "--auth-token", + help=( + "Require 'Authorization: Bearer ' on every request except " + f"/health. Defaults to ${AUTH_TOKEN_ENV_VAR}." + ), + ) args = parser.parse_args() - start_server(args.host, args.port, args.base_url, args.verbose) + start_server( + args.host, args.port, args.base_url, args.verbose, auth_token=args.auth_token + ) if __name__ == "__main__": diff --git a/tests/unit/test_mcp_bridge_auth.py b/tests/unit/test_mcp_bridge_auth.py new file mode 100644 index 000000000..1ef69f3ad --- /dev/null +++ b/tests/unit/test_mcp_bridge_auth.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python +# +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Bearer-token enforcement on the MCP bridge. + +``--auth-token`` used to be accepted, announced, and then dropped on the floor: +the token never reached the HTTP server and no handler looked at the +Authorization header, so every endpoint answered identically with no token, a +valid token, or a wrong one. These tests drive a real ``HTTPServer`` over real +sockets — mocking the handler would prove only that a function was called, not +that an unauthenticated request is actually refused on the wire. +""" + +import json +import threading +import urllib.error +import urllib.request +from http.server import HTTPServer + +import pytest + +from gaia.mcp.mcp_bridge import ( + AUTH_TOKEN_ENV_VAR, + PUBLIC_PATHS, + MCPHTTPHandler, +) + +# Every test here drives a loopback HTTPServer on an ephemeral port — that is +# the point of the suite, so opt out of the unit-test socket guard. +pytestmark = pytest.mark.allow_network + +TOKEN = "s3cret-token" + + +class StubBridge: + """Minimal stand-in for GAIAMCPBridge. + + The real constructor imports agents (faiss, LLM clients, Jira); the auth + boundary needs none of that. + """ + + def __init__(self, auth_token=None): + self.auth_token = auth_token + self.host = "localhost" + self.port = 0 + self.base_url = "http://localhost:13305/api/v1" + self.agents = {"llm": {"description": "stub"}} + self.tools = {"gaia.query": {"name": "gaia.query", "description": "stub"}} + self.executed = [] + + def execute_tool(self, tool_name, arguments): + self.executed.append((tool_name, arguments)) + return {"success": True, "result": "stub"} + + +@pytest.fixture(name="server_factory") +def _server_factory(): + """Start a real MCP bridge HTTP server on an ephemeral port.""" + started = [] + + def start(auth_token=None): + bridge = StubBridge(auth_token=auth_token) + + def handler(*args, **kwargs): + return MCPHTTPHandler(*args, bridge=bridge, **kwargs) + + httpd = HTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + started.append((httpd, thread)) + return f"http://127.0.0.1:{httpd.server_port}", bridge + + yield start + + for httpd, thread in started: + httpd.shutdown() + httpd.server_close() + thread.join(timeout=5) + + +def _request(url, token=None, method="GET", payload=None, raw_header=None): + """Perform a request, returning (status, body_dict).""" + headers = {} + if raw_header is not None: + headers["Authorization"] = raw_header + elif token is not None: + headers["Authorization"] = f"Bearer {token}" + + data = None + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=10) as response: + return response.status, json.loads(response.read().decode()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read().decode()) + + +PROTECTED_GETS = ["/status", "/tools"] +JSONRPC_LIST = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"} +JSONRPC_CALL = { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "gaia.query", "arguments": {"query": "hi"}}, +} + + +class TestTokenConfigured: + """With --auth-token set, unauthenticated callers must be refused.""" + + @pytest.mark.parametrize("path", PROTECTED_GETS) + def test_get_without_token_is_401(self, server_factory, path): + base, _ = server_factory(auth_token=TOKEN) + status, body = _request(f"{base}{path}") + assert status == 401 + assert "Authorization" in body["error"] + + @pytest.mark.parametrize("path", PROTECTED_GETS) + def test_get_with_wrong_token_is_403(self, server_factory, path): + base, _ = server_factory(auth_token=TOKEN) + status, body = _request(f"{base}{path}", token="WRONGTOKEN") + assert status == 403 + assert "Invalid" in body["error"] + + @pytest.mark.parametrize("path", PROTECTED_GETS) + def test_get_with_valid_token_succeeds(self, server_factory, path): + base, _ = server_factory(auth_token=TOKEN) + status, _body = _request(f"{base}{path}", token=TOKEN) + assert status == 200 + + def test_jsonrpc_tools_list_without_token_is_401(self, server_factory): + base, _ = server_factory(auth_token=TOKEN) + status, _ = _request(f"{base}/", method="POST", payload=JSONRPC_LIST) + assert status == 401 + + def test_jsonrpc_tools_call_without_token_does_not_execute(self, server_factory): + """The tool must not run — rejection happens before dispatch.""" + base, bridge = server_factory(auth_token=TOKEN) + status, _ = _request(f"{base}/", method="POST", payload=JSONRPC_CALL) + assert status == 401 + assert bridge.executed == [] + + def test_jsonrpc_tools_call_with_valid_token_executes(self, server_factory): + base, bridge = server_factory(auth_token=TOKEN) + status, _ = _request( + f"{base}/", method="POST", payload=JSONRPC_CALL, token=TOKEN + ) + assert status == 200 + assert bridge.executed == [("gaia.query", {"query": "hi"})] + + @pytest.mark.parametrize("path", ["/chat", "/llm", "/jira"]) + def test_direct_tool_endpoints_reject_and_do_not_execute( + self, server_factory, path + ): + base, bridge = server_factory(auth_token=TOKEN) + status, _ = _request(f"{base}{path}", method="POST", payload={"query": "hi"}) + assert status == 401 + assert bridge.executed == [] + + @pytest.mark.parametrize( + "raw_header", + [ + "", + TOKEN, # bare token, no scheme + f"Basic {TOKEN}", # wrong scheme + "Bearer", # scheme with no value + "Bearer ", + ], + ) + def test_malformed_authorization_headers_are_401(self, server_factory, raw_header): + base, _ = server_factory(auth_token=TOKEN) + status, _ = _request(f"{base}/status", raw_header=raw_header) + assert status == 401 + + def test_bearer_scheme_is_case_insensitive(self, server_factory): + """RFC 7235 auth-scheme matching is case-insensitive.""" + base, _ = server_factory(auth_token=TOKEN) + status, _ = _request(f"{base}/status", raw_header=f"bearer {TOKEN}") + assert status == 200 + + def test_non_ascii_token_is_rejected_cleanly(self, server_factory): + """A non-ASCII token must 403, not blow up compare_digest into a 500.""" + base, _ = server_factory(auth_token=TOKEN) + status, _ = _request(f"{base}/status", raw_header="Bearer pásswörd") + assert status == 403 + + def test_non_ascii_configured_token_still_works(self, server_factory): + base, _ = server_factory(auth_token="pásswörd") + assert _request(f"{base}/status", token="pásswörd")[0] == 200 + assert _request(f"{base}/status", token="wrong")[0] == 403 + + def test_token_prefix_is_rejected(self, server_factory): + """A truncated token must not pass — guards against prefix comparison.""" + base, _ = server_factory(auth_token=TOKEN) + status, _ = _request(f"{base}/status", token=TOKEN[:-1]) + assert status == 403 + + def test_health_stays_public(self, server_factory): + """Liveness probes and `gaia mcp status` rely on /health being open.""" + base, _ = server_factory(auth_token=TOKEN) + status, body = _request(f"{base}/health") + assert status == 200 + assert body["status"] == "healthy" + + def test_health_does_not_leak_inventory(self, server_factory): + """The public endpoint exposes counts only, never agent or tool names.""" + base, _ = server_factory(auth_token=TOKEN) + _, body = _request(f"{base}/health") + assert body["agents"] == 1 + assert body["tools"] == 1 + assert "gaia.query" not in json.dumps(body) + + def test_status_inventory_requires_auth(self, server_factory): + """Tool names are only readable with credentials.""" + base, _ = server_factory(auth_token=TOKEN) + _, unauth = _request(f"{base}/status") + assert "gaia.query" not in json.dumps(unauth) + _, authed = _request(f"{base}/status", token=TOKEN) + assert "gaia.query" in json.dumps(authed) + + def test_cors_preflight_stays_open_and_allows_authorization(self, server_factory): + """Browsers never send Authorization on preflight.""" + base, _ = server_factory(auth_token=TOKEN) + req = urllib.request.Request(f"{base}/status", method="OPTIONS") + with urllib.request.urlopen(req, timeout=10) as response: + assert response.status == 200 + allowed = response.headers.get("Access-Control-Allow-Headers", "") + assert "Authorization" in allowed + + +class TestNoTokenConfigured: + """Without a token the bridge stays open — unchanged default behaviour.""" + + @pytest.mark.parametrize("path", ["/health"] + PROTECTED_GETS) + def test_endpoints_open_when_unconfigured(self, server_factory, path): + base, _ = server_factory(auth_token=None) + status, _ = _request(f"{base}{path}") + assert status == 200 + + def test_stray_authorization_header_is_ignored(self, server_factory): + base, _ = server_factory(auth_token=None) + status, _ = _request(f"{base}/status", token="anything-at-all") + assert status == 200 + + def test_empty_token_is_treated_as_unconfigured(self, server_factory): + """An empty --auth-token must not silently enable a bypassable check.""" + base, _ = server_factory(auth_token="") + status, _ = _request(f"{base}/status") + assert status == 200 + + +class TestConfigurationContract: + def test_health_is_the_only_public_path(self): + assert PUBLIC_PATHS == frozenset({"/health"}) + + def test_cli_env_var_matches_bridge(self): + """cli.py duplicates the name to avoid importing the heavy bridge module.""" + from gaia.cli import MCP_AUTH_TOKEN_ENV + + assert MCP_AUTH_TOKEN_ENV == AUTH_TOKEN_ENV_VAR + + def test_bridge_reads_token_from_environment(self, monkeypatch): + """start_server falls back to the env var so argv never carries the secret.""" + import sys + + import gaia.mcp.mcp_bridge as bridge_mod + + # start_server rewraps sys.stdout on Windows, which breaks pytest capture. + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setenv(AUTH_TOKEN_ENV_VAR, "from-env") + captured = {} + + class FakeBridge(StubBridge): + def __init__(self, *args, **kwargs): + super().__init__(auth_token=kwargs.get("auth_token")) + captured["auth_token"] = kwargs.get("auth_token") + + class FakeServer: + def __init__(self, *args, **kwargs): + pass + + def serve_forever(self): + raise KeyboardInterrupt + + monkeypatch.setattr(bridge_mod, "GAIAMCPBridge", FakeBridge) + monkeypatch.setattr(bridge_mod, "HTTPServer", FakeServer) + + bridge_mod.start_server(host="localhost", port=0) + + assert captured["auth_token"] == "from-env" diff --git a/tests/unit/test_mcp_bridge_bind.py b/tests/unit/test_mcp_bridge_bind.py index b001344ac..99d822ea0 100644 --- a/tests/unit/test_mcp_bridge_bind.py +++ b/tests/unit/test_mcp_bridge_bind.py @@ -5,9 +5,9 @@ """Bind-host resolution for the MCP bridge. -The bridge is unauthenticated, so a request for "localhost" must never -silently widen to a wildcard bind. Binding all interfaces is explicit -opt-in only, and must be loudly logged. +A request for "localhost" must never silently widen to a wildcard bind. +Binding all interfaces is explicit opt-in only, and is loudly warned about +whenever the bridge is running without an auth token. """ import sys @@ -50,6 +50,13 @@ def test_explicit_wildcard_is_honored_but_warns(self, wildcard, caplog): for record in caplog.records ), "expected a warning that the unauthenticated bridge is network-exposed" + @pytest.mark.parametrize("wildcard", ["0.0.0.0", "::"]) + def test_authenticated_wildcard_does_not_warn(self, wildcard, caplog): + """With a token configured there is no unauthenticated exposure to warn about.""" + with caplog.at_level("WARNING", logger="gaia.mcp.mcp_bridge"): + assert resolve_bind_host(wildcard, authenticated=True) == wildcard + assert not caplog.records + def test_specific_host_passes_through_without_warning(self, caplog): with caplog.at_level("WARNING", logger="gaia.mcp.mcp_bridge"): assert resolve_bind_host("192.168.1.50") == "192.168.1.50"