feat(proxy): round the per-username sign-in allowance down and exempt an address with an override of 0

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-18 09:54:54 +00:00
parent b6bb212248
commit 0da2f5b96c
4 changed files with 66 additions and 27 deletions

View file

@ -2755,11 +2755,11 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
max_failed_login_attempts_per_source: int | None = Field(
None,
ge=1,
description="Failed Admin UI sign-in attempts allowed from one source address, across every username, within `failed_login_window_seconds`. One more blocks that address for `failed_login_block_seconds`. Half this value, rounded up, is the allowance for one username from that address; one more blocks that address for that username only, and its further failures stop counting toward the address limit, so a script stuck on one account does not block everyone behind a shared address. The per-address limit is only enforced when `trusted_proxy_ranges` is set: to the proxies in front of LiteLLM, or to an empty list when clients connect directly. Left unset, the peer address may be a shared ingress and only the per-username half runs. IPv6 addresses are grouped by /64. Set under `general_settings` in config.yaml. Defaults to 10",
description="Failed Admin UI sign-in attempts allowed from one source address, across every username, within `failed_login_window_seconds`. One more blocks that address for `failed_login_block_seconds`. Half this value, rounded down but at least 1, is the allowance for one username from that address; one more blocks that address for that username only, and its further failures stop counting toward the address limit, so a script stuck on one account does not block everyone behind a shared address. The per-address limit is only enforced when `trusted_proxy_ranges` is set: to the proxies in front of LiteLLM, or to an empty list when clients connect directly. Left unset, the peer address may be a shared ingress and only the per-username half runs. IPv6 addresses are grouped by /64. Set under `general_settings` in config.yaml. Defaults to 10",
)
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 very large value opts the address out of 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, 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,

View file

@ -43,6 +43,7 @@ DEFAULT_FAILED_LOGIN_WINDOW_SECONDS: Final = 60
DEFAULT_FAILED_LOGIN_BLOCK_SECONDS: Final = 300
IPV6_SOURCE_PREFIX_LENGTH: Final = 64
EXEMPT: Final = 0
SOURCE_LIMIT_KEY: Final = "max_failed_login_attempts_per_source"
SOURCE_LIMIT_OVERRIDES_KEY: Final = "max_failed_login_attempts_per_source_overrides"
@ -157,6 +158,13 @@ def _int_setting(settings: Mapping[str, object], key: str, default: int) -> int:
return _positive_int(settings.get(key), key, default)
def _override_limit(raw: object, default: int) -> int:
"""A per-address override: a limit of 1 or more, or ``EXEMPT`` (0) to leave that address unlimited."""
if str(raw).strip() == str(EXEMPT):
return EXEMPT
return _positive_int(raw, SOURCE_LIMIT_OVERRIDES_KEY, default)
def _parse_address(client_ip: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
"""The address as it is limited and counted: an IPv4-mapped IPv6 address is its IPv4 address."""
try:
@ -179,7 +187,10 @@ def _parse_network(raw_range: str) -> _Network | None:
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."""
"""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.
"""
default: Final = _int_setting(settings, SOURCE_LIMIT_KEY, DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS_PER_SOURCE)
raw_overrides: Final = settings.get(SOURCE_LIMIT_OVERRIDES_KEY)
if raw_overrides is None:
@ -195,7 +206,7 @@ def _source_limit(settings: Mapping[str, object], client_ip: str) -> int:
if address is None:
return default
matches: Final = sorted(
(network.prefixlen, _positive_int(raw_limit, SOURCE_LIMIT_OVERRIDES_KEY, default))
(network.prefixlen, _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
)
@ -203,8 +214,8 @@ def _source_limit(settings: Mapping[str, object], client_ip: str) -> int:
def user_limit_for(source_limit: int) -> int:
"""Failures allowed for one username from one address: half the address allowance, rounded up."""
return (source_limit + 1) // 2
"""Failures allowed for one username from one address: half the address allowance, rounded down, at least 1."""
return max(source_limit // 2, 1)
def source_group(client_ip: str) -> str:
@ -236,7 +247,8 @@ class LoginThrottle:
``source_limit`` is None when the source scope is off: ``trusted_proxy_ranges`` is unset, so the peer
address may be a shared ingress. An empty list means clients connect directly and the peer is the source.
``user_limit`` is derived from the address allowance either way, see ``user_limit_for``.
``user_limit`` is derived from the address allowance either way, see ``user_limit_for``. An address whose
override is ``EXEMPT`` gets a disabled throttle: nothing is counted or blocked for it.
"""
client_ip: str
@ -262,16 +274,17 @@ class LoginThrottle:
request, TrustedProxyConfig(use_forwarded_for=bool(proxies), trusted_proxy_cidrs=proxies or ())
)
source_limit: Final = _source_limit(settings, resolved or LOGIN_THROTTLE_UNKNOWN_SOURCE)
exempt: Final = source_limit == EXEMPT
return cls(
client_ip=resolved or LOGIN_THROTTLE_UNKNOWN_SOURCE,
source_limit=source_limit if proxies is not None and resolved is not None else None,
source_limit=source_limit if proxies is not None and resolved is not None and not exempt else None,
user_limit=user_limit_for(source_limit),
window_seconds=_int_setting(settings, WINDOW_KEY, DEFAULT_FAILED_LOGIN_WINDOW_SECONDS),
block_seconds=_int_setting(settings, BLOCK_KEY, DEFAULT_FAILED_LOGIN_BLOCK_SECONDS),
counters=_COUNTERS,
blocks=_BLOCKS,
redis_cache=redis_cache,
enabled=not _rate_limit_disabled(),
enabled=not exempt and not _rate_limit_disabled(),
)
def _keys(self, username: str) -> _Keys:

