From b09acc51ee140024a7fcaf3571f0d758665d0b52 Mon Sep 17 00:00:00 2001 From: Danyal Ahmed <58849388+danyalahmed1995@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:21:37 +0500 Subject: [PATCH] fix(security): restrict tokenless dev mode to loopback --- docs/configuration.md | 11 ++- docs/quickstart.md | 7 +- docs/tutorials/connecting-agent-frameworks.md | 2 +- examples/bfsi-demo/cmcp-config.yaml | 2 +- src/cmcp_runtime/cli.py | 4 +- src/cmcp_runtime/config.py | 71 +++++++++++++- tests/unit/test_config.py | 97 +++++++++++++++++++ tests/unit/test_startup.py | 1 + 8 files changed, 185 insertions(+), 10 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index e0e0c2e..a739003 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,4 +1,4 @@ -# Configuration Reference +# Configuration Reference `cmcp-config.yaml` controls the gateway's attestation provider, policy enforcement behavior, network settings, and file paths. Environment variables override specific fields and control secrets that must not appear in config files. @@ -55,6 +55,11 @@ policy_bundle_path: policies/ catalog_path: catalog.json # Address and port the runtime listens on. +# Effective defaults: +# - tokenless CMCP_DEV_MODE=1: "127.0.0.1:8443" +# - authenticated or production operation: "0.0.0.0:8443" (unless explicitly configured) +# Tokenless development mode only accepts loopback addresses (e.g., 127.0.0.1:8443, localhost:8443, [::1]:8443). +# Wildcard, LAN, public, and non-loopback hostname binds require CMCP_BEARER_TOKEN. listen_addr: "0.0.0.0:8443" # Maximum size of a tool response payload in bytes. Responses larger than @@ -98,7 +103,7 @@ All fields are optional as a group. If `path` is set, `trust_anchor_path` must a |-------|------|---------|-------------| | `policy_bundle_path` | string | `policies/` | Path to the Cedar policy bundle directory. Must contain `.cedar` files and a `manifest.json`. Path traversal (`..` components) is rejected. | | `catalog_path` | string | `catalog.json` | Path to the JSON tool catalog. Path traversal (`..` components) is rejected. | -| `listen_addr` | string | `0.0.0.0:8443` | Address and port the gateway binds to. | +| `listen_addr` | string | `0.0.0.0:8443` | Address and port the gateway binds to. Default is `127.0.0.1:8443` in tokenless `CMCP_DEV_MODE=1`, otherwise `0.0.0.0:8443`. Tokenless dev mode requires loopback (e.g., `127.0.0.1:8443`, `localhost:8443`, `[::1]:8443`). Wildcard, LAN, public, and non-loopback hostname binds require `CMCP_BEARER_TOKEN`. | | `max_response_size_bytes` | integer | `2097152` | Maximum tool response size in bytes (2MB). Must be a positive integer. Responses exceeding this limit are rejected before inspection. | | `policy_reload_interval_seconds` | integer | `0` | Interval in seconds between automatic Cedar bundle reloads. `0` disables automatic reload. See note in the full example above. | @@ -109,7 +114,7 @@ Environment variables control secrets and mode flags that must not appear in con | Variable | Description | Overrides | |----------|-------------|-----------| | `CMCP_DEV_MODE=1` | Enables software-only attestation. No hardware TEE required. TRACE Claims will show `partially_verified` status. Required when `provider` is `software-only`. | `attestation.provider` (forces software-only) | -| `CMCP_BEARER_TOKEN` | Optional bearer token for runtime HTTP auth. If set, all requests to the runtime must include `Authorization: Bearer `. If unset, no bearer auth is enforced. | none | +| `CMCP_BEARER_TOKEN` | Optional bearer token for runtime HTTP auth. If set, all requests to the runtime must include `Authorization: Bearer `. If unset, no bearer auth is enforced. This token is required for non-loopback binds. | none | | `OPAQUE_ATTESTATION_URL` | Enables the OPAQUE Managed Runtime provider. Must be set to the OPAQUE attestation service URL. Required when `provider` is `opaque` or `auto` on OPAQUE infrastructure. | enables `opaque` provider detection | | `CMCP_POLICY_HASH` | SHA-256 hash of the approved policy bundle. Required in non-dev mode and checked by startup before Agent Manifest binding. The gateway fails closed at startup if this is unset and `CMCP_DEV_MODE` is not `1`. Format: `sha256:`. | none (startup policy integrity check) | | `CMCP_CATALOG_HASH` | SHA-256 hash of the approved `catalog.json`. Required in non-dev mode. The gateway fails closed at startup if this is unset and `CMCP_DEV_MODE` is not `1`. Format: `sha256:`. | none (additional startup check) | diff --git a/docs/quickstart.md b/docs/quickstart.md index d8b9a61..ffe4967 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -63,6 +63,7 @@ attestation: enforcement_mode: enforcing policy_bundle_path: ./policies/ catalog_path: ./catalog.json +listen_addr: "127.0.0.1:8443" audit_db_path: ./audit.db ``` @@ -222,10 +223,12 @@ In dev mode the runtime uses a software-only TEE provider (no hardware required) No hardware TEE detected. Running in development mode: attestation is not hardware-backed. ... SPIFFE SVID not available (SPIRE agent socket not found ...) - gateway will use self-signed TLS for mTLS CMCP_NRAS_API_KEY is not set -- skipping NRAS post-attestation appraisal. ... -cMCP Runtime starting: TEE: software-only, listen: 0.0.0.0:8443 -INFO: Uvicorn running on http://0.0.0.0:8443 (Press CTRL+C to quit) +cMCP Runtime starting: TEE: software-only, listen: 127.0.0.1:8443 +INFO: Uvicorn running on http://127.0.0.1:8443 (Press CTRL+C to quit) ``` +Tokenless dev mode binds to loopback only. Reaching the gateway from a LAN, a container network, or the cloud requires setting `CMCP_BEARER_TOKEN`, so you never expose an unauthenticated gateway by accident. + The gateway now holds this terminal open. Leave it running and open a **second terminal** for the next steps. In that second terminal, `cd` back into `cmcp-quickstart` (and re-activate your Python environment if you use one) so the commands run from the right place. --- diff --git a/docs/tutorials/connecting-agent-frameworks.md b/docs/tutorials/connecting-agent-frameworks.md index a11e57b..00d97b9 100644 --- a/docs/tutorials/connecting-agent-frameworks.md +++ b/docs/tutorials/connecting-agent-frameworks.md @@ -32,7 +32,7 @@ curl http://localhost:8443/health ## How the gateway looks to an agent -The gateway runs at `listen_addr` (default `0.0.0.0:8443`) and exposes a standard MCP over HTTP/SSE transport. Two endpoints matter for agent frameworks: +The gateway runs at `listen_addr` (default `127.0.0.1:8443` in dev mode, otherwise `0.0.0.0:8443`) and exposes a standard MCP over HTTP/SSE transport. Two endpoints matter for agent frameworks: | Endpoint | Method | Auth | Purpose | |---|---|---|---| diff --git a/examples/bfsi-demo/cmcp-config.yaml b/examples/bfsi-demo/cmcp-config.yaml index 11a9ecb..3f5d920 100644 --- a/examples/bfsi-demo/cmcp-config.yaml +++ b/examples/bfsi-demo/cmcp-config.yaml @@ -5,6 +5,6 @@ attestation: policy_bundle_path: ./policies/ catalog_path: ./catalog.json -listen_addr: "0.0.0.0:8443" +listen_addr: "127.0.0.1:8443" max_response_size_bytes: 2097152 # 2MB audit_db_path: ./audit.db diff --git a/src/cmcp_runtime/cli.py b/src/cmcp_runtime/cli.py index 79b75e3..1fc8395 100644 --- a/src/cmcp_runtime/cli.py +++ b/src/cmcp_runtime/cli.py @@ -83,6 +83,7 @@ def start(config: str, enforcement: str | None) -> None: """Start the cMCP Runtime.""" import uvicorn + from cmcp_runtime.config import parse_listen_addr from cmcp_runtime.startup import run_startup ctx = run_startup(config) @@ -94,8 +95,7 @@ def start(config: str, enforcement: str | None) -> None: server = build_server(ctx) - host, _, port_str = ctx.config.listen_addr.rpartition(":") - port = int(port_str) + host, port = parse_listen_addr(ctx.config.listen_addr) click.echo( f"cMCP Runtime starting: TEE: {ctx.attestation_report.provider}," diff --git a/src/cmcp_runtime/config.py b/src/cmcp_runtime/config.py index 16c848c..5297a2e 100644 --- a/src/cmcp_runtime/config.py +++ b/src/cmcp_runtime/config.py @@ -5,6 +5,7 @@ import os from dataclasses import dataclass, field from enum import StrEnum +from ipaddress import ip_address from pathlib import PurePosixPath, PureWindowsPath from typing import Any @@ -104,6 +105,59 @@ class Config: _KNOWN_AGENT_MANIFEST_KEYS = {"path", "trust_anchor_path", "authenticated_subject"} +def parse_listen_addr(value: str) -> tuple[str, int]: + """Parse a listen address into a host and port.""" + + if not isinstance(value, str): + raise ConfigError("listen_addr must be a string") + + address = value.strip() + if not address: + raise ConfigError("listen_addr must not be empty") + + if address.startswith("["): + closing_bracket = address.find("]") + if closing_bracket == -1 or address[closing_bracket + 1 : closing_bracket + 2] != ":": + raise ConfigError( + "listen_addr must use the format '[IPv6]:port'" + ) + + host = address[1:closing_bracket] + port_text = address[closing_bracket + 2 :] + else: + host, separator, port_text = address.rpartition(":") + if not separator: + raise ConfigError( + "listen_addr must use the format 'host:port'" + ) + + if not host: + raise ConfigError("listen_addr host must not be empty") + + try: + port = int(port_text) + except ValueError as exc: + raise ConfigError("listen_addr port must be an integer") from exc + + if not 1 <= port <= 65535: + raise ConfigError("listen_addr port must be between 1 and 65535") + + return host, port + + +def _is_loopback_host(host: str) -> bool: + """Return whether a host is explicitly local-only.""" + + if host.casefold() == "localhost": + return True + + try: + return ip_address(host).is_loopback + except ValueError: + # Hostnames other than localhost are not guaranteed to remain local. + return False + + def _check_no_traversal(field_name: str, path_str: str) -> None: """Reject paths that contain '..' components to prevent directory traversal (CONF-004).""" for part in PurePosixPath(path_str).parts: @@ -221,6 +275,21 @@ def load_config(path: str) -> Config: dev_mode = DEV_MODE # TEE-002: use the frozen constant, never re-read from env bearer_token = os.environ.get("CMCP_BEARER_TOKEN") or None + default_listen_addr = ( + "127.0.0.1:8443" + if dev_mode and bearer_token is None + else "0.0.0.0:8443" + ) + listen_addr = raw.get("listen_addr", default_listen_addr) + listen_host, _ = parse_listen_addr(listen_addr) + + if dev_mode and bearer_token is None and not _is_loopback_host(listen_host): + raise ConfigError( + "Tokenless development mode may only bind to a loopback address. " + "Use 127.0.0.1:, localhost:, or [::1]:, " + "or configure CMCP_BEARER_TOKEN for non-loopback access." + ) + policy_bundle_path = raw.get("policy_bundle_path", "policy/") catalog_path = raw.get("catalog_path", "catalog.json") audit_db_path = raw.get("audit_db_path", "audit.db") @@ -269,7 +338,7 @@ def load_config(path: str) -> Config: ), policy_bundle_path=policy_bundle_path, catalog_path=catalog_path, - listen_addr=raw.get("listen_addr", "0.0.0.0:8443"), + listen_addr=listen_addr, max_response_size_bytes=max_bytes, policy_reload_interval_seconds=policy_reload_interval, audit_db_path=audit_db_path, diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 1c44b07..62b844b 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -219,3 +219,100 @@ def test_expected_measurement_non_string_rejected(config_file): path = config_file("attestation:\n expected_measurement: 12345\n") with pytest.raises(ConfigError, match="expected_measurement"): load_config(path) + + +def test_tokenless_dev_mode_defaults_to_loopback(config_file, monkeypatch): + import cmcp_runtime.config as config_module + + monkeypatch.setattr(config_module, "DEV_MODE", True) + monkeypatch.delenv("CMCP_BEARER_TOKEN", raising=False) + + cfg = load_config(config_file("")) + + assert cfg.listen_addr == "127.0.0.1:8443" + + +@pytest.mark.parametrize( + "listen_addr", + [ + "127.0.0.1:8443", + "localhost:8443", + "::1:8443", + "[::1]:8443", + ], +) +def test_tokenless_dev_mode_allows_loopback( + config_file, + monkeypatch, + listen_addr, +): + import cmcp_runtime.config as config_module + + monkeypatch.setattr(config_module, "DEV_MODE", True) + monkeypatch.delenv("CMCP_BEARER_TOKEN", raising=False) + + path = config_file(f'listen_addr: "{listen_addr}"\n') + cfg = load_config(path) + + assert cfg.listen_addr == listen_addr + + +@pytest.mark.parametrize( + "listen_addr", + [ + "0.0.0.0:8443", + "[::]:8443", + "192.168.1.20:8443", + "example.com:8443", + ], +) +def test_tokenless_dev_mode_rejects_non_loopback( + config_file, + monkeypatch, + listen_addr, +): + import cmcp_runtime.config as config_module + + monkeypatch.setattr(config_module, "DEV_MODE", True) + monkeypatch.delenv("CMCP_BEARER_TOKEN", raising=False) + + path = config_file(f'listen_addr: "{listen_addr}"\n') + + with pytest.raises(ConfigError, match="loopback"): + load_config(path) + + +@pytest.mark.parametrize( + "listen_addr", + [ + "0.0.0.0:8443", + "[::]:8443", + "192.168.1.20:8443", + ], +) +def test_dev_mode_with_bearer_token_allows_non_loopback( + config_file, + monkeypatch, + listen_addr, +): + import cmcp_runtime.config as config_module + + monkeypatch.setattr(config_module, "DEV_MODE", True) + monkeypatch.setenv("CMCP_BEARER_TOKEN", "test-secret-token") + + path = config_file(f'listen_addr: "{listen_addr}"\n') + cfg = load_config(path) + + assert cfg.listen_addr == listen_addr + assert cfg.bearer_token == "test-secret-token" + + +def test_non_dev_mode_preserves_wildcard_default(config_file, monkeypatch): + import cmcp_runtime.config as config_module + + monkeypatch.setattr(config_module, "DEV_MODE", False) + monkeypatch.delenv("CMCP_BEARER_TOKEN", raising=False) + + cfg = load_config(config_file("")) + + assert cfg.listen_addr == "0.0.0.0:8443" diff --git a/tests/unit/test_startup.py b/tests/unit/test_startup.py index e817f1a..8212505 100644 --- a/tests/unit/test_startup.py +++ b/tests/unit/test_startup.py @@ -129,6 +129,7 @@ def test_startup_succeeds_in_dev_mode(complete_setup): ctx = run_startup(complete_setup) assert isinstance(ctx, RuntimeContext) assert ctx.config.dev_mode is True + assert ctx.config.listen_addr == "127.0.0.1:8443" assert ctx.signing_key is not None assert ctx.policy_bundle is not None assert ctx.catalog is not None