Skip to content

Commit 593913c

Browse files
authored
Merge pull request #5246 from IBM/fix/internal-mcp-middleware-exempt
Exempt the trusted-internal dispatch from edge middleware
2 parents 6172a4e + 14ee593 commit 593913c

11 files changed

Lines changed: 370 additions & 80 deletions

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ CSRF_ROTATE_ON_LOGIN=true
6464
CSRF_TRUSTED_ORIGINS=["http://localhost:4444","http://localhost:8080"]
6565

6666
# Paths exempt from CSRF protection (JSON array)
67-
CSRF_EXEMPT_PATHS=["/health","/auth/login","/auth/logout","/auth/refresh","/auth/email/login","/auth/email/register","/auth/email/forgot-password","/auth/email/reset-password","/admin","/oauth/fetch-tools","/docs","/redoc","/openapi.json","/metrics","/mcp/","/sse","/message"]
67+
CSRF_EXEMPT_PATHS=["/health","/auth/login","/auth/logout","/auth/refresh","/auth/email/login","/auth/email/register","/auth/email/forgot-password","/auth/email/reset-password","/admin","/oauth/fetch-tools","/docs","/redoc","/openapi.json","/metrics","/mcp/","/sse","/message","/_internal/mcp/"]
6868

6969
# Bootstrap admin credentials (email auth)
7070
# PRODUCTION: Change these values

mcpgateway/auth_context.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,94 @@ def has_valid_internal_mcp_runtime_auth_header(request: Request) -> bool:
310310
return hmac.compare_digest(provided, _expected_internal_mcp_runtime_auth_header())
311311

312312

