diff --git a/docs/environment-variables.rst b/docs/environment-variables.rst index a549b27a4..df5cba4e6 100644 --- a/docs/environment-variables.rst +++ b/docs/environment-variables.rst @@ -2108,6 +2108,18 @@ - ``CodeKeeper`` - ``CodeKeeper`` - MCP + * - ``MCP_ALLOWED_HOSTS`` + - רשימת Host מותרים לשרת ה-MCP (CSV; תומך wildcard כמו ``*.onrender.com``). ריק = הגנת DNS-rebinding כבויה (מתאים לשרת ציבורי מוגן-טוקן). + - לא + - "" (כבוי) + - ``codekeeper-mcp.onrender.com`` + - MCP + * - ``MCP_ALLOWED_ORIGINS`` + - רשימת Origin מותרים לשרת ה-MCP (CSV). רלוונטי רק כשמפעילים הגנה דרך ``MCP_ALLOWED_HOSTS``. + - לא + - "" + - ``https://claude.ai`` + - MCP דגלי בדיקות ופיתוח ------------------- diff --git a/mcp_server/server.py b/mcp_server/server.py index 17e5cd8cc..b8b099e51 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -11,9 +11,11 @@ from __future__ import annotations +import os from typing import Any from mcp.server.fastmcp import Context, FastMCP +from mcp.server.transport_security import TransportSecuritySettings from starlette.responses import JSONResponse from starlette.routing import Route @@ -28,8 +30,33 @@ ) +def _transport_security() -> TransportSecuritySettings: + """DNS-rebinding protection config for the Streamable-HTTP transport. + + That protection targets *localhost* servers (a malicious web page tricking a + browser into calling 127.0.0.1). This server is public and Bearer-token + gated, so the default host check only blocks legitimate access behind a real + domain (HTTP 421 "Invalid Host header"). Default: OFF. Set MCP_ALLOWED_HOSTS + (comma-separated; wildcards like ``*.onrender.com`` allowed) to lock it down. + """ + hosts = [h.strip() for h in os.getenv("MCP_ALLOWED_HOSTS", "").split(",") if h.strip()] + origins = [o.strip() for o in os.getenv("MCP_ALLOWED_ORIGINS", "").split(",") if o.strip()] + if hosts or origins: + return TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=hosts, + allowed_origins=origins, + ) + return TransportSecuritySettings(enable_dns_rebinding_protection=False) + + def build_mcp(backend: Any, *, name: str = "CodeKeeper") -> FastMCP: - mcp: FastMCP = FastMCP(name, instructions=_INSTRUCTIONS, stateless_http=True) + mcp: FastMCP = FastMCP( + name, + instructions=_INSTRUCTIONS, + stateless_http=True, + transport_security=_transport_security(), + ) @mcp.tool(description="List the user's saved code files (metadata only, no code).") def list_files(ctx: Context, page: int = 1, per_page: int = 50) -> dict: diff --git a/services/config_inspector_service.py b/services/config_inspector_service.py index 017cec181..d5a0779c8 100644 --- a/services/config_inspector_service.py +++ b/services/config_inspector_service.py @@ -526,6 +526,18 @@ class ConfigService: description="שם התצוגה של שרת ה-MCP (שם ה-Connector שמוצג ללקוח).", category="mcp", ), + "MCP_ALLOWED_HOSTS": ConfigDefinition( + key="MCP_ALLOWED_HOSTS", + default="", + description="Host מותרים לשרת ה-MCP (CSV, תומך wildcard). ריק = הגנת DNS-rebinding כבויה (מתאים לשרת ציבורי מוגן-טוקן).", + category="mcp", + ), + "MCP_ALLOWED_ORIGINS": ConfigDefinition( + key="MCP_ALLOWED_ORIGINS", + default="", + description="Origin מותרים לשרת ה-MCP (CSV). רלוונטי רק כשמפעילים הגנה דרך MCP_ALLOWED_HOSTS.", + category="mcp", + ), # --- Repo Sync Engine (Git Mirror) --- "REPO_NAME": ConfigDefinition( diff --git a/tests/test_mcp_server_build.py b/tests/test_mcp_server_build.py index ef7c56bd3..ada905968 100644 --- a/tests/test_mcp_server_build.py +++ b/tests/test_mcp_server_build.py @@ -57,3 +57,23 @@ def test_build_app_exposes_healthz_route(): app = build_app(_FakeBackend(), _FakeStore()) paths = {getattr(r, "path", None) for r in app.routes} assert "/healthz" in paths + + +def test_transport_security_off_by_default(monkeypatch): + monkeypatch.delenv("MCP_ALLOWED_HOSTS", raising=False) + monkeypatch.delenv("MCP_ALLOWED_ORIGINS", raising=False) + from mcp_server.server import _transport_security + + ts = _transport_security() + # Public token-gated server: DNS-rebinding host check must be off so a real + # domain (e.g. *.onrender.com) is not rejected with HTTP 421. + assert ts.enable_dns_rebinding_protection is False + + +def test_transport_security_locks_down_via_env(monkeypatch): + monkeypatch.setenv("MCP_ALLOWED_HOSTS", "a.com, *.b.com") + from mcp_server.server import _transport_security + + ts = _transport_security() + assert ts.enable_dns_rebinding_protection is True + assert ts.allowed_hosts == ["a.com", "*.b.com"]