View file

@ -6,6 +6,7 @@ to login_utils.py for better reusability.
"""
import os
from collections.abc import Mapping
from contextlib import ExitStack
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
@ -1504,39 +1505,64 @@ def test_the_defaults_are_the_agreed_ones():
@pytest.mark.parametrize(
("source_limit", "expected_user_limit"),
[(1, 1), (2, 1), (3, 2), (10, 5), (1_000_000, 500_000)],
ids=["one-stays-one", "two-halves-to-one", "odd-rounds-up", "default", "opt-out"],
[(1, 1), (2, 1), (3, 1), (10, 5), (11, 5), (70, 35)],
ids=["one-stays-one", "two-halves-to-one", "odd-rounds-down", "default", "eleven-rounds-down", "even"],
)
def test_the_per_username_allowance_is_half_the_address_allowance_rounded_up(source_limit, expected_user_limit):
def test_the_per_username_allowance_is_half_the_address_allowance_rounded_down_at_least_one(
source_limit, expected_user_limit
):
from litellm.proxy.auth.login_throttle import user_limit_for
assert user_limit_for(source_limit) == expected_user_limit
def test_a_per_address_override_also_raises_that_address_per_username_allowance():
"""One override opts an address out of both limits, so operators need no second override table."""
def _throttle_behind_trusted_proxy(client_ip: str, settings: Mapping[str, object]) -> "LoginThrottle":
from litellm.proxy.auth.login_throttle import LoginThrottle
request = MagicMock()
request.headers = {"x-forwarded-for": client_ip}
request.client = MagicMock()
request.client.host = "10.0.0.1"
return LoginThrottle.from_request(request, general_settings=settings, redis_cache=None)
def test_a_per_address_override_also_raises_that_address_per_username_allowance():
"""One override sizes both limits for an address, so operators need no second override table."""
settings = {
"trusted_proxy_ranges": ["10.0.0.0/8"],
"max_failed_login_attempts_per_source": 10,
"max_failed_login_attempts_per_source_overrides": {"203.0.113.0/24": 1_000_000},
"max_failed_login_attempts_per_source_overrides": {"203.0.113.0/24": 50},
}
def _from(client_ip: str) -> LoginThrottle:
request = MagicMock()
request.headers = {"x-forwarded-for": client_ip}
request.client = MagicMock()
request.client.host = "10.0.0.1"
return LoginThrottle.from_request(request, general_settings=settings, redis_cache=None)
raised = _throttle_behind_trusted_proxy("203.0.113.9", settings)
assert (raised.source_limit, raised.user_limit) == (50, 25)
exempt = _from("203.0.113.9")
assert (exempt.source_limit, exempt.user_limit) == (1_000_000, 500_000)
ordinary = _from("198.51.100.4")
ordinary = _throttle_behind_trusted_proxy("198.51.100.4", settings)
assert (ordinary.source_limit, ordinary.user_limit) == (10, 5)
@pytest.mark.asyncio
async def test_an_override_of_zero_exempts_that_address_from_both_limits():
"""Regression: opting an address out used to mean guessing a large enough number."""
settings = {
"trusted_proxy_ranges": ["10.0.0.0/8"],
"max_failed_login_attempts_per_source": 1,
"max_failed_login_attempts_per_source_overrides": {"203.0.113.7": 0, "203.0.113.0/24": 3},
}
exempt = _throttle_behind_trusted_proxy("203.0.113.7", settings)
assert exempt.enabled is False
assert exempt.source_limit is None
attempt = await exempt.attempt("scanner@example.com")
for _ in range(5):
await attempt.failed()
await exempt.attempt("scanner@example.com")
sibling = _throttle_behind_trusted_proxy("203.0.113.8", settings)
assert sibling.enabled is True
assert (sibling.source_limit, sibling.user_limit) == (3, 1)
def test_the_per_username_allowance_follows_the_peer_override_when_the_source_scope_is_off():
"""Without trusted_proxy_ranges the address is not blocked, but its override still sizes the pair limit."""
from litellm.proxy.auth.login_throttle import LoginThrottle

View file

@ -26566,12 +26566,12 @@ export interface components {
max_batch_file_size_mb?: number | null;
/**
* Max Failed Login Attempts Per Source
* @description Failed Admin UI sign-in attempts allowed from one source address, across every username, within `failed_login_window_seconds`. One more blocks that address for `failed_login_block_seconds`. Half this value, rounded up, is the allowance for one username from that address; one more blocks that address for that username only, and its further failures stop counting toward the address limit, so a script stuck on one account does not block everyone behind a shared address. The per-address limit is only enforced when `trusted_proxy_ranges` is set: to the proxies in front of LiteLLM, or to an empty list when clients connect directly. Left unset, the peer address may be a shared ingress and only the per-username half runs. IPv6 addresses are grouped by /64. Set under `general_settings` in config.yaml. Defaults to 10
* @description Failed Admin UI sign-in attempts allowed from one source address, across every username, within `failed_login_window_seconds`. One more blocks that address for `failed_login_block_seconds`. Half this value, rounded down but at least 1, is the allowance for one username from that address; one more blocks that address for that username only, and its further failures stop counting toward the address limit, so a script stuck on one account does not block everyone behind a shared address. The per-address limit is only enforced when `trusted_proxy_ranges` is set: to the proxies in front of LiteLLM, or to an empty list when clients connect directly. Left unset, the peer address may be a shared ingress and only the per-username half runs. IPv6 addresses are grouped by /64. Set under `general_settings` in config.yaml. Defaults to 10
*/
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 very large value opts the address out of 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, 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;