From 1332eb4fc322ab27ac5f911157a347e1b9e5615d Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Sun, 10 May 2026 05:23:00 +0000 Subject: [PATCH] fix(mcp): don't leak resolved internal IP in SSRF error detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /token and /register OAuth endpoints are reachable without a LiteLLM API key — they sit mid-OAuth-handshake. When validate_url blocked an internal destination, the raw SSRFError ("URL targets a blocked address (10.0.0.50). …") was wrapped verbatim into the HTTPException detail and returned to the unauthenticated caller, handing them the resolved internal address of the operator's IdP — exactly the reconnaissance the SSRF guard is meant to deny. Log the real reason at WARNING for operators and return a generic "the destination resolves to a blocked address" detail to the caller. Add regression tests asserting the resolved IP does not appear in the HTTPException detail for either role. --- .../mcp_server/discoverable_endpoints.py | 14 +++++++++++++- .../mcp_server/test_discoverable_endpoints.py | 12 ++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index e27589ff567..59707f240b9 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -46,9 +46,21 @@ def _validate_mcp_oauth_outbound_url( try: return validate_url(url) except SSRFError as exc: + # The /token and /register endpoints are reachable without an API key, + # so the error response goes to an unauthenticated caller. The raw + # SSRFError message includes the resolved IP — leaking it would tell + # the caller exactly which internal address the operator's IdP lives at, + # which is the reconnaissance the SSRF guard is meant to deny. Log the + # real reason for operators and return a generic message to the caller. + verbose_logger.warning( + "MCP OAuth %s URL blocked by SSRF validation: %s", role, exc + ) raise HTTPException( status_code=400, - detail=f"Configured MCP {role} URL is not safe to call: {exc}", + detail=( + f"Configured MCP {role} URL is not safe to call: " + "the destination resolves to a blocked address." + ), ) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index 06926ecf9eb..6e4c2305e1c 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -2537,17 +2537,25 @@ class TestOutboundOAuthURLValidation: "http://127.0.0.1:8080/token", role="token" ) assert exc_info.value.status_code == 400 - assert "token" in str(exc_info.value.detail) + detail = str(exc_info.value.detail) + assert "token" in detail + # The /token endpoint is unauthenticated — leaking the resolved IP + # would hand reconnaissance to the caller. Detail must stay generic. + assert "127.0.0.1" not in detail + assert "blocked address" in detail def test_validator_rejects_rfc1918_url(self): from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( _validate_mcp_oauth_outbound_url, ) - with pytest.raises(HTTPException): + with pytest.raises(HTTPException) as exc_info: _validate_mcp_oauth_outbound_url( "http://192.168.1.10/oauth/register", role="registration" ) + detail = str(exc_info.value.detail) + assert "192.168.1.10" not in detail + assert "registration" in detail def test_validator_passes_when_validation_disabled(self): # Operators who explicitly opt out via litellm.user_url_validation = False