diff --git a/charts/mcp-stack/templates/configmap-nginx-proxy.yaml b/charts/mcp-stack/templates/configmap-nginx-proxy.yaml index b7abdb53ea..d4bc1da10b 100644 --- a/charts/mcp-stack/templates/configmap-nginx-proxy.yaml +++ b/charts/mcp-stack/templates/configmap-nginx-proxy.yaml @@ -29,6 +29,13 @@ data: proxy_cache_path {{ .Values.nginxProxy.config.cache.path }} levels=1:2 keys_zone=mcp_cache:100m max_size={{ .Values.nginxProxy.config.cache.maxSize }} inactive={{ .Values.nginxProxy.config.cache.inactive }} use_temp_path=off; {{- end }} + # Preserve X-Forwarded-Proto from upstream proxy (e.g. ALB, ingress + # controller); fall back to $scheme when nginx is the outermost proxy. + map $http_x_forwarded_proto $forwarded_proto { + default $http_x_forwarded_proto; + "" $scheme; + } + upstream gateway_upstream { server {{ $gatewayServiceName }}:{{ $gatewayServicePort }}; keepalive 32; @@ -51,10 +58,11 @@ data: location / { proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + proxy_set_header X-Forwarded-Host $http_host; proxy_set_header Connection ""; proxy_connect_timeout 30s; diff --git a/infra/nginx/nginx.conf b/infra/nginx/nginx.conf index 94025dde4b..9a9be05b3e 100644 --- a/infra/nginx/nginx.conf +++ b/infra/nginx/nginx.conf @@ -185,6 +185,13 @@ http { # # proxy_ssl_trusted_certificate /app/certs/cert.pem; # proxy_ssl_session_reuse on; + # Preserve X-Forwarded-Proto from upstream proxy (e.g. ALB, ingress + # controller); fall back to $scheme when nginx is the outermost proxy. + map $http_x_forwarded_proto $forwarded_proto { + default $http_x_forwarded_proto; + "" $scheme; + } + # Cache bypass conditions map $request_method $skip_cache { default 0; @@ -285,7 +292,8 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + proxy_set_header X-Forwarded-Host $http_host; proxy_set_header Connection ""; proxy_http_version 1.1; } @@ -313,7 +321,8 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + proxy_set_header X-Forwarded-Host $http_host; proxy_set_header Connection ""; proxy_http_version 1.1; } @@ -361,7 +370,8 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + proxy_set_header X-Forwarded-Host $http_host; proxy_set_header Connection ""; proxy_http_version 1.1; @@ -384,7 +394,8 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + proxy_set_header X-Forwarded-Host $http_host; proxy_set_header Connection ""; proxy_http_version 1.1; @@ -450,7 +461,8 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + proxy_set_header X-Forwarded-Host $http_host; proxy_set_header Connection ""; proxy_http_version 1.1; @@ -477,7 +489,8 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + proxy_set_header X-Forwarded-Host $http_host; # Extended timeouts for SSE proxy_connect_timeout 1h; @@ -500,7 +513,8 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + proxy_set_header X-Forwarded-Host $http_host; # Extended timeouts for SSE proxy_connect_timeout 1h; @@ -518,7 +532,8 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + proxy_set_header X-Forwarded-Host $http_host; } location ~ ^/servers/.*/ws$ { @@ -534,7 +549,8 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + proxy_set_header X-Forwarded-Host $http_host; # Extended timeouts for WebSocket proxy_connect_timeout 1h; @@ -564,7 +580,8 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + proxy_set_header X-Forwarded-Host $http_host; proxy_set_header Connection ""; proxy_http_version 1.1; @@ -596,7 +613,8 @@ http { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + proxy_set_header X-Forwarded-Host $http_host; proxy_set_header Connection ""; proxy_http_version 1.1; diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index 49707b2fe1..cfe19039fd 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -1080,16 +1080,20 @@ def _normalize_origin_parts(scheme: str, netloc: str) -> tuple[str, str, int]: def _request_origin_matches(request: Request) -> bool: """Return ``True`` when Origin/Referer matches this request origin. + The function first performs an exact same-origin comparison using the + request's forwarded headers (``X-Forwarded-Proto`` / ``X-Forwarded-Host``). + When that fails — common behind layered reverse proxies where forwarded + headers reflect internal hops rather than the external scheme — it falls + back to checking whether the candidate origin is explicitly listed in + ``settings.allowed_origins``. Wildcard entries (``*``, ``null``, ``""``) + are excluded from the fallback to preserve fail-closed behavior. + Args: request: Incoming request carrying Origin/Referer and host headers. Returns: - ``True`` when candidate origin exactly matches request origin; otherwise ``False``. - - Note: - When the service is deployed behind a reverse proxy, this check relies on - ``X-Forwarded-Proto`` / ``X-Forwarded-Host`` values emitted by that proxy. - The deployment boundary must sanitize and overwrite forwarded headers. + ``True`` when candidate origin matches either the request origin or an + entry in ``settings.allowed_origins``; otherwise ``False``. """ origin = request.headers.get("origin") referer = request.headers.get("referer") @@ -1117,7 +1121,29 @@ def _request_origin_matches(request: Request) -> bool: candidate_parts = _normalize_origin_parts(parsed_candidate.scheme, parsed_candidate.netloc) request_parts = _normalize_origin_parts(request_scheme, request_netloc) - return candidate_parts == request_parts + if candidate_parts == request_parts: + return True + + # Fallback: accept origins explicitly listed in settings.allowed_origins. + # Handles reverse-proxy deployments where forwarded headers may not + # accurately reflect the external scheme/host. + for allowed in settings.allowed_origins: + # Normalize each allowed origin to avoid config surprises such as + # ["https://a.com "] or [" null "], which could otherwise be + # mis-parsed or skipped. + allowed_normalized = str(allowed).strip() + if not allowed_normalized or allowed_normalized == "*" or allowed_normalized.casefold() == "null": + continue + try: + allowed_parsed = urllib.parse.urlparse(allowed_normalized if "://" in allowed_normalized else f"https://{allowed_normalized}") + if not allowed_parsed.scheme or not allowed_parsed.netloc: + continue + if candidate_parts == _normalize_origin_parts(allowed_parsed.scheme, allowed_parsed.netloc): + return True + except Exception: # nosec B112 - malformed allowed_origins entry should not crash + continue + + return False def _set_admin_csrf_cookie(request: Request, response: Response) -> str: diff --git a/tests/unit/mcpgateway/test_admin.py b/tests/unit/mcpgateway/test_admin.py index 4dad9beca7..f94424a2d7 100644 --- a/tests/unit/mcpgateway/test_admin.py +++ b/tests/unit/mcpgateway/test_admin.py @@ -20,8 +20,8 @@ # Third-Party from fastapi import FastAPI, HTTPException, Query, Request -from fastapi.testclient import TestClient from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response, StreamingResponse +from fastapi.testclient import TestClient from pydantic import ValidationError from pydantic_core import InitErrorDetails from pydantic_core import ValidationError as CoreValidationError @@ -249,8 +249,8 @@ update_global_passthrough_headers, update_observability_query, ) -from mcpgateway.middleware.request_logging_middleware import RequestLoggingMiddleware from mcpgateway.config import settings, UI_HIDABLE_HEADER_ITEMS, UI_HIDABLE_SECTIONS, UI_HIDE_SECTION_ALIASES +from mcpgateway.middleware.request_logging_middleware import RequestLoggingMiddleware from mcpgateway.schemas import ( GatewayTestRequest, GlobalConfigRead, @@ -503,7 +503,7 @@ async def admin_servers_endpoint(request: Request): "visibility": "private", "associatedTools": ["1", "2", "3"], "selectAllTools": "true", - "allToolIds": "[\"1\",\"2\",\"3\"]", + "allToolIds": '["1","2","3"]', }, ) @@ -16052,6 +16052,7 @@ def decode_html_entities(value: str) -> str: # Register tojson_attr filter (same as in main.py) for inline event handler escaping def tojson_attr(value: object) -> str: """JSON-encode a value for safe use inside double-quoted HTML attributes.""" + # Standard import json as _json s = _json.dumps(value) @@ -17898,9 +17899,277 @@ async def test_enforce_admin_csrf_rejects_when_form_parse_fails(self): with pytest.raises(HTTPException, match="token validation failed"): await admin_mod.enforce_admin_csrf(request) + # -- allowed_origins fallback tests ------------------------------------ + + @pytest.mark.asyncio + async def test_enforce_admin_csrf_accepts_allowed_origin_fallback(self, monkeypatch): + """Origin mismatches forwarded headers but is listed in allowed_origins.""" + # First-Party + from mcpgateway import admin as admin_mod + + monkeypatch.setattr("mcpgateway.admin.settings.allowed_origins", {"https://external.com"}) + + request = self._make_request( + method="POST", + headers={ + "origin": "https://external.com", + "x-forwarded-proto": "http", + "x-forwarded-host": "internal:4444", + "content-type": "application/x-www-form-urlencoded", + }, + cookies={"jwt_token": "jwt", admin_mod.ADMIN_CSRF_COOKIE_NAME: "expected"}, + form_data={admin_mod.ADMIN_CSRF_FORM_FIELD: "expected"}, + ) + await admin_mod.enforce_admin_csrf(request) + + @pytest.mark.asyncio + async def test_enforce_admin_csrf_rejects_wildcard_allowed_origin(self, monkeypatch): + """Wildcard in allowed_origins must not bypass CSRF origin check.""" + # First-Party + from mcpgateway import admin as admin_mod + + monkeypatch.setattr("mcpgateway.admin.settings.allowed_origins", {"*"}) + + request = self._make_request( + method="POST", + headers={ + "origin": "https://evil.com", + "host": "example.com", + "content-type": "application/x-www-form-urlencoded", + }, + cookies={"jwt_token": "jwt", admin_mod.ADMIN_CSRF_COOKIE_NAME: "expected"}, + form_data={admin_mod.ADMIN_CSRF_FORM_FIELD: "expected"}, + ) + + with pytest.raises(HTTPException, match="CSRF origin validation failed"): + await admin_mod.enforce_admin_csrf(request) + + @pytest.mark.asyncio + async def test_enforce_admin_csrf_rejects_null_allowed_origin(self, monkeypatch): + """'null' in allowed_origins must not bypass CSRF origin check.""" + # First-Party + from mcpgateway import admin as admin_mod + + monkeypatch.setattr("mcpgateway.admin.settings.allowed_origins", {"null"}) + + request = self._make_request( + method="POST", + headers={ + "origin": "https://evil.com", + "host": "example.com", + "content-type": "application/x-www-form-urlencoded", + }, + cookies={"jwt_token": "jwt", admin_mod.ADMIN_CSRF_COOKIE_NAME: "expected"}, + form_data={admin_mod.ADMIN_CSRF_FORM_FIELD: "expected"}, + ) + with pytest.raises(HTTPException, match="CSRF origin validation failed"): + await admin_mod.enforce_admin_csrf(request) + + @pytest.mark.asyncio + async def test_enforce_admin_csrf_rejects_blank_allowed_origin(self, monkeypatch): + """Blank string in allowed_origins must not bypass CSRF origin check.""" + # First-Party + from mcpgateway import admin as admin_mod + + monkeypatch.setattr("mcpgateway.admin.settings.allowed_origins", {""}) + + request = self._make_request( + method="POST", + headers={ + "origin": "https://evil.com", + "host": "example.com", + "content-type": "application/x-www-form-urlencoded", + }, + cookies={"jwt_token": "jwt", admin_mod.ADMIN_CSRF_COOKIE_NAME: "expected"}, + form_data={admin_mod.ADMIN_CSRF_FORM_FIELD: "expected"}, + ) + with pytest.raises(HTTPException, match="CSRF origin validation failed"): + await admin_mod.enforce_admin_csrf(request) + + @pytest.mark.asyncio + async def test_enforce_admin_csrf_skips_empty_scheme_netloc_origin(self, monkeypatch): + """Entry like '://' that has separator but empty scheme/netloc is skipped.""" + # First-Party + from mcpgateway import admin as admin_mod + + monkeypatch.setattr("mcpgateway.admin.settings.allowed_origins", {"://"}) + + request = self._make_request( + method="POST", + headers={ + "origin": "https://evil.com", + "host": "example.com", + "content-type": "application/x-www-form-urlencoded", + }, + cookies={"jwt_token": "jwt", admin_mod.ADMIN_CSRF_COOKIE_NAME: "expected"}, + form_data={admin_mod.ADMIN_CSRF_FORM_FIELD: "expected"}, + ) + with pytest.raises(HTTPException, match="CSRF origin validation failed"): + await admin_mod.enforce_admin_csrf(request) + + @pytest.mark.asyncio + async def test_enforce_admin_csrf_accepts_schemeless_allowed_origin(self, monkeypatch): + """Bare hostname in allowed_origins gets https:// prepended (consistent with SSO).""" + # First-Party + from mcpgateway import admin as admin_mod + + monkeypatch.setattr("mcpgateway.admin.settings.allowed_origins", {"external.com"}) + + request = self._make_request( + method="POST", + headers={ + "origin": "https://external.com", + "x-forwarded-proto": "http", + "x-forwarded-host": "internal:4444", + "content-type": "application/x-www-form-urlencoded", + }, + cookies={"jwt_token": "jwt", admin_mod.ADMIN_CSRF_COOKIE_NAME: "expected"}, + form_data={admin_mod.ADMIN_CSRF_FORM_FIELD: "expected"}, + ) + await admin_mod.enforce_admin_csrf(request) + + @pytest.mark.asyncio + async def test_enforce_admin_csrf_rejects_unlisted_origin(self, monkeypatch): + """Origin not in allowed_origins must be rejected.""" + # First-Party + from mcpgateway import admin as admin_mod + + monkeypatch.setattr("mcpgateway.admin.settings.allowed_origins", {"https://legit.com"}) + + request = self._make_request( + method="POST", + headers={ + "origin": "https://attacker.com", + "host": "internal:4444", + "content-type": "application/x-www-form-urlencoded", + }, + cookies={"jwt_token": "jwt", admin_mod.ADMIN_CSRF_COOKIE_NAME: "expected"}, + form_data={admin_mod.ADMIN_CSRF_FORM_FIELD: "expected"}, + ) + with pytest.raises(HTTPException, match="CSRF origin validation failed"): + await admin_mod.enforce_admin_csrf(request) + + @pytest.mark.asyncio + async def test_enforce_admin_csrf_allowed_origin_port_normalization(self, monkeypatch): + """Default-port origins must match even when port is explicit in allowed_origins.""" + # First-Party + from mcpgateway import admin as admin_mod + + monkeypatch.setattr("mcpgateway.admin.settings.allowed_origins", {"https://gw.com:443"}) + + request = self._make_request( + method="POST", + headers={ + "origin": "https://gw.com", + "host": "internal:4444", + "content-type": "application/x-www-form-urlencoded", + }, + cookies={"jwt_token": "jwt", admin_mod.ADMIN_CSRF_COOKIE_NAME: "expected"}, + form_data={admin_mod.ADMIN_CSRF_FORM_FIELD: "expected"}, + ) + await admin_mod.enforce_admin_csrf(request) + + @pytest.mark.asyncio + async def test_enforce_admin_csrf_rejects_non_matching_bare_host(self, monkeypatch): + """Bare hostname gets https:// prepended but still rejects non-matching origin.""" + # First-Party + from mcpgateway import admin as admin_mod + + # "just-a-path" gets https:// prepended → https://just-a-path, which + # does not match the attacker origin and must be rejected. + monkeypatch.setattr("mcpgateway.admin.settings.allowed_origins", {"just-a-path"}) + + request = self._make_request( + method="POST", + headers={ + "origin": "https://evil.com", + "host": "example.com", + "content-type": "application/x-www-form-urlencoded", + }, + cookies={"jwt_token": "jwt", admin_mod.ADMIN_CSRF_COOKIE_NAME: "expected"}, + form_data={admin_mod.ADMIN_CSRF_FORM_FIELD: "expected"}, + ) + with pytest.raises(HTTPException, match="CSRF origin validation failed"): + await admin_mod.enforce_admin_csrf(request) + + @pytest.mark.asyncio + async def test_enforce_admin_csrf_survives_malformed_allowed_origin(self, monkeypatch): + """A malformed allowed_origins entry that triggers an exception must not crash.""" + # First-Party + from unittest.mock import patch + + from mcpgateway import admin as admin_mod + + # Only the malformed entry — forces the except branch to run, then the + # function falls through to return False and CSRF validation fails. + monkeypatch.setattr("mcpgateway.admin.settings.allowed_origins", {"will-explode://bad"}) + + real_normalize = admin_mod._normalize_origin_parts + + def _boom_on_bad(scheme, netloc): + if netloc == "bad": + raise ValueError("intentional test boom") + return real_normalize(scheme, netloc) + + request = self._make_request( + method="POST", + headers={ + "origin": "https://attacker.com", + "host": "internal:4444", + "content-type": "application/x-www-form-urlencoded", + }, + cookies={"jwt_token": "jwt", admin_mod.ADMIN_CSRF_COOKIE_NAME: "expected"}, + form_data={admin_mod.ADMIN_CSRF_FORM_FIELD: "expected"}, + ) + with patch.object(admin_mod, "_normalize_origin_parts", side_effect=_boom_on_bad): + with pytest.raises(HTTPException, match="CSRF origin validation failed"): + await admin_mod.enforce_admin_csrf(request) + + @pytest.mark.asyncio + async def test_enforce_admin_csrf_empty_allowed_origins(self, monkeypatch): + """Empty allowed_origins set must not bypass CSRF origin check.""" + # First-Party + from mcpgateway import admin as admin_mod + + monkeypatch.setattr("mcpgateway.admin.settings.allowed_origins", set()) + + request = self._make_request( + method="POST", + headers={ + "origin": "https://external.com", + "host": "internal:4444", + "content-type": "application/x-www-form-urlencoded", + }, + cookies={"jwt_token": "jwt", admin_mod.ADMIN_CSRF_COOKIE_NAME: "expected"}, + form_data={admin_mod.ADMIN_CSRF_FORM_FIELD: "expected"}, + ) + with pytest.raises(HTTPException, match="CSRF origin validation failed"): + await admin_mod.enforce_admin_csrf(request) + + @pytest.mark.asyncio + async def test_enforce_admin_csrf_direct_match_still_works(self, monkeypatch): + """Direct same-origin match must pass even with empty allowed_origins (regression guard).""" + # First-Party + from mcpgateway import admin as admin_mod + + monkeypatch.setattr("mcpgateway.admin.settings.allowed_origins", set()) + + request = self._make_request( + method="POST", + headers={ + "origin": "https://example.com", + "host": "example.com", + "content-type": "application/x-www-form-urlencoded", + }, + cookies={"jwt_token": "jwt", admin_mod.ADMIN_CSRF_COOKIE_NAME: "expected"}, + form_data={admin_mod.ADMIN_CSRF_FORM_FIELD: "expected"}, + ) + await admin_mod.enforce_admin_csrf(request) + # -- _resolve_root_path tests ------------------------------------------ def test_resolve_root_path_prefers_scope_root_path(self, monkeypatch): + # First-Party from mcpgateway import admin as admin_mod monkeypatch.setattr("mcpgateway.admin.settings.app_root_path", "/fallback", raising=False) @@ -17910,6 +18179,7 @@ def test_resolve_root_path_prefers_scope_root_path(self, monkeypatch): assert admin_mod._resolve_root_path(request) == "/mounted" def test_resolve_root_path_falls_back_to_settings(self, monkeypatch): + # First-Party from mcpgateway import admin as admin_mod monkeypatch.setattr("mcpgateway.admin.settings.app_root_path", "/api/proxy/mcp", raising=False) @@ -17919,6 +18189,7 @@ def test_resolve_root_path_falls_back_to_settings(self, monkeypatch): assert admin_mod._resolve_root_path(request) == "/api/proxy/mcp" def test_resolve_root_path_returns_empty_when_both_empty(self, monkeypatch): + # First-Party from mcpgateway import admin as admin_mod monkeypatch.setattr("mcpgateway.admin.settings.app_root_path", "", raising=False) @@ -17928,6 +18199,7 @@ def test_resolve_root_path_returns_empty_when_both_empty(self, monkeypatch): assert admin_mod._resolve_root_path(request) == "" def test_resolve_root_path_normalizes_leading_slash(self, monkeypatch): + # First-Party from mcpgateway import admin as admin_mod monkeypatch.setattr("mcpgateway.admin.settings.app_root_path", "api/proxy/mcp", raising=False) @@ -17937,6 +18209,7 @@ def test_resolve_root_path_normalizes_leading_slash(self, monkeypatch): assert admin_mod._resolve_root_path(request) == "/api/proxy/mcp" def test_resolve_root_path_strips_trailing_slash(self, monkeypatch): + # First-Party from mcpgateway import admin as admin_mod monkeypatch.setattr("mcpgateway.admin.settings.app_root_path", "", raising=False) @@ -17946,6 +18219,7 @@ def test_resolve_root_path_strips_trailing_slash(self, monkeypatch): assert admin_mod._resolve_root_path(request) == "/mounted" def test_resolve_root_path_missing_scope_key_falls_back(self, monkeypatch): + # First-Party from mcpgateway import admin as admin_mod monkeypatch.setattr("mcpgateway.admin.settings.app_root_path", "/api/proxy/mcp", raising=False) @@ -17955,6 +18229,7 @@ def test_resolve_root_path_missing_scope_key_falls_back(self, monkeypatch): assert admin_mod._resolve_root_path(request) == "/api/proxy/mcp" def test_resolve_root_path_none_settings_returns_empty(self, monkeypatch): + # First-Party from mcpgateway import admin as admin_mod monkeypatch.setattr("mcpgateway.admin.settings.app_root_path", None, raising=False) @@ -17964,6 +18239,7 @@ def test_resolve_root_path_none_settings_returns_empty(self, monkeypatch): assert admin_mod._resolve_root_path(request) == "" def test_resolve_root_path_scope_none_value_falls_back(self, monkeypatch): + # First-Party from mcpgateway import admin as admin_mod monkeypatch.setattr("mcpgateway.admin.settings.app_root_path", "/fallback", raising=False) @@ -17973,6 +18249,7 @@ def test_resolve_root_path_scope_none_value_falls_back(self, monkeypatch): assert admin_mod._resolve_root_path(request) == "/fallback" def test_resolve_root_path_strips_scheme_relative_double_slash(self, monkeypatch): + # First-Party from mcpgateway import admin as admin_mod monkeypatch.setattr("mcpgateway.admin.settings.app_root_path", "", raising=False) @@ -17982,6 +18259,7 @@ def test_resolve_root_path_strips_scheme_relative_double_slash(self, monkeypatch assert admin_mod._resolve_root_path(request) == "/evil.com" def test_resolve_root_path_whitespace_only_scope_falls_back(self, monkeypatch): + # First-Party from mcpgateway import admin as admin_mod monkeypatch.setattr("mcpgateway.admin.settings.app_root_path", "/fallback", raising=False) @@ -18411,6 +18689,7 @@ async def test_create_grpc_service_blocks_public_when_flag_false(self, mock_requ monkeypatch.setattr("mcpgateway.admin.settings.allow_public_visibility", False) monkeypatch.setattr("mcpgateway.admin.GRPC_AVAILABLE", True) monkeypatch.setattr("mcpgateway.admin.settings.mcpgateway_grpc_enabled", True) + # First-Party from mcpgateway.schemas import GrpcServiceCreate service = GrpcServiceCreate(name="G", target="localhost:50051", visibility="public", team_id="team-abc") @@ -18476,6 +18755,7 @@ async def test_create_grpc_service_allows_public_when_flag_false_no_team_id(self mock_mgr = MagicMock() mock_mgr.register_service = AsyncMock(return_value={"id": "svc-new", "name": "G"}) monkeypatch.setattr("mcpgateway.admin.grpc_service_mgr", mock_mgr) + # First-Party from mcpgateway.schemas import GrpcServiceCreate service = GrpcServiceCreate(name="G", target="localhost:50051", visibility="public")