mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
fix(mcp): fall back to client_ip in stateful session owner fingerprint
Addresses Greptile review on PR #26857: when no API key, user_id, or OAuth bearer is available (e.g. unauthenticated/passthrough callers), the owner fingerprint collapsed to a single 'anonymous' value, allowing two unrelated callers to drive each other's stateful MCP sessions. Fold client IP into the fingerprint as a fallback identity signal so distinct anonymous sources do not share an owner identity. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
This commit is contained in:
parent
356b8af65d
commit
ea0a12f9ba
2 changed files with 27 additions and 2 deletions
|
|
@ -2674,6 +2674,7 @@ if MCP_AVAILABLE:
|
|||
def _owner_fingerprint_for(
|
||||
user_api_key_auth: Optional[UserAPIKeyAuth],
|
||||
oauth2_headers: Optional[Dict[str, str]] = None,
|
||||
client_ip: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Stable, non-reversible identifier for the caller used to bind an
|
||||
|
|
@ -2684,6 +2685,13 @@ if MCP_AVAILABLE:
|
|||
the caller's identity is the upstream OAuth bearer; hash it so two
|
||||
OAuth callers with different tokens don't both fingerprint to
|
||||
``anonymous`` and end up sharing a session.
|
||||
|
||||
When no caller-identifying credentials are available at all
|
||||
(e.g. proxy running without master key, or an unauthenticated
|
||||
passthrough path), fall back to the client IP so two unrelated
|
||||
anonymous callers from different sources do not collapse to a
|
||||
single ``anonymous`` owner and end up able to drive each other's
|
||||
stateful sessions.
|
||||
"""
|
||||
if user_api_key_auth is not None:
|
||||
if user_api_key_auth.api_key:
|
||||
|
|
@ -2696,6 +2704,8 @@ if MCP_AVAILABLE:
|
|||
)
|
||||
if authz:
|
||||
return f"oauth:{hashlib.sha256(authz.encode('utf-8')).hexdigest()}"
|
||||
if client_ip:
|
||||
return f"ip:{hashlib.sha256(client_ip.encode('utf-8')).hexdigest()}"
|
||||
return "anonymous"
|
||||
|
||||
def _is_initialize_request(body: bytes) -> bool:
|
||||
|
|
@ -3003,7 +3013,7 @@ if MCP_AVAILABLE:
|
|||
if session_id:
|
||||
expected_owner = _stateful_session_owners.get(session_id)
|
||||
request_owner = _owner_fingerprint_for(
|
||||
user_api_key_auth, oauth2_headers
|
||||
user_api_key_auth, oauth2_headers, _client_ip
|
||||
)
|
||||
if expected_owner is not None and expected_owner != request_owner:
|
||||
verbose_logger.warning(
|
||||
|
|
@ -3074,7 +3084,9 @@ if MCP_AVAILABLE:
|
|||
local_send = _wrap_send_with_stateful_session_auth_context(
|
||||
local_send,
|
||||
auth_user,
|
||||
_owner_fingerprint_for(user_api_key_auth, oauth2_headers),
|
||||
_owner_fingerprint_for(
|
||||
user_api_key_auth, oauth2_headers, _client_ip
|
||||
),
|
||||
)
|
||||
|
||||
async with _gateway_initialize_instructions_request_scope(
|
||||
|
|
|
|||
|
|
@ -1453,6 +1453,19 @@ async def test_owner_fingerprint_distinguishes_oauth_callers():
|
|||
assert fp_no_oauth == "anonymous"
|
||||
assert "Bearer token-A" not in fp_a
|
||||
|
||||
# When no API key, user_id, or OAuth bearer is available, fall back to
|
||||
# client IP so two unrelated unauthenticated callers from different
|
||||
# sources don't collapse to a single 'anonymous' owner and end up able
|
||||
# to drive each other's stateful sessions.
|
||||
fp_ip_a = _owner_fingerprint_for(anon_auth, None, "10.0.0.1")
|
||||
fp_ip_b = _owner_fingerprint_for(anon_auth, None, "10.0.0.2")
|
||||
fp_ip_a_again = _owner_fingerprint_for(anon_auth, None, "10.0.0.1")
|
||||
|
||||
assert fp_ip_a != fp_ip_b
|
||||
assert fp_ip_a == fp_ip_a_again
|
||||
assert fp_ip_a.startswith("ip:")
|
||||
assert "10.0.0.1" not in fp_ip_a
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stateful_mcp_session_owner_mismatch_returns_403():
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue