fix(mcp): don't leak resolved internal IP in SSRF error detail

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.
This commit is contained in:
user 2026-05-10 05:23:00 +00:00
parent bf935a556c
commit 1332eb4fc3
No known key found for this signature in database
2 changed files with 23 additions and 3 deletions

View file

@ -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."
),
)

View file

@ -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