313+
# Internal-dispatch trust gate, defined once and shared by all callers.
314+
INTERNAL_MCP_PATH_PREFIX = "/_internal/mcp"
315+
INTERNAL_A2A_PATH_PREFIX = "/_internal/a2a"
316+
INTERNAL_RUNTIME_MARKER_HEADER = "x-contextforge-mcp-runtime"
317+
INTERNAL_AUTH_CONTEXT_HEADER = "x-contextforge-auth-context"
318+
TRUSTED_INTERNAL_RUNTIME_MARKERS = frozenset({"rust", "affinity"})
319+
320+
321+
def _internal_path_requires_auth_context(path: str) -> bool:
322+
"""Whether an internal route requires an auth context.
323+
324+
Only ``*/authenticate`` is exempt, since it creates the context; every
325+
other internal route must carry one.
326+
327+
Args:
328+
path: The request path to classify.
329+
330+
Returns:
331+
``False`` for ``*/authenticate``, otherwise ``True``.
332+
"""
333+
return not path.rstrip("/").endswith("/authenticate")
334+
335+
336+
def is_trusted_internal_runtime_request(
337+
request: Request,
338+
*,
339+
allowed_prefixes: tuple[str, ...],
340+
require_auth_context: bool,
341+
path: Optional[str] = None,
342+
) -> bool:
343+
"""Return whether a request is a trusted in-process internal-runtime hop.
344+
345+
The trust boundary is the HMAC header and, when required, the encoded
346+
``x-contextforge-auth-context``. The loopback check is defense in depth,
347+
not an independent gate: ProxyHeaders(trusted_hosts="*") lets a direct
348+
external caller influence ``request.client.host`` (the replay is hardened
349+
separately by stripping forwarded / client-IP headers).
350+
351+
Args:
352+
request: Incoming request to inspect.
353+
allowed_prefixes: Internal path prefixes this caller trusts.
354+
require_auth_context: Require a non-empty ``x-contextforge-auth-context``.
355+
path: Explicit path override for callers that strip a ``root_path``;
356+
defaults to ``request.url.path``.
357+
358+
Returns:
359+
``True`` only when prefix, runtime marker, HMAC, optional auth context,
360+
and loopback all hold; otherwise ``False``.
361+
"""
362+
p = path if path is not None else (getattr(getattr(request, "url", None), "path", "") or "")
363+
if not any(p == prefix or p.startswith(f"{prefix}/") for prefix in allowed_prefixes):
364+
return False
365+
if request.headers.get(INTERNAL_RUNTIME_MARKER_HEADER) not in TRUSTED_INTERNAL_RUNTIME_MARKERS:
366+
return False
367+
if not has_valid_internal_mcp_runtime_auth_header(request):
368+
return False
369+
if require_auth_context and not request.headers.get(INTERNAL_AUTH_CONTEXT_HEADER):
370+
return False
371+
client_host = getattr(getattr(request, "client", None), "host", None)
372+
return client_host in ("127.0.0.1", "::1")
373+
374+
375+
def is_trusted_internal_mcp_request(request: Request, *, path: Optional[str] = None) -> bool:
376+
"""MCP + A2A internal trust gate with a path-aware auth-context requirement.
377+
378+
``*/authenticate`` routes are exempt from the auth-context requirement;
379+
every other internal route requires it. ``/_internal/a2a/*`` is trusted
380+
only when the A2A feature is enabled.
381+
382+
Args:
383+
request: Incoming request to inspect.
384+
path: Explicit path override (e.g. a ``root_path``-stripped path);
385+
defaults to ``request.url.path``.
386+
387+
Returns:
388+
``True`` when the request is a trusted internal MCP/A2A hop.
389+
"""
390+
p = path if path is not None else (getattr(getattr(request, "url", None), "path", "") or "")
391+
if p.startswith(f"{INTERNAL_A2A_PATH_PREFIX}/") and not settings.mcpgateway_a2a_enabled:
392+
return False
393+
return is_trusted_internal_runtime_request(
394+
request,
395+
allowed_prefixes=(INTERNAL_MCP_PATH_PREFIX, INTERNAL_A2A_PATH_PREFIX),
396+
require_auth_context=_internal_path_requires_auth_context(p),
397+
path=p,
398+
)
399+
400+
313401
def get_token_teams_from_request(request: Request) -> Optional[List[str]]:
314402
"""Extract and normalize teams from verified JWT token.
315403

mcpgateway/main.py

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@
8181
get_scoped_resource_access_context,
8282
get_token_teams_from_request,
8383
get_user_email,
84-
has_valid_internal_mcp_runtime_auth_header,
8584
INTERNAL_MCP_SESSION_VALIDATED_HEADER,
85+
is_trusted_internal_mcp_request,
8686
)
8787
from mcpgateway.cache import ResourceCache, SessionRegistry
8888
from mcpgateway.common.models import InitializeResult
@@ -296,17 +296,7 @@ def _is_trusted_internal_mcp_runtime_request(request: Request) -> bool:
296296
``True`` when the request carries a trusted internal-runtime marker
297297
from loopback, otherwise ``False``.
298298
"""
299-
runtime_marker = request.headers.get("x-contextforge-mcp-runtime")
300-
client_host = getattr(getattr(request, "client", None), "host", None)
301-
if runtime_marker not in ("rust", "affinity") or not has_valid_internal_mcp_runtime_auth_header(request) or client_host not in ("127.0.0.1", "::1"):
302-
return False
303-
# Defense-in-depth: /_internal/a2a/* endpoints must refuse requests when
304-
# A2A support is disabled, even from an otherwise-trusted local sidecar.
305-
# A legitimate sidecar should not be running when the feature is off.
306-
path = getattr(getattr(request, "url", None), "path", "") or ""
307-
if path.startswith("/_internal/a2a/") and not settings.mcpgateway_a2a_enabled:
308-
return False
309-
return True
299+
return is_trusted_internal_mcp_request(request)
310300

311301

312302
def _is_jwt_token(token: str) -> bool:

mcpgateway/middleware/rate_limit_middleware.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838

3939
# First-Party
4040
from mcpgateway import auth
41+
from mcpgateway.auth_context import is_trusted_internal_mcp_request
4142
from mcpgateway.config import settings
4243
from mcpgateway.services.security_logger import SecurityEventType, SecurityLogger, SecuritySeverity
4344

@@ -199,6 +200,10 @@ async def dispatch(self, request: Request, call_next):
199200
if not self.enabled:
200201
return await call_next(request)
201202

203+
# Skip rate limiting for the trusted-internal dispatch; the edge request was already counted.
204+
if is_trusted_internal_mcp_request(request):
205+
return await call_next(request)
206+
202207
tier = self.get_endpoint_tier(request.url.path)
203208
dimensions = self._get_client_dimensions(request)
204209

mcpgateway/middleware/token_scoping.py

Lines changed: 10 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@
1313
# Standard
1414
from datetime import datetime, timedelta, timezone
1515
from functools import lru_cache
16-
import hashlib
17-
import hmac
1816
import ipaddress
1917
import re
2018
from typing import List, Optional, Pattern, Tuple
@@ -64,11 +62,6 @@
6462
(re.compile(r"/gateways/?([a-f0-9\-]+)"), "gateway"),
6563
]
6664
_AUTH_COOKIE_NAMES = ("jwt_token", "access_token")
67-
_INTERNAL_MCP_PATH_PREFIX = "/_internal/mcp"
68-
_INTERNAL_MCP_RUNTIME_HEADER = "x-contextforge-mcp-runtime"
69-
_INTERNAL_MCP_AUTH_CONTEXT_HEADER = "x-contextforge-auth-context"
70-
_INTERNAL_MCP_RUNTIME_AUTH_HEADER = "x-contextforge-mcp-runtime-auth"
71-
_INTERNAL_MCP_RUNTIME_AUTH_CONTEXT = "contextforge-internal-mcp-runtime-v1"
7265

7366
# Permission map with precompiled patterns
7467
# Maps (HTTP method, path pattern) to required permission
@@ -1350,70 +1343,24 @@ async def __call__(self, request: Request, call_next):
13501343
)
13511344

13521345
def _is_trusted_internal_mcp_runtime_request(self, request: Request, normalized_path: str) -> bool:
1353-
"""Return whether the request is a trusted loopback Rust MCP sidecar hop.
1346+
"""Return whether the request is a trusted internal MCP/A2A runtime hop.
1347+
1348+
Delegates to the shared gate in ``auth_context`` so the trust decision is
1349+
defined in one place. Unlike the previous local check, this trusts both
1350+
the ``rust`` and ``affinity`` runtime markers — closing the gap where the
1351+
in-process session-affinity dispatch was still token-scoped.
13541352
13551353
Args:
13561354
request: Incoming HTTP request.
13571355
normalized_path: Canonicalized request path used for route matching.
13581356
13591357
Returns:
1360-
``True`` when the request originated from the local Rust MCP runtime and
1361-
includes the expected trusted headers.
1362-
"""
1363-
if normalized_path != _INTERNAL_MCP_PATH_PREFIX and not normalized_path.startswith(f"{_INTERNAL_MCP_PATH_PREFIX}/"):
1364-
return False
1365-
1366-
if request.headers.get(_INTERNAL_MCP_RUNTIME_HEADER) != "rust":
1367-
return False
1368-
1369-
provided_auth = request.headers.get(_INTERNAL_MCP_RUNTIME_AUTH_HEADER)
1370-
if not provided_auth:
1371-
return False
1372-
1373-
expected_auth = self._expected_internal_mcp_runtime_auth_header()
1374-
if not hmac.compare_digest(provided_auth, expected_auth):
1375-
return False
1376-
1377-
if not request.headers.get(_INTERNAL_MCP_AUTH_CONTEXT_HEADER):
1378-
return False
1379-
1380-
client_host = getattr(getattr(request, "client", None), "host", None)
1381-
return client_host in ("127.0.0.1", "::1")
1382-
1383-
@staticmethod
1384-
def _auth_encryption_secret_value() -> str:
1385-
"""Return the configured auth-encryption secret as a plain string.
1386-
1387-
Returns:
1388-
The auth-encryption secret, normalized to a regular string.
1389-
"""
1390-
secret = settings.auth_encryption_secret
1391-
if hasattr(secret, "get_secret_value"):
1392-
return secret.get_secret_value()
1393-
return str(secret)
1394-
1395-
@staticmethod
1396-
@lru_cache(maxsize=8)
1397-
def _expected_internal_mcp_runtime_auth_header_for_secret(secret: str) -> str:
1398-
"""Return the expected shared internal-auth header for a specific secret.
1399-
1400-
Args:
1401-
secret: Auth-encryption secret to derive the trust header from.
1402-
1403-
Returns:
1404-
Hex-encoded SHA-256 digest derived from the provided auth secret.
1358+
``True`` when the request is a trusted internal MCP/A2A hop.
14051359
"""
1406-
material = f"{secret}:{_INTERNAL_MCP_RUNTIME_AUTH_CONTEXT}".encode("utf-8")
1407-
return hashlib.sha256(material).hexdigest()
1408-
1409-
@staticmethod
1410-
def _expected_internal_mcp_runtime_auth_header() -> str:
1411-
"""Return the expected shared internal-auth header for Rust MCP hops.
1360+
# First-Party
1361+
from mcpgateway.auth_context import is_trusted_internal_mcp_request # pylint: disable=import-outside-toplevel
14121362

1413-
Returns:
1414-
Shared secret-derived digest expected on trusted internal Rust MCP calls.
1415-
"""
1416-
return TokenScopingMiddleware._expected_internal_mcp_runtime_auth_header_for_secret(TokenScopingMiddleware._auth_encryption_secret_value())
1363+
return is_trusted_internal_mcp_request(request, path=normalized_path)
14171364

14181365

14191366
# Create middleware instance

mcpgateway/middleware/token_usage_middleware.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from starlette.types import ASGIApp, Receive, Scope, Send
3131

3232
# First-Party
33+
from mcpgateway.auth_context import is_trusted_internal_mcp_request
3334
from mcpgateway.db import fresh_db_session
3435
from mcpgateway.middleware.path_filter import should_skip_auth_context
3536
from mcpgateway.services.token_catalog_service import TokenCatalogService
@@ -87,6 +88,11 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
8788
await self.app(scope, receive, send)
8889
return
8990

91+
# Skip usage logging for the trusted-internal dispatch; the edge request is already recorded.
92+
if is_trusted_internal_mcp_request(Request(scope), path=path):
93+
await self.app(scope, receive, send)
94+
return
95+
9096
# Record start time
9197
start_time = time.time()
9298

mcpgateway/utils/passthrough_headers.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,17 @@ async def set_global_passthrough_headers(db: Session) -> None:
573573
"x-contextforge-mcp-runtime",
574574
"x-contextforge-mcp-runtime-auth",
575575
"x-contextforge-session-validated",
576+
# Forwarded / client-IP headers: strip so a caller cannot spoof the loopback
577+
# client address that ProxyHeaders(trusted_hosts="*") would otherwise trust.
578+
"forwarded",
579+
"x-forwarded-for",
580+
"x-forwarded-host",
581+
"x-forwarded-proto",
582+
"x-forwarded-port",
583+
"x-forwarded-prefix",
584+
"x-real-ip",
585+
"cf-connecting-ip",
586+
"true-client-ip",
576587
}
577588
)
578589

tests/unit/mcpgateway/middleware/test_rate_limit_middleware.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,30 @@
1616
import pytest
1717

1818

19+
def _trusted_internal_request(path, *, marker="rust", hmac_value=None, ctx="ctx", client="127.0.0.1"):
20+
"""Build a real loopback internal request for trust-gate exemption tests."""
21+
from starlette.requests import Request
22+
23+
from mcpgateway.auth_context import _expected_internal_mcp_runtime_auth_header
24+
25+
headers = [
26+
(b"x-contextforge-mcp-runtime", marker.encode()),
27+
(b"x-contextforge-mcp-runtime-auth", (hmac_value or _expected_internal_mcp_runtime_auth_header()).encode()),
28+
]
29+
if ctx is not None:
30+
headers.append((b"x-contextforge-auth-context", ctx.encode()))
31+
scope = {
32+
"type": "http",
33+
"method": "POST",
34+
"path": path,
35+
"raw_path": path.encode(),
36+
"query_string": b"",
37+
"headers": headers,
38+
"client": (client, 12345),
39+
}
40+
return Request(scope)
41+
42+
1943
class TestRateLimitMiddleware:
2044
"""Tests for RateLimitMiddleware."""
2145

@@ -58,6 +82,29 @@ def mock_request(self):
5882
request.scope = {"client": ("192.168.1.100", 12345)}
5983
return request
6084

85+
@pytest.mark.asyncio
86+
async def test_trusted_internal_dispatch_skips_rate_limiting(self, middleware):
87+
"""A trusted-internal hop is not rate-limited; the edge request already was."""
88+
request = _trusted_internal_request("/_internal/mcp/rpc")
89+
call_next = AsyncMock(return_value="passthrough")
90+
# If the exemption fires, tier computation is never reached.
91+
middleware.get_endpoint_tier = MagicMock(side_effect=AssertionError("trusted internal must not be rate-limited"))
92+
93+
result = await middleware.dispatch(request, call_next)
94+
95+
assert result == "passthrough"
96+
call_next.assert_awaited_once_with(request)
97+
98+
@pytest.mark.asyncio
99+
async def test_forged_internal_request_is_still_rate_limited(self, middleware):
100+
"""A forged HMAC fails the trust gate, so the rate-limit path runs."""
101+
request = _trusted_internal_request("/_internal/mcp/rpc", hmac_value="forged")
102+
call_next = AsyncMock(return_value="passthrough")
103+
middleware.get_endpoint_tier = MagicMock(side_effect=RuntimeError("reached rate-limit path"))
104+
105+
with pytest.raises(RuntimeError, match="reached rate-limit path"):
106+
await middleware.dispatch(request, call_next)
107+
61108
def test_endpoint_tier_critical(self, middleware):
62109
"""Test CRITICAL tier detection for auth endpoints."""
63110
tier = middleware.get_endpoint_tier("/auth/email/login")

0 commit comments

Comments
 (0)