From cfdf4fa5dc043f378d38c09a5b6ea87874076041 Mon Sep 17 00:00:00 2001 From: yucheng Date: Fri, 18 Sep 2026 10:20:39 +0000 Subject: [PATCH] fix(proxy): break ties between equivalent login limit overrides deterministically Two spellings of one network share a prefix length, so the exemption wins the tie, then the higher limit, regardless of mapping order Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_types.py | 2 +- litellm/proxy/auth/login_throttle.py | 12 +++++++++--- .../test_litellm/proxy/auth/test_login_utils.py | 17 +++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 +- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 04ea7712545..851b2e7b28e 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2759,7 +2759,7 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): ) max_failed_login_attempts_per_source_overrides: dict[str, int] | None = Field( None, - description="Per-address overrides of `max_failed_login_attempts_per_source`, keyed by IP address or CIDR range, e.g. {'1.2.3.4': 200, '5.6.0.0/24': 500}. The most specific matching range wins, and the per-username allowance for that address follows as half the override. A value of 0 exempts the address from both limits. Set under `general_settings` in config.yaml", + description="Per-address overrides of `max_failed_login_attempts_per_source`, keyed by IP address or CIDR range, e.g. {'1.2.3.4': 200, '5.6.0.0/24': 500}. The most specific matching range wins (between equivalent keys such as '1.2.3.4' and '1.2.3.4/32', an exemption wins, then the higher limit), and the per-username allowance for that address follows as half the override. A value of 0 exempts the address from both limits. Set under `general_settings` in config.yaml", ) failed_login_window_seconds: int | None = Field( None, diff --git a/litellm/proxy/auth/login_throttle.py b/litellm/proxy/auth/login_throttle.py index 273cf8b9f93..e4d9ffd4c5b 100644 --- a/litellm/proxy/auth/login_throttle.py +++ b/litellm/proxy/auth/login_throttle.py @@ -186,10 +186,16 @@ def _parse_network(raw_range: str) -> _Network | None: return None +def _precedence(network: _Network, limit: int) -> tuple[int, bool, int]: + """Sort key for competing overrides: the longest prefix wins, then an exemption, then the higher limit.""" + return (network.prefixlen, limit == EXEMPT, limit) + + def _source_limit(settings: Mapping[str, object], client_ip: str) -> int: """Failure allowance for this address: the most specific configured range containing it, else the default. - ``EXEMPT`` (0) means the operator opted this address out of both limits. + ``EXEMPT`` (0) means the operator opted this address out of both limits. Between equivalent keys such as + ``1.2.3.4`` and ``1.2.3.4/32`` an exemption wins, then the higher limit. """ default: Final = _int_setting(settings, SOURCE_LIMIT_KEY, DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS_PER_SOURCE) raw_overrides: Final = settings.get(SOURCE_LIMIT_OVERRIDES_KEY) @@ -206,11 +212,11 @@ def _source_limit(settings: Mapping[str, object], client_ip: str) -> int: if address is None: return default matches: Final = sorted( - (network.prefixlen, _override_limit(raw_limit, default)) + _precedence(network, _override_limit(raw_limit, default)) for raw_range, raw_limit in overrides.items() if (network := _parse_network(raw_range)) is not None and address in network ) - return matches[-1][1] if matches else default + return matches[-1][-1] if matches else default def user_limit_for(source_limit: int) -> int: diff --git a/tests/test_litellm/proxy/auth/test_login_utils.py b/tests/test_litellm/proxy/auth/test_login_utils.py index 52ae3ca092b..0cac0364219 100644 --- a/tests/test_litellm/proxy/auth/test_login_utils.py +++ b/tests/test_litellm/proxy/auth/test_login_utils.py @@ -1023,6 +1023,23 @@ def test_source_overrides_pick_the_most_specific_matching_range(): assert _limit("::ffff:203.0.113.10") == 200 +@pytest.mark.parametrize( + ("overrides", "expected"), + [ + ({"203.0.113.7": 0, "203.0.113.7/32": 5}, None), + ({"203.0.113.7/32": 5, "203.0.113.7": 0}, None), + ({"203.0.113.0/24": 3, "203.0.113.9/24": 8}, 8), + ({"203.0.113.9/24": 8, "203.0.113.0/24": 3}, 8), + ], + ids=["exact-then-slash32", "slash32-then-exact", "low-then-high", "high-then-low"], +) +def test_equivalent_override_keys_resolve_to_the_exemption_then_the_higher_limit(overrides, expected): + """Two spellings of the same network are a config mistake, so precedence must not depend on dict order.""" + settings = {"trusted_proxy_ranges": ["10.0.0.0/8"], "max_failed_login_attempts_per_source_overrides": overrides} + + assert _throttle_behind_trusted_proxy("203.0.113.7", settings).source_limit == expected + + def test_ipv6_sources_are_grouped_by_their_64_bit_prefix(): """A /64 holder has 2^64 addresses; counting each one separately would hand them unlimited fresh buckets.""" from litellm.proxy.auth.login_throttle import source_group diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index e6e1627c4ca..5b1532c3629 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -26571,7 +26571,7 @@ export interface components { max_failed_login_attempts_per_source?: number | null; /** * Max Failed Login Attempts Per Source Overrides - * @description Per-address overrides of `max_failed_login_attempts_per_source`, keyed by IP address or CIDR range, e.g. {'1.2.3.4': 200, '5.6.0.0/24': 500}. The most specific matching range wins, and the per-username allowance for that address follows as half the override. A value of 0 exempts the address from both limits. Set under `general_settings` in config.yaml + * @description Per-address overrides of `max_failed_login_attempts_per_source`, keyed by IP address or CIDR range, e.g. {'1.2.3.4': 200, '5.6.0.0/24': 500}. The most specific matching range wins (between equivalent keys such as '1.2.3.4' and '1.2.3.4/32', an exemption wins, then the higher limit), and the per-username allowance for that address follows as half the override. A value of 0 exempts the address from both limits. Set under `general_settings` in config.yaml */ max_failed_login_attempts_per_source_overrides?: { [key: string]: number;