Skip to content

Commit 6db55e0

Browse files
authored
feat(mcp): add mcp_xff_num_trusted_hops to harden X-Forwarded-For client IP resolution (BerriAI#31257)
* feat(mcp): add mcp_xff_num_trusted_hops to harden XFF client IP resolution MCP per-server IP access control reads the client IP from X-Forwarded-For and trusts the leftmost entry. Behind an append-style proxy or load balancer (AWS ALB, nginx with $proxy_add_x_forwarded_for, HAProxy, Envoy, Cloudflare), a client can prepend an arbitrary value to the header, so the leftmost entry is attacker-controllable even when the direct peer is a trusted proxy. An attacker can therefore spoof an internal IP and reach servers marked available_on_public_internet=false. This adds an optional mcp_xff_num_trusted_hops general setting modelled on Envoy's xff_num_trusted_hops. When set to N, the client IP is read N entries from the right of the chain (where N is the number of trusted appending proxies in front of the gateway) instead of the leftmost value, so any entries a client prepends are ignored. It composes with mcp_trusted_proxy_ranges, which still validates the direct peer, and only takes effect once that check passes; without a validated direct peer the gateway keeps failing closed, so hop counting cannot be abused by a direct-to-pod attacker. The chain must contain at least N valid entries or resolution fails closed. Default is unset, preserving existing behaviour. * chore(ui): regenerate dashboard schema for mcp_xff_num_trusted_hops * fix(mcp): warn when mcp_xff_num_trusted_hops is below the minimum A 0 or negative value is silently treated as disabled, which could leave an operator believing they enabled append-style X-Forwarded-For hardening while client IP resolution stays on the spoofable leftmost value. Emit a warning, consistent with how the module already surfaces invalid CIDR config, so the misconfiguration is visible in logs. * fix(mcp): reject mcp_xff_num_trusted_hops < 1 at config-parse time Add a ge=1 bound to the ConfigGeneralSettings field so the update_config_general_settings path rejects 0 and negative values with a clear validation error instead of accepting them, and self-documents the valid range. The runtime warning stays as defense-in-depth for raw-dict config that bypasses model validation. * style(mcp): black-format ip_address_utils.py * fix(mcp): fail closed when mcp_xff_num_trusted_hops is set but invalid A present-but-invalid mcp_xff_num_trusted_hops (non-integer, or below 1) previously made _resolve_num_trusted_hops return None, which the caller treated identically to "unset" and silently fell back to the legacy leftmost X-Forwarded-For value. An operator who set the value to harden client IP resolution but typo'd it would get weaker security than before, with no fail-closed signal. Model the setting as a tagged union (_HopCountUnset, _HopCountInvalid, _HopCount) so the three states are distinct: unset keeps the legacy path, a valid count drives hop-counting, and an invalid value fails closed (returns "") instead of reverting to the spoofable leftmost address. The caller matches on the union exhaustively. Add a parametrized regression test asserting get_mcp_client_ip returns "" for 0, -1, "abc", and 1.5 even with a spoofed internal leftmost entry, and update the resolver unit tests for the new return type.
1 parent 0a8a87a commit 6db55e0

5 files changed

Lines changed: 338 additions & 1 deletion

File tree

litellm/proxy/_types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2361,6 +2361,11 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
23612361
None,
23622362
description="CIDR ranges of trusted reverse proxies. When set, X-Forwarded-For and X-Forwarded-* origin headers are only trusted from these IPs.",
23632363
)
2364+
mcp_xff_num_trusted_hops: Optional[int] = Field(
2365+
None,
2366+
ge=1,
2367+
description="Number of trusted reverse proxies/load balancers in front of the gateway that append to X-Forwarded-For. When set (and mcp_trusted_proxy_ranges validates the direct peer), the client IP for MCP access control is read this many entries from the right of the chain instead of the spoofable leftmost value, defeating append-style X-Forwarded-For forgery.",
2368+
)
23642369
trusted_proxy_ranges: Optional[List[str]] = Field(
23652370
None,
23662371
description="CIDR ranges of trusted reverse proxies allowed to provide identity headers for header-based auth paths such as enable_oauth2_proxy_auth and custom_ui_sso_sign_in_handler.",

litellm/proxy/auth/ip_address_utils.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
"""
77

88
import ipaddress
9+
from dataclasses import dataclass
910
from typing import Any, Dict, List, Optional, Union
1011

1112
from fastapi import Request
13+
from pydantic import TypeAdapter, ValidationError
1214

1315
from litellm._logging import verbose_proxy_logger
1416
from litellm.proxy.auth.auth_utils import _get_request_ip_address
@@ -25,6 +27,26 @@
2527
# enabled, so a later rollback to disabled warns again.
2628
_warned_xff_present_but_disabled = False
2729

30+
_NUM_TRUSTED_HOPS_ADAPTER = TypeAdapter(int)
31+
32+
33+
@dataclass(frozen=True, slots=True)
34+
class _HopCountUnset:
35+
"""mcp_xff_num_trusted_hops is absent: keep the legacy leftmost-XFF path."""
36+
37+
38+
@dataclass(frozen=True, slots=True)
39+
class _HopCountInvalid:
40+
"""mcp_xff_num_trusted_hops is present but unusable: fail closed, never legacy."""
41+
42+
43+
@dataclass(frozen=True, slots=True)
44+
class _HopCount:
45+
value: int
46+
47+
48+
_HopCountSetting = Union[_HopCountUnset, _HopCountInvalid, _HopCount]
49+
2850

2951
class IPAddressUtils:
3052
"""Static utilities for IP-based MCP access control."""
@@ -174,6 +196,60 @@ def is_request_from_trusted_proxy(
174196
trusted_networks = IPAddressUtils.parse_trusted_proxy_networks(trusted_ranges)
175197
return IPAddressUtils.is_trusted_proxy(direct_ip, trusted_networks)
176198

199+
@staticmethod
200+
def extract_client_ip_from_xff_hops(
201+
xff_header: str,
202+
num_trusted_hops: int,
203+
) -> Optional[str]:
204+
"""
205+
Resolve the originating client IP from an X-Forwarded-For chain by
206+
counting ``num_trusted_hops`` entries from the right.
207+
208+
Each trusted proxy appends the address it received the connection from,
209+
so the right end of the chain is written by infrastructure while the
210+
left end is attacker-controllable. Selecting the Nth entry from the
211+
right, where N is the number of trusted appending proxies in front of
212+
the gateway, yields the real client IP and discards any values a client
213+
prepended to spoof an allowed address.
214+
215+
Returns None when the chain has fewer than ``num_trusted_hops`` entries
216+
or the selected entry is not a valid IP, so callers can fail closed.
217+
"""
218+
entries = tuple(part.strip() for part in xff_header.split(",") if part.strip())
219+
if num_trusted_hops < 1 or len(entries) < num_trusted_hops:
220+
return None
221+
candidate = entries[-num_trusted_hops]
222+
try:
223+
ipaddress.ip_address(candidate)
224+
except ValueError:
225+
return None
226+
return candidate
227+
228+
@staticmethod
229+
def _resolve_num_trusted_hops(raw_num_trusted_hops: object) -> _HopCountSetting:
230+
if raw_num_trusted_hops is None:
231+
return _HopCountUnset()
232+
try:
233+
num_hops = _NUM_TRUSTED_HOPS_ADAPTER.validate_python(raw_num_trusted_hops)
234+
except ValidationError:
235+
verbose_proxy_logger.warning(
236+
"Invalid mcp_xff_num_trusted_hops value %r; failing closed for "
237+
"MCP client IP resolution. Set it to a positive integer, or "
238+
"remove the setting to restore the legacy X-Forwarded-For path",
239+
raw_num_trusted_hops,
240+
)
241+
return _HopCountInvalid()
242+
if num_hops < 1:
243+
verbose_proxy_logger.warning(
244+
"mcp_xff_num_trusted_hops=%s is below the minimum of 1; failing "
245+
"closed for MCP client IP resolution. Set it to a positive "
246+
"integer, or remove the setting to restore the legacy "
247+
"X-Forwarded-For path",
248+
num_hops,
249+
)
250+
return _HopCountInvalid()
251+
return _HopCount(num_hops)
252+
177253
@staticmethod
178254
def get_mcp_client_ip(
179255
request: Request,
@@ -186,6 +262,12 @@ def get_mcp_client_ip(
186262
1. use_x_forwarded_for is enabled in settings
187263
2. The direct connection is from a trusted proxy (if mcp_trusted_proxy_ranges configured)
188264
265+
When ``mcp_xff_num_trusted_hops`` is set, the client IP is read that many
266+
entries from the right of the chain instead of the spoofable leftmost
267+
value, defeating append-style X-Forwarded-For forgery. A present-but-invalid
268+
value (non-integer or below 1) fails closed rather than silently reverting
269+
to the legacy path, so a config typo cannot quietly weaken access control.
270+
189271
Args:
190272
request: FastAPI request object
191273
general_settings: Optional settings dict. If not provided, imports from proxy_server.
@@ -245,4 +327,24 @@ def get_mcp_client_ip(
245327
# returning it would mis-classify external callers as internal.
246328
# Fail closed for access control.
247329
return ""
330+
match IPAddressUtils._resolve_num_trusted_hops(
331+
general_settings.get("mcp_xff_num_trusted_hops")
332+
):
333+
case _HopCountInvalid():
334+
return ""
335+
case _HopCount(value=num_trusted_hops):
336+
client_ip = IPAddressUtils.extract_client_ip_from_xff_hops(
337+
request.headers["x-forwarded-for"], num_trusted_hops
338+
)
339+
if client_ip is None:
340+
verbose_proxy_logger.warning(
341+
"X-Forwarded-For chain has fewer than "
342+
"mcp_xff_num_trusted_hops=%s entries or an invalid "
343+
"address; failing closed",
344+
num_trusted_hops,
345+
)
346+
return ""
347+
return client_ip
348+
case _HopCountUnset():
349+
pass
248350
return _get_request_ip_address(request, use_x_forwarded_for=use_xff)

litellm/proxy/proxy_server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15385,6 +15385,7 @@ async def get_config_list(
1538515385
"maximum_spend_logs_retention_period": {"type": "String"},
1538615386
"mcp_internal_ip_ranges": {"type": "List"},
1538715387
"mcp_trusted_proxy_ranges": {"type": "List"},
15388+
"mcp_xff_num_trusted_hops": {"type": "Integer"},
1538815389
"always_include_stream_usage": {"type": "Boolean"},
1538915390
"forward_client_headers_to_llm_api": {"type": "Boolean"},
1539015391
"mcp_required_fields": {"type": "List"},

0 commit comments

Comments
 (0)