Skip to content

Commit d6c2335

Browse files
committed
fix: harden internal MCP trust boundaries
Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
1 parent 46ef080 commit d6c2335

File tree

8 files changed

+531
-84
lines changed

8 files changed

+531
-84
lines changed

mcpgateway/main.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from datetime import datetime, timezone
3434
from functools import lru_cache
3535
import hashlib
36+
import hmac
3637
import html
3738
import re
3839
import sys
@@ -309,6 +310,8 @@ def get_user_email(user):
309310

310311

311312
_INTERNAL_MCP_AUTH_CONTEXT_HEADER = "x-contextforge-auth-context"
313+
_INTERNAL_MCP_RUNTIME_AUTH_HEADER = "x-contextforge-mcp-runtime-auth"
314+
_INTERNAL_MCP_RUNTIME_AUTH_CONTEXT = "contextforge-internal-mcp-runtime-v1"
312315
_INTERNAL_MCP_SESSION_VALIDATED_HEADER = "x-contextforge-session-validated"
313316

314317

@@ -347,6 +350,33 @@ def _decode_internal_mcp_auth_context(header_value: str) -> Dict[str, Any]:
347350
return payload
348351

349352

353+
@lru_cache(maxsize=1)
354+
def _expected_internal_mcp_runtime_auth_header() -> str:
355+
"""Return the shared secret-derived trust header for Rust->Python MCP hops.
356+
357+
Returns:
358+
Hex-encoded SHA-256 digest derived from the shared auth secret.
359+
"""
360+
secret = settings.auth_encryption_secret.get_secret_value()
361+
material = f"{secret}:{_INTERNAL_MCP_RUNTIME_AUTH_CONTEXT}".encode("utf-8")
362+
return hashlib.sha256(material).hexdigest()
363+
364+
365+
def _has_valid_internal_mcp_runtime_auth_header(request: Request) -> bool:
366+
"""Validate the shared secret-derived trust header for internal MCP requests.
367+
368+
Args:
369+
request: Incoming internal MCP request.
370+
371+
Returns:
372+
``True`` when the derived trust header matches the expected value.
373+
"""
374+
provided = request.headers.get(_INTERNAL_MCP_RUNTIME_AUTH_HEADER)
375+
if not provided:
376+
return False
377+
return hmac.compare_digest(provided, _expected_internal_mcp_runtime_auth_header())
378+
379+
350380
def _is_trusted_internal_mcp_runtime_request(request: Request) -> bool:
351381
"""Return whether the request came from the local Rust runtime sidecar.
352382
@@ -359,7 +389,7 @@ def _is_trusted_internal_mcp_runtime_request(request: Request) -> bool:
359389
"""
360390
runtime_marker = request.headers.get("x-contextforge-mcp-runtime")
361391
client_host = getattr(getattr(request, "client", None), "host", None)
362-
return runtime_marker == "rust" and client_host in ("127.0.0.1", "::1")
392+
return runtime_marker == "rust" and _has_valid_internal_mcp_runtime_auth_header(request) and client_host in ("127.0.0.1", "::1")
363393

364394

365395
def _build_internal_mcp_forwarded_user(request: Request) -> Dict[str, Any]:
@@ -9290,9 +9320,7 @@ def _lowered_request_headers() -> Dict[str, str]:
92909320
# Catch-all for other completion/* methods (currently unsupported)
92919321
result = {}
92929322
elif method == "logging/setLevel":
9293-
# MCP logging/setLevel is a standard MCP capability invoked by clients during
9294-
# initialization; servers.use (not admin.system_config) keeps the handshake working.
9295-
await _ensure_rpc_permission(user, db, "servers.use", method, request=request)
9323+
await _ensure_rpc_permission(user, db, "admin.system_config", method, request=request)
92969324
level = LogLevel(params.get("level"))
92979325
await logging_service.set_level(level)
92989326
result = {}

mcpgateway/middleware/token_scoping.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
# Standard
1414
from datetime import datetime, timedelta, timezone
1515
from functools import lru_cache
16+
import hashlib
17+
import hmac
1618
import ipaddress
1719
import re
1820
from typing import List, Optional, Pattern, Tuple
@@ -62,6 +64,8 @@
6264
_INTERNAL_MCP_PATH_PREFIX = "/_internal/mcp"
6365
_INTERNAL_MCP_RUNTIME_HEADER = "x-contextforge-mcp-runtime"
6466
_INTERNAL_MCP_AUTH_CONTEXT_HEADER = "x-contextforge-auth-context"
67+
_INTERNAL_MCP_RUNTIME_AUTH_HEADER = "x-contextforge-mcp-runtime-auth"
68+
_INTERNAL_MCP_RUNTIME_AUTH_CONTEXT = "contextforge-internal-mcp-runtime-v1"
6569

6670
# Permission map with precompiled patterns
6771
# Maps (HTTP method, path pattern) to required permission
@@ -1355,12 +1359,32 @@ def _is_trusted_internal_mcp_runtime_request(self, request: Request, normalized_
13551359
if request.headers.get(_INTERNAL_MCP_RUNTIME_HEADER) != "rust":
13561360
return False
13571361

1362+
provided_auth = request.headers.get(_INTERNAL_MCP_RUNTIME_AUTH_HEADER)
1363+
if not provided_auth:
1364+
return False
1365+
1366+
expected_auth = self._expected_internal_mcp_runtime_auth_header()
1367+
if not hmac.compare_digest(provided_auth, expected_auth):
1368+
return False
1369+
13581370
if not request.headers.get(_INTERNAL_MCP_AUTH_CONTEXT_HEADER):
13591371
return False
13601372

13611373
client_host = getattr(getattr(request, "client", None), "host", None)
13621374
return client_host in ("127.0.0.1", "::1")
13631375

1376+
@staticmethod
1377+
@lru_cache(maxsize=1)
1378+
def _expected_internal_mcp_runtime_auth_header() -> str:
1379+
"""Return the expected shared internal-auth header for Rust MCP hops.
1380+
1381+
Returns:
1382+
Shared secret-derived digest expected on trusted internal Rust MCP calls.
1383+
"""
1384+
secret = settings.auth_encryption_secret.get_secret_value()
1385+
material = f"{secret}:{_INTERNAL_MCP_RUNTIME_AUTH_CONTEXT}".encode("utf-8")
1386+
return hashlib.sha256(material).hexdigest()
1387+
13641388

13651389
# Create middleware instance
13661390
token_scoping_middleware = TokenScopingMiddleware()

mcpgateway/transports/streamablehttp_transport.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2223,14 +2223,12 @@ async def set_logging_level(level: types.LoggingLevel) -> types.EmptyResult:
22232223

22242224
if _should_enforce_streamable_rbac(user_context):
22252225
# Layer 1: Token scope cap
2226-
# MCP logging/setLevel is a standard MCP capability invoked by clients during
2227-
# initialization; servers.use (not admin.system_config) keeps the handshake working.
2228-
if not _check_scoped_permission(user_context, "servers.use"):
2226+
if not _check_scoped_permission(user_context, "admin.system_config"):
22292227
raise PermissionError(_ACCESS_DENIED_MSG)
22302228
# Layer 2: RBAC check
22312229
has_permission = await _check_streamable_permission(
22322230
user_context=user_context,
2233-
permission="servers.use",
2231+
permission="admin.system_config",
22342232
check_any_team=_check_any_team_for_server_scoped_rbac(user_context, server_id),
22352233
)
22362234
if not has_permission:

tests/unit/mcpgateway/middleware/test_token_scoping.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"""
1313

1414
# Standard
15+
import hashlib
1516
import json
1617
from unittest.mock import AsyncMock, MagicMock, patch
1718

@@ -21,10 +22,21 @@
2122
import pytest
2223

2324
# First-Party
25+
from mcpgateway.config import settings
2426
from mcpgateway.db import Permissions
2527
from mcpgateway.middleware.token_scoping import _get_llm_permission_patterns, TokenScopingMiddleware
2628

2729

30+
def _trusted_internal_runtime_headers() -> dict[str, str]:
31+
secret = settings.auth_encryption_secret.get_secret_value()
32+
expected = hashlib.sha256(f"{secret}:contextforge-internal-mcp-runtime-v1".encode("utf-8")).hexdigest()
33+
return {
34+
"x-contextforge-mcp-runtime": "rust",
35+
"x-contextforge-mcp-runtime-auth": expected,
36+
"x-contextforge-auth-context": "trusted-payload",
37+
}
38+
39+
2840
@pytest.fixture(autouse=True)
2941
def clear_llm_permission_pattern_cache():
3042
"""Clear cached LLM permission regex patterns between tests."""
@@ -148,11 +160,7 @@ async def test_trusted_internal_mcp_runtime_request_bypasses_token_scoping(self,
148160
mock_request.url.path = "/_internal/mcp/rpc"
149161
mock_request.scope["path"] = "/_internal/mcp/rpc"
150162
mock_request.method = "POST"
151-
mock_request.headers = {
152-
"Authorization": "Bearer scoped-token",
153-
"x-contextforge-mcp-runtime": "rust",
154-
"x-contextforge-auth-context": "trusted-payload",
155-
}
163+
mock_request.headers = {"Authorization": "Bearer scoped-token", **_trusted_internal_runtime_headers()}
156164

157165
call_next = AsyncMock(return_value="ok")
158166
with patch.object(middleware, "_extract_token_scopes", new=AsyncMock(side_effect=AssertionError("token scoping should be bypassed"))):
@@ -168,11 +176,7 @@ async def test_untrusted_internal_mcp_runtime_request_still_enforces_token_scopi
168176
mock_request.scope["path"] = "/_internal/mcp/rpc"
169177
mock_request.method = "POST"
170178
mock_request.client.host = "10.0.0.8"
171-
mock_request.headers = {
172-
"Authorization": "Bearer scoped-token",
173-
"x-contextforge-mcp-runtime": "rust",
174-
"x-contextforge-auth-context": "trusted-payload",
175-
}
179+
mock_request.headers = {"Authorization": "Bearer scoped-token", **_trusted_internal_runtime_headers()}
176180

177181
payload = {"sub": "user@example.com", "scopes": {"permissions": ["tools.read"]}}
178182
with (
@@ -219,6 +223,7 @@ async def test_internal_mcp_request_without_auth_context_does_not_bypass(self, m
219223
mock_request.headers = {
220224
"Authorization": "Bearer scoped-token",
221225
"x-contextforge-mcp-runtime": "rust",
226+
"x-contextforge-mcp-runtime-auth": _trusted_internal_runtime_headers()["x-contextforge-mcp-runtime-auth"],
222227
}
223228

224229
with (

tests/unit/mcpgateway/test_main_extended.py

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
InternalTrustedMCPTransportBridge,
4040
MCPRuntimeHeaderTransportWrapper,
4141
MCPPathRewriteMiddleware,
42+
_expected_internal_mcp_runtime_auth_header,
4243
_build_internal_mcp_auth_scope,
4344
_build_internal_mcp_forwarded_user,
4445
_decode_internal_mcp_auth_context,
@@ -133,6 +134,17 @@ def _make_request(
133134
return request
134135

135136

137+
def _trusted_internal_mcp_headers(auth_context: dict[str, object], **extra_headers: str) -> dict[str, str]:
138+
"""Build trusted Rust->Python internal MCP headers for unit tests."""
139+
headers = {
140+
"x-contextforge-mcp-runtime": "rust",
141+
"x-contextforge-mcp-runtime-auth": _expected_internal_mcp_runtime_auth_header(),
142+
"x-contextforge-auth-context": base64.urlsafe_b64encode(orjson.dumps(auth_context)).decode().rstrip("="),
143+
}
144+
headers.update(extra_headers)
145+
return headers
146+
147+
136148
def _import_fresh_main_module(
137149
monkeypatch: pytest.MonkeyPatch,
138150
*,
@@ -382,6 +394,7 @@ async def handle_streamable_http(self, scope, receive, send):
382394
"query_string": b"session_id=abc123",
383395
"headers": [
384396
(b"x-contextforge-mcp-runtime", b"rust"),
397+
(b"x-contextforge-mcp-runtime-auth", _expected_internal_mcp_runtime_auth_header().encode("ascii")),
385398
(b"x-contextforge-auth-context", encoded_auth.encode("ascii")),
386399
(b"x-contextforge-server-id", b"server-1"),
387400
],
@@ -443,6 +456,7 @@ async def handle_streamable_http(self, _scope, _receive, send):
443456
"query_string": b"session_id=abc123",
444457
"headers": [
445458
(b"x-contextforge-mcp-runtime", b"rust"),
459+
(b"x-contextforge-mcp-runtime-auth", _expected_internal_mcp_runtime_auth_header().encode("ascii")),
446460
(b"x-contextforge-auth-context", encoded_auth.encode("ascii")),
447461
(b"x-contextforge-session-validated", b"rust"),
448462
],
@@ -503,6 +517,7 @@ async def handle_streamable_http(self, scope, receive, send):
503517
"query_string": b"",
504518
"headers": [
505519
(b"x-contextforge-mcp-runtime", b"rust"),
520+
(b"x-contextforge-mcp-runtime-auth", _expected_internal_mcp_runtime_auth_header().encode("ascii")),
506521
(b"x-contextforge-auth-context", encoded_auth.encode("ascii")),
507522
(b"x-contextforge-server-id", b"server-1"),
508523
],
@@ -532,7 +547,10 @@ async def test_bridge_rejects_missing_internal_auth_context(self):
532547
"method": "GET",
533548
"path": "/_internal/mcp/transport",
534549
"query_string": b"",
535-
"headers": [(b"x-contextforge-mcp-runtime", b"rust")],
550+
"headers": [
551+
(b"x-contextforge-mcp-runtime", b"rust"),
552+
(b"x-contextforge-mcp-runtime-auth", _expected_internal_mcp_runtime_auth_header().encode("ascii")),
553+
],
536554
"client": ("127.0.0.1", 5000),
537555
}
538556

@@ -864,6 +882,7 @@ def test_build_internal_mcp_forwarded_user_rejects_invalid_auth_context(self):
864882
request = MagicMock(spec=Request)
865883
request.headers = {
866884
"x-contextforge-mcp-runtime": "rust",
885+
"x-contextforge-mcp-runtime-auth": _expected_internal_mcp_runtime_auth_header(),
867886
"x-contextforge-auth-context": "not-base64",
868887
}
869888
request.client = SimpleNamespace(host="127.0.0.1")
@@ -875,28 +894,37 @@ def test_build_internal_mcp_forwarded_user_rejects_invalid_auth_context(self):
875894
assert excinfo.value.status_code == 400
876895
assert "Invalid trusted MCP auth context" in excinfo.value.detail
877896

878-
def test_build_internal_mcp_forwarded_user_sets_session_validated_and_token_teams(self):
879-
"""Trusted forwarded auth should copy teams and set the Rust session validation marker."""
897+
def test_build_internal_mcp_forwarded_user_requires_internal_runtime_auth_header(self):
898+
"""Trusted Rust forwarding must include the shared internal-auth header."""
880899
request = MagicMock(spec=Request)
881900
request.headers = {
882901
"x-contextforge-mcp-runtime": "rust",
883-
"x-contextforge-session-validated": "rust",
884-
"x-contextforge-auth-context": base64.urlsafe_b64encode(
885-
orjson.dumps(
886-
{
887-
"email": "user@example.com",
888-
"teams": ["team-a"],
889-
"is_authenticated": True,
890-
"is_admin": False,
891-
"permission_is_admin": True,
892-
"token_use": "session",
893-
}
894-
)
895-
)
896-
.decode()
897-
.rstrip("="),
902+
"x-contextforge-auth-context": base64.urlsafe_b64encode(orjson.dumps({"email": "user@example.com"})).decode().rstrip("="),
898903
}
899904
request.client = SimpleNamespace(host="127.0.0.1")
905+
request.state = MagicMock()
906+
907+
with pytest.raises(HTTPException) as excinfo:
908+
_build_internal_mcp_forwarded_user(request)
909+
910+
assert excinfo.value.status_code == 403
911+
assert "only available to the local Rust runtime" in excinfo.value.detail
912+
913+
def test_build_internal_mcp_forwarded_user_sets_session_validated_and_token_teams(self):
914+
"""Trusted forwarded auth should copy teams and set the Rust session validation marker."""
915+
request = MagicMock(spec=Request)
916+
request.headers = _trusted_internal_mcp_headers(
917+
{
918+
"email": "user@example.com",
919+
"teams": ["team-a"],
920+
"is_authenticated": True,
921+
"is_admin": False,
922+
"permission_is_admin": True,
923+
"token_use": "session",
924+
},
925+
**{"x-contextforge-session-validated": "rust"},
926+
)
927+
request.client = SimpleNamespace(host="127.0.0.1")
900928
request.state = SimpleNamespace()
901929

902930
forwarded = _build_internal_mcp_forwarded_user(request)
@@ -4814,6 +4842,19 @@ async def test_list_and_get_a2a_agents_branches(self, monkeypatch):
48144842
class TestRpcHandling:
48154843
"""Cover RPC handler branches."""
48164844

4845+
@pytest.fixture(autouse=True)
4846+
def _trust_internal_rust_headers_for_handler_logic_tests(self, monkeypatch):
4847+
"""Keep this suite focused on handler logic, not trust-boundary validation.
4848+
4849+
The trust boundary itself is covered separately by the dedicated helper
4850+
and middleware tests above.
4851+
"""
4852+
monkeypatch.setattr(
4853+
"mcpgateway.main._is_trusted_internal_mcp_runtime_request",
4854+
lambda request: request.headers.get("x-contextforge-mcp-runtime") == "rust"
4855+
and getattr(getattr(request, "client", None), "host", None) in ("127.0.0.1", "::1"),
4856+
)
4857+
48174858
@staticmethod
48184859
def _make_request(payload: dict) -> MagicMock:
48194860
request = MagicMock(spec=Request)
@@ -10784,18 +10825,18 @@ async def test_resources_subscribe_denied_with_servers_use_only(self):
1078410825
assert result["error"]["code"] == -32003
1078510826
assert "Access denied" in result["error"]["message"]
1078610827

10787-
async def test_logging_set_level_allowed_with_servers_use(self):
10788-
"""Token scoped to servers.use should be allowed logging/setLevel."""
10828+
async def test_logging_set_level_allowed_with_admin_system_config(self):
10829+
"""Token scoped to admin.system_config should be allowed logging/setLevel."""
1078910830
payload = {"jsonrpc": "2.0", "id": 1, "method": "logging/setLevel", "params": {"level": "error"}}
10790-
request = self._make_request(payload, scoped_permissions=["servers.use"])
10831+
request = self._make_request(payload, scoped_permissions=["admin.system_config"])
1079110832

1079210833
result = await handle_rpc(request, db=MagicMock(), user={"email": "user@example.com"})
1079310834
assert "error" not in result
1079410835

10795-
async def test_logging_set_level_denied_without_servers_use(self):
10796-
"""Token scoped to tools.read only (no servers.use) should be denied logging/setLevel."""
10836+
async def test_logging_set_level_denied_without_admin_system_config(self):
10837+
"""Token scoped without admin.system_config should be denied logging/setLevel."""
1079710838
payload = {"jsonrpc": "2.0", "id": 1, "method": "logging/setLevel", "params": {"level": "error"}}
10798-
request = self._make_request(payload, scoped_permissions=["tools.read"])
10839+
request = self._make_request(payload, scoped_permissions=["servers.use"])
1079910840

1080010841
result = await handle_rpc(request, db=MagicMock(), user={"email": "user@example.com"})
1080110842
assert result["error"]["code"] == -32003

tests/unit/mcpgateway/transports/test_streamablehttp_transport.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3667,7 +3667,7 @@ async def test_set_logging_level_exception():
36673667

36683668
@pytest.mark.asyncio
36693669
async def test_set_logging_level_requires_servers_use(monkeypatch):
3670-
"""logging/setLevel requires servers.use permission for authenticated users."""
3670+
"""logging/setLevel requires admin.system_config permission for authenticated users."""
36713671
# First-Party
36723672
from mcpgateway.transports.streamablehttp_transport import set_logging_level
36733673

@@ -3691,15 +3691,15 @@ async def test_set_logging_level_requires_servers_use(monkeypatch):
36913691
mock_logging_service.set_level = AsyncMock()
36923692
monkeypatch.setattr("mcpgateway.transports.streamablehttp_transport.logging_service", mock_logging_service)
36933693

3694-
# Should raise PermissionError for non-admin user without servers.use
3694+
# Should raise PermissionError for non-admin user without admin.system_config
36953695
with pytest.raises(PermissionError, match="Access denied"):
36963696
await set_logging_level("info")
36973697
mock_logging_service.set_level.assert_not_called()
36983698

36993699

37003700
@pytest.mark.asyncio
37013701
async def test_set_logging_level_admin_allowed(monkeypatch):
3702-
"""logging/setLevel succeeds when the caller has servers.use permission."""
3702+
"""logging/setLevel succeeds when the caller has admin.system_config permission."""
37033703
# Third-Party
37043704
from mcp import types as mcp_types
37053705

0 commit comments

Comments
 (0)