diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml new file mode 100644 index 00000000..5e6cfba1 --- /dev/null +++ b/.github/workflows/conformance.yaml @@ -0,0 +1,77 @@ +name: MCP Conformance + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + conformance: + runs-on: ubuntu-latest + name: Protocol conformance (streamable HTTP) + + steps: + - uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Install package + run: uv sync --all-extras + + # Start the server over streamable HTTP with no kubeconfig / no cluster. + # health_check and all tools degrade gracefully when K8s is unreachable, + # so the server starts and answers initialize/tools-list without a cluster. + - name: Start MCP server + env: + KUBECONFIG: /nonexistent + run: | + uv run kubeflow-mcp serve --transport http --no-banner > server.log 2>&1 & + echo $! > server.pid + + # FastMCP streamable-HTTP has no plain health endpoint, so probe the MCP + # endpoint with a real initialize request and wait for HTTP 200. + - name: Wait for server + run: | + timeout 60 bash -c 'until curl -sf -o /dev/null \ + http://localhost:8000/mcp \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"ci\",\"version\":\"1\"}}}"; \ + do sleep 1; done' + + - name: Run MCP conformance suite + uses: modelcontextprotocol/conformance@v0.1.16 + with: + mode: server + url: http://localhost:8000/mcp + suite: active + expected-failures: tests/conformance/expected-failures.yaml + node-version: 22 + + - name: Dump server logs + if: always() + run: cat server.log || true + + - name: Stop MCP server + if: always() + run: | + if [ -f server.pid ]; then + kill "$(cat server.pid)" 2>/dev/null || true + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f497086f..e6660473 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,6 +59,41 @@ To run unit tests locally, use the following `make` command: make test-python ``` +### Conformance Testing + +We validate that the server correctly implements the MCP protocol +(`initialize`, `tools/list`, `tools/call`, etc.) using the official +[MCP conformance framework](https://github.com/modelcontextprotocol/conformance). +This runs in CI on every PR (`.github/workflows/conformance.yaml`) and needs +**no Kubernetes cluster and no LLM** — the server starts against an unreachable +cluster and its tools degrade gracefully. + +To run the same suite locally (requires Node.js 20+): + +```bash +make conformance +``` + +This starts `kubeflow-mcp serve --transport http` in the background, waits for +it to answer `initialize`, then runs the conformance `active` suite against +`http://localhost:8000/mcp`. + +**Baseline file.** `tests/conformance/expected-failures.yaml` lists scenarios +that are allowed to fail — these are known spec gaps for this server (mostly +scenarios that assume the reference server's test fixtures, plus a few +unadvertised capabilities). Every scenario *not* listed must pass, and CI also +fails if a listed scenario unexpectedly starts passing. When you add protocol +features, remove the now-passing scenarios from the baseline; if you +intentionally change behavior, update the list with a one-line reason. + +**DNS rebinding protection.** The HTTP/SSE transports validate the `Host` and +`Origin` headers (`kubeflow_mcp/core/transport_security.py`) so a locally-bound +server cannot be reached by a malicious web page via DNS rebinding. Protection +is on by default and allows loopback hosts; override the allowlists with +`KUBEFLOW_MCP_ALLOWED_HOSTS` / `KUBEFLOW_MCP_ALLOWED_ORIGINS` (comma-separated, +`:*` port wildcard supported) when serving on a non-loopback address, or set +`KUBEFLOW_MCP_DNS_REBINDING_PROTECTION=false` to disable it (not recommended). + ## Commit Messages We use [Conventional Commits](https://www.conventionalcommits.org/): diff --git a/Makefile b/Makefile index 9165d8ea..b9a16e21 100644 --- a/Makefile +++ b/Makefile @@ -62,6 +62,18 @@ test-cov: ## Run tests with HTML coverage report ##@ Dev Tools +.PHONY: conformance +conformance: install-dev ## Run MCP protocol conformance suite against a local HTTP server + @echo "Starting kubeflow-mcp on http://localhost:8000/mcp (no cluster required)..." + @KUBECONFIG=/nonexistent uv run kubeflow-mcp serve --transport http --no-banner > /tmp/kubeflow-mcp-conformance.log 2>&1 & \ + SERVER_PID=$$!; \ + trap "kill $$SERVER_PID 2>/dev/null || true" EXIT; \ + timeout 60 bash -c 'until curl -sf -o /dev/null http://localhost:8000/mcp -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"make\",\"version\":\"1\"}}}"; do sleep 1; done'; \ + npx -y @modelcontextprotocol/conformance@0.1.16 server \ + --url http://localhost:8000/mcp \ + --suite active \ + --expected-failures tests/conformance/expected-failures.yaml + TRANSPORT ?= stdio inspector: install-dev ## Launch MCP Inspector (TRANSPORT=stdio|http|sse) diff --git a/README.md b/README.md index ca3e3a25..d08dfd2f 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,9 @@ The server listens on `http://localhost:8000/mcp`. | `KUBEFLOW_MCP_JWT_AUDIENCE` | _(none)_ | Expected JWT audience | | `KUBEFLOW_MCP_CLIENTS` | `trainer` | Comma-separated client modules to load | | `KUBEFLOW_MCP_PERSONA` | `readonly` | Tool persona (`readonly`, `data-scientist`, `ml-engineer`, `platform-admin`) | +| `KUBEFLOW_MCP_ALLOWED_HOSTS` | _(loopback)_ | Comma-separated `Host` header allowlist for DNS rebinding protection; `:*` port wildcard supported (e.g. `mcp.example.com,mcp.example.com:*`) | +| `KUBEFLOW_MCP_ALLOWED_ORIGINS` | _(loopback)_ | Comma-separated `Origin` header allowlist; `:*` port wildcard supported (e.g. `https://mcp.example.com`) | +| `KUBEFLOW_MCP_DNS_REBINDING_PROTECTION` | `true` | Set `false` to disable Host/Origin validation (not recommended) | | `LOG_FORMAT` | `json` | Log format (`json`, `console`) | | `LOG_LEVEL` | `INFO` | Log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) | @@ -81,6 +84,8 @@ The server listens on `http://localhost:8000/mcp`. For in-cluster deployments, replace `localhost:8000` with the Kubernetes Service address and mount `KUBEFLOW_MCP_AUTH_TOKEN` from a Secret. +> **Note:** DNS rebinding protection allows only loopback `Host`/`Origin` headers by default. When exposing the server through a Service or Ingress, set `KUBEFLOW_MCP_ALLOWED_HOSTS` (e.g. `KUBEFLOW_MCP_ALLOWED_HOSTS=kubeflow-mcp.kubeflow.svc:*,mcp.example.com`) or requests will be rejected with HTTP 421. + ### Example: Fine-tune a model via AI agent Once connected, your AI agent can run a complete training workflow through natural language: diff --git a/kubeflow_mcp/cli.py b/kubeflow_mcp/cli.py index e977b218..bd8cb7bb 100644 --- a/kubeflow_mcp/cli.py +++ b/kubeflow_mcp/cli.py @@ -163,6 +163,7 @@ def serve( ) auth_provider = None + http_middleware = None if transport != "stdio": auth_provider = build_auth_provider(cfg.auth) if auth_provider is None: @@ -172,6 +173,27 @@ def serve( "or KUBEFLOW_MCP_JWKS_URI for JWT verification." ) + from starlette.middleware import Middleware + + from kubeflow_mcp.core.transport_security import ( + DNSRebindingProtectionMiddleware, + build_transport_security_settings, + ) + + security_settings = build_transport_security_settings(cfg.security) + if security_settings.enable_dns_rebinding_protection: + http_middleware = [ + Middleware( + DNSRebindingProtectionMiddleware, + settings=security_settings, + ) + ] + else: + logger.warning( + "DNS rebinding protection is disabled — set " + "KUBEFLOW_MCP_DNS_REBINDING_PROTECTION=true to re-enable it." + ) + client_list = [c.strip() for c in clients.split(",")] server = create_server( clients=client_list, @@ -185,9 +207,13 @@ def serve( if transport == "stdio": server.run(show_banner=show_banner) elif transport == "sse": - server.run(transport="sse", show_banner=show_banner) + server.run(transport="sse", show_banner=show_banner, middleware=http_middleware) else: - server.run(transport="streamable-http", show_banner=show_banner) + server.run( + transport="streamable-http", + show_banner=show_banner, + middleware=http_middleware, + ) @cli.command() diff --git a/kubeflow_mcp/core/config.py b/kubeflow_mcp/core/config.py index 8627eb77..0703576b 100644 --- a/kubeflow_mcp/core/config.py +++ b/kubeflow_mcp/core/config.py @@ -115,6 +115,25 @@ class AuthConfig(BaseModel): audience: str | None = Field(default=None) +class SecurityConfig(BaseModel): + """HTTP transport security (DNS rebinding protection). + + Validates the ``Host`` and ``Origin`` headers of incoming HTTP requests so a + malicious web page cannot use DNS rebinding to reach a locally-bound server. + Only applies to the ``http``/``sse`` transports; ``stdio`` has no HTTP surface. + + ``allowed_hosts``/``allowed_origins`` support an exact match or a ``:*`` port + wildcard (e.g. ``localhost:*``). When left empty they default to loopback + values (IPv4 ``127.0.0.1``, IPv6 ``[::1]``, and ``localhost``), which is + correct for the default ``127.0.0.1`` bind used in dev and CI. IPv6 hosts + must be given in bracketed form (e.g. ``[::1]:*``) to match the Host header. + """ + + enable_dns_rebinding_protection: bool = Field(default=True) + allowed_hosts: list[str] = Field(default_factory=list) + allowed_origins: list[str] = Field(default_factory=list) + + class ResilienceConfig(BaseModel): """Rate limiter and circuit breaker tuning.""" @@ -149,6 +168,7 @@ class Config(BaseModel): server: ServerConfig = Field(default_factory=ServerConfig) auth: AuthConfig = Field(default_factory=AuthConfig) + security: SecurityConfig = Field(default_factory=SecurityConfig) resilience: ResilienceConfig = Field(default_factory=ResilienceConfig) trainer: TrainerConfig = Field(default_factory=TrainerConfig) optimizer: OptimizerConfig = Field(default_factory=OptimizerConfig) @@ -272,6 +292,29 @@ def load_config(config_path: Path | None = None) -> Config: audience=os.getenv("KUBEFLOW_MCP_JWT_AUDIENCE", auth_file.get("audience")), ) + security_file = file_config.get("security", {}) + + def _csv_env(name: str, file_default: list[str]) -> list[str]: + raw = os.getenv(name) + if raw is None: + return list(file_default) + return [item.strip() for item in raw.split(",") if item.strip()] + + dns_protect_env = os.getenv("KUBEFLOW_MCP_DNS_REBINDING_PROTECTION") + security = SecurityConfig( + enable_dns_rebinding_protection=( + dns_protect_env.strip().lower() not in {"0", "false", "no", "off"} + if dns_protect_env is not None + else security_file.get("enable_dns_rebinding_protection", True) + ), + allowed_hosts=_csv_env( + "KUBEFLOW_MCP_ALLOWED_HOSTS", security_file.get("allowed_hosts", []) + ), + allowed_origins=_csv_env( + "KUBEFLOW_MCP_ALLOWED_ORIGINS", security_file.get("allowed_origins", []) + ), + ) + resilience_file = file_config.get("resilience", {}) resilience = ResilienceConfig( rate_limit=float( @@ -297,6 +340,7 @@ def load_config(config_path: Path | None = None) -> Config: return Config( server=server, auth=auth, + security=security, resilience=resilience, trainer=trainer, optimizer=optimizer, diff --git a/kubeflow_mcp/core/middleware.py b/kubeflow_mcp/core/middleware.py index 6346355a..c3ffdfcd 100644 --- a/kubeflow_mcp/core/middleware.py +++ b/kubeflow_mcp/core/middleware.py @@ -69,7 +69,7 @@ class AuditIdentityMiddleware: from kubeflow_mcp.core.middleware import AuditIdentityMiddleware mcp = FastMCP("kubeflow-mcp-server") - mcp.add_middleware(AuditIdentityMiddleware) + mcp.add_middleware(AuditIdentityMiddleware()) """ async def __call__(self, context: Any, call_next: Any) -> Any: diff --git a/kubeflow_mcp/core/server.py b/kubeflow_mcp/core/server.py index 679ed5b5..1e4441ee 100644 --- a/kubeflow_mcp/core/server.py +++ b/kubeflow_mcp/core/server.py @@ -362,7 +362,7 @@ def create_server( # noqa: C901 # Bridge FastMCP async context into sync _audit_wrap via ContextVars from kubeflow_mcp.core.middleware import AuditIdentityMiddleware - mcp.add_middleware(AuditIdentityMiddleware) + mcp.add_middleware(AuditIdentityMiddleware()) # Merge tool metadata from client modules all_descriptions: dict[str, str] = {} diff --git a/kubeflow_mcp/core/transport_security.py b/kubeflow_mcp/core/transport_security.py new file mode 100644 index 00000000..3db63a5e --- /dev/null +++ b/kubeflow_mcp/core/transport_security.py @@ -0,0 +1,110 @@ +# Copyright The Kubeflow Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""DNS rebinding protection for the HTTP/SSE transports. + +FastMCP's streamable-HTTP transport does not validate the ``Host``/``Origin`` +headers by default, so a page loaded in a victim's browser could use DNS +rebinding to reach a locally-bound server. This module rejects requests whose +``Host``/``Origin`` are not on an allowlist before they reach the MCP handler. + +The header validation itself is delegated to the MCP SDK's reference +implementation (:class:`mcp.server.transport_security.TransportSecurityMiddleware`) +so behaviour matches the protocol conformance suite: an invalid ``Host`` yields +``421``, an invalid ``Origin`` yields ``403``, and a POST without a JSON +``Content-Type`` yields ``400``. + +The middleware is implemented as a pure-ASGI wrapper (rather than +``BaseHTTPMiddleware``) so it does not buffer the streaming SSE responses the +transport relies on — it only inspects request headers and either short-circuits +with an error response or passes the untouched send/receive channels through. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from mcp.server.transport_security import ( + TransportSecurityMiddleware, + TransportSecuritySettings, +) +from starlette.requests import Request +from starlette.types import ASGIApp, Receive, Scope, Send + +if TYPE_CHECKING: + from kubeflow_mcp.core.config import SecurityConfig + +# Loopback defaults used when no explicit allowlist is configured. These cover +# both the IPv4 (127.0.0.1) and IPv6 (::1) loopback binds FastMCP may use, plus +# the ``localhost`` name. IPv6 hosts arrive bracketed in the Host/Origin header +# (e.g. ``[::1]:8000``), so the allowlist must use the bracketed form. The ``:*`` +# suffix matches any port. An absent Origin (typical for non-browser MCP clients) +# is always allowed by the SDK validator. +_DEFAULT_ALLOWED_HOSTS = [ + "127.0.0.1", + "localhost", + "[::1]", + "127.0.0.1:*", + "localhost:*", + "[::1]:*", +] +_DEFAULT_ALLOWED_ORIGINS = [ + "http://127.0.0.1", + "http://localhost", + "http://[::1]", + "http://127.0.0.1:*", + "http://localhost:*", + "http://[::1]:*", +] + + +def build_transport_security_settings( + config: SecurityConfig, +) -> TransportSecuritySettings: + """Translate our ``SecurityConfig`` into the SDK's transport settings. + + Falls back to loopback defaults when the allowlists are left empty so + protection is on out of the box for the default bind. + """ + allowed_hosts = config.allowed_hosts or _DEFAULT_ALLOWED_HOSTS + allowed_origins = config.allowed_origins or _DEFAULT_ALLOWED_ORIGINS + return TransportSecuritySettings( + enable_dns_rebinding_protection=config.enable_dns_rebinding_protection, + allowed_hosts=allowed_hosts, + allowed_origins=allowed_origins, + ) + + +class DNSRebindingProtectionMiddleware: + """Pure-ASGI middleware that validates Host/Origin before the MCP handler.""" + + def __init__(self, app: ASGIApp, settings: TransportSecuritySettings) -> None: + self.app = app + self._validator = TransportSecurityMiddleware(settings) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + # Reading headers off the scope does not consume the receive channel, so + # the downstream app still gets the full request body. + request = Request(scope, receive) + is_post = scope.get("method") == "POST" + error = await self._validator.validate_request(request, is_post=is_post) + if error is not None: + await error(scope, receive, send) + return + + await self.app(scope, receive, send) diff --git a/tests/conformance/expected-failures.yaml b/tests/conformance/expected-failures.yaml new file mode 100644 index 00000000..e64c0543 --- /dev/null +++ b/tests/conformance/expected-failures.yaml @@ -0,0 +1,60 @@ +# MCP conformance baseline — known spec gaps for kubeflow-mcp. +# +# The conformance runner (modelcontextprotocol/conformance) executes the +# "active" suite against the running server over streamable HTTP. Scenarios +# listed here are allowed to fail without failing CI; every scenario NOT listed +# here must pass. The runner also fails if a listed scenario starts *passing*, +# so this file catches both regressions and unexpected improvements. +# +# How to regenerate: run `make conformance` locally (see CONTRIBUTING.md), then +# move any newly-failing scenario into this list with a one-line reason, or +# remove a scenario that now passes. +# +# Every entry below is a legitimate gap for THIS server, not a protocol bug: +# - Most "active" scenarios assume the reference test server's fixtures +# (a resource named `test://static-text`, a prompt `test_simple_prompt`, +# tools that return images/audio/progress, etc.). kubeflow-mcp exposes real +# Kubeflow tools instead, so these fixture-driven scenarios can never pass. +# - A few exercise MCP capabilities this server does not advertise +# (completions, sampling, elicitation). +# +# Note: dns-rebinding-protection is intentionally NOT baselined — this server +# enforces Host/Origin validation (see kubeflow_mcp/core/transport_security.py), +# so that scenario is a required pass and CI must flag any regression. + +server: + # --- Capabilities not advertised by this server --- + # Completions capability is not implemented (JSON-RPC -32601 Method not found). + - completion-complete + # Server never issues sampling requests from within a tool call. + - tools-call-sampling + # Server never issues elicitation requests from within a tool call. + - tools-call-elicitation + - elicitation-sep1034-defaults + - elicitation-sep1330-enums + + # --- Fixture-driven tool scenarios (no matching tool on this server) --- + # These expect reference-server tools that return specific content types or + # emit progress/logging notifications; kubeflow-mcp's tools return text. + - tools-call-image + - tools-call-audio + - tools-call-embedded-resource + - tools-call-mixed-content + - tools-call-with-logging + - tools-call-with-progress + + # --- Fixture-driven resource scenarios (reference-server URIs absent) --- + # e.g. resources-read-text expects `test://static-text`. This server exposes + # its own Kubeflow resources and does not implement resource subscriptions. + - resources-read-text + - resources-read-binary + - resources-templates-read + - resources-subscribe + - resources-unsubscribe + + # --- Fixture-driven prompt scenarios (reference-server prompts absent) --- + # e.g. prompts-get-simple expects `test_simple_prompt`. + - prompts-get-simple + - prompts-get-with-args + - prompts-get-embedded-resource + - prompts-get-with-image diff --git a/tests/unit/core/test_transport_security.py b/tests/unit/core/test_transport_security.py new file mode 100644 index 00000000..d222a73d --- /dev/null +++ b/tests/unit/core/test_transport_security.py @@ -0,0 +1,185 @@ +# Copyright The Kubeflow Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for DNS rebinding protection on the HTTP transport.""" + +import pytest +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.responses import JSONResponse +from starlette.routing import Route +from starlette.testclient import TestClient + +from kubeflow_mcp.core.config import SecurityConfig +from kubeflow_mcp.core.transport_security import ( + DNSRebindingProtectionMiddleware, + build_transport_security_settings, +) + + +def _make_client(config: SecurityConfig) -> TestClient: + async def ok(_request): + return JSONResponse({"ok": True}) + + settings = build_transport_security_settings(config) + app = Starlette( + routes=[Route("/mcp", ok, methods=["GET", "POST"])], + middleware=[Middleware(DNSRebindingProtectionMiddleware, settings=settings)], + ) + # raise_server_exceptions keeps behaviour close to a real ASGI server. + return TestClient(app, raise_server_exceptions=False) + + +def test_defaults_allow_loopback_host(): + client = _make_client(SecurityConfig()) + resp = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "ping"}, + headers={"host": "localhost:8000"}, + ) + assert resp.status_code == 200 + + +def test_defaults_allow_ipv6_loopback_host(): + # FastMCP may bind ::1; clients then send a bracketed IPv6 Host header. + client = _make_client(SecurityConfig()) + resp = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "ping"}, + headers={"host": "[::1]:8000", "origin": "http://[::1]:8000"}, + ) + assert resp.status_code == 200 + + +def test_rejects_unknown_host_with_421(): + client = _make_client(SecurityConfig()) + resp = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "ping"}, + headers={"host": "attacker.example.com"}, + ) + assert resp.status_code == 421 + + +def test_rejects_unknown_origin_with_403(): + client = _make_client(SecurityConfig()) + resp = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "ping"}, + headers={"host": "localhost:8000", "origin": "http://evil.example.com"}, + ) + assert resp.status_code == 403 + + +def test_rejects_non_json_post_with_400(): + client = _make_client(SecurityConfig()) + resp = client.post( + "/mcp", + content=b"not json", + headers={"host": "localhost:8000", "content-type": "text/plain"}, + ) + assert resp.status_code == 400 + + +def test_disabled_protection_allows_any_host(): + client = _make_client(SecurityConfig(enable_dns_rebinding_protection=False)) + resp = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "ping"}, + headers={"host": "attacker.example.com"}, + ) + assert resp.status_code == 200 + + +def test_custom_allowlist_is_honored(): + client = _make_client(SecurityConfig(allowed_hosts=["mcp.internal:8000"])) + allowed = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "ping"}, + headers={"host": "mcp.internal:8000"}, + ) + assert allowed.status_code == 200 + + # Loopback is no longer implicitly allowed once an explicit list is set. + rejected = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "ping"}, + headers={"host": "localhost:8000"}, + ) + assert rejected.status_code == 421 + + +def test_get_stream_without_origin_allowed(): + # SSE GET streams carry no Origin header from non-browser MCP clients. + client = _make_client(SecurityConfig()) + resp = client.get("/mcp", headers={"host": "127.0.0.1:8000"}) + assert resp.status_code == 200 + + +class TestLoadConfigSecurityEnvVars: + """load_config() env-var handling for the security section.""" + + @pytest.fixture(autouse=True) + def _isolate_config(self, monkeypatch: pytest.MonkeyPatch): + # Ignore any ~/.kubeflow-mcp.yaml on the host running the tests. + from kubeflow_mcp.core import config + + monkeypatch.setattr(config, "_find_config_file", lambda: None) + for var in ( + "KUBEFLOW_MCP_DNS_REBINDING_PROTECTION", + "KUBEFLOW_MCP_ALLOWED_HOSTS", + "KUBEFLOW_MCP_ALLOWED_ORIGINS", + ): + monkeypatch.delenv(var, raising=False) + + def test_defaults_when_unset(self): + from kubeflow_mcp.core.config import load_config + + cfg = load_config() + assert cfg.security.enable_dns_rebinding_protection is True + assert cfg.security.allowed_hosts == [] + assert cfg.security.allowed_origins == [] + + def test_allowed_hosts_csv_parsing(self, monkeypatch: pytest.MonkeyPatch): + from kubeflow_mcp.core.config import load_config + + monkeypatch.setenv("KUBEFLOW_MCP_ALLOWED_HOSTS", "mcp.example.com, mcp.example.com:* ,") + cfg = load_config() + assert cfg.security.allowed_hosts == ["mcp.example.com", "mcp.example.com:*"] + + def test_allowed_origins_csv_parsing(self, monkeypatch: pytest.MonkeyPatch): + from kubeflow_mcp.core.config import load_config + + monkeypatch.setenv("KUBEFLOW_MCP_ALLOWED_ORIGINS", "https://mcp.example.com,http://[::1]:*") + cfg = load_config() + assert cfg.security.allowed_origins == [ + "https://mcp.example.com", + "http://[::1]:*", + ] + + @pytest.mark.parametrize("value", ["false", "0", "no", "off", "False", " OFF "]) + def test_protection_disabled_via_env(self, monkeypatch: pytest.MonkeyPatch, value: str): + from kubeflow_mcp.core.config import load_config + + monkeypatch.setenv("KUBEFLOW_MCP_DNS_REBINDING_PROTECTION", value) + cfg = load_config() + assert cfg.security.enable_dns_rebinding_protection is False + + @pytest.mark.parametrize("value", ["true", "1", "yes", "on"]) + def test_protection_enabled_via_env(self, monkeypatch: pytest.MonkeyPatch, value: str): + from kubeflow_mcp.core.config import load_config + + monkeypatch.setenv("KUBEFLOW_MCP_DNS_REBINDING_PROTECTION", value) + cfg = load_config() + assert cfg.security.enable_dns_rebinding_protection is True