feat(proxy): derive the per-username sign-in allowance from the address limit

The per-address-and-username allowance is now half the effective address allowance, rounded up, instead of a separate max_failed_login_attempts_per_user setting. A per-address override therefore raises or effectively removes both limits for that address, and no second override table is needed

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-18 09:25:34 +00:00
parent b227a8c4c9
commit fade26b969
6 changed files with 76 additions and 31 deletions

View file

@ -2755,16 +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`. 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 this limit is off. 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 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",
)
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. Set under `general_settings` in config.yaml",
)
max_failed_login_attempts_per_user: int | None = Field(
None,
ge=1,
description="Failed Admin UI sign-in attempts allowed from one source address for one username within `failed_login_window_seconds`. One more blocks that address for that username for `failed_login_block_seconds`, and its further failures stop counting against `max_failed_login_attempts_per_source`, so a script stuck on one account does not block everyone behind the same address. Set under `general_settings` in config.yaml. Defaults to 5",
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",
)
failed_login_window_seconds: int | None = Field(
None,

View file

@ -39,7 +39,6 @@ from litellm.proxy.auth.network import TrustedProxyConfig, normalize_cidr_ranges
from litellm.secret_managers.main import get_secret_bool
DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS_PER_SOURCE: Final = 10
DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS_PER_USER: Final = 5
DEFAULT_FAILED_LOGIN_WINDOW_SECONDS: Final = 60
DEFAULT_FAILED_LOGIN_BLOCK_SECONDS: Final = 300
@ -47,7 +46,6 @@ IPV6_SOURCE_PREFIX_LENGTH: Final = 64
SOURCE_LIMIT_KEY: Final = "max_failed_login_attempts_per_source"
SOURCE_LIMIT_OVERRIDES_KEY: Final = "max_failed_login_attempts_per_source_overrides"
USER_LIMIT_KEY: Final = "max_failed_login_attempts_per_user"
WINDOW_KEY: Final = "failed_login_window_seconds"
BLOCK_KEY: Final = "failed_login_block_seconds"
TRUSTED_PROXY_RANGES_KEY: Final = "trusted_proxy_ranges"
@ -204,6 +202,11 @@ def _source_limit(settings: Mapping[str, object], client_ip: str) -> int:
return matches[-1][1] if matches else default
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
def source_group(client_ip: str) -> str:
"""The bucket an address is counted in: IPv4 as is, IPv6 by its /64, so one prefix holder cannot rotate."""
address: Final = _parse_address(client_ip)
@ -233,6 +236,7 @@ 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``.
"""
client_ip: str
@ -257,10 +261,11 @@ class LoginThrottle:
resolved, _ = resolve_client_ip(
request, TrustedProxyConfig(use_forwarded_for=bool(proxies), trusted_proxy_cidrs=proxies or ())
)
source_limit: Final = _source_limit(settings, resolved or LOGIN_THROTTLE_UNKNOWN_SOURCE)
return cls(
client_ip=resolved or LOGIN_THROTTLE_UNKNOWN_SOURCE,
source_limit=_source_limit(settings, resolved) if proxies is not None and resolved is not None else None,
user_limit=_int_setting(settings, USER_LIMIT_KEY, DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS_PER_USER),
source_limit=source_limit if proxies is not None and resolved is not None 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,

View file

@ -1471,7 +1471,6 @@ def test_settings_that_arrive_as_environment_strings_are_honored():
general_settings={
"trusted_proxy_ranges": "10.0.0.0/8",
"max_failed_login_attempts_per_source": " 70 ",
"max_failed_login_attempts_per_user": "7",
"failed_login_window_seconds": "not-a-number",
"failed_login_block_seconds": "-5",
},
@ -1479,7 +1478,7 @@ def test_settings_that_arrive_as_environment_strings_are_honored():
)
assert throttle.source_limit == 70
assert throttle.user_limit == 7
assert throttle.user_limit == 35, "the per-username allowance is half the address allowance"
assert throttle.window_seconds == 60, "garbage falls back to the default"
assert throttle.block_seconds == 300, "a value below one would block nothing or forever"
@ -1503,6 +1502,59 @@ 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"],
)
def test_the_per_username_allowance_is_half_the_address_allowance_rounded_up(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."""
from litellm.proxy.auth.login_throttle import LoginThrottle
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},
}
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)
exempt = _from("203.0.113.9")
assert (exempt.source_limit, exempt.user_limit) == (1_000_000, 500_000)
ordinary = _from("198.51.100.4")
assert (ordinary.source_limit, ordinary.user_limit) == (10, 5)
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
request = MagicMock()
request.headers = {}
request.client = MagicMock()
request.client.host = "192.0.2.8"
throttle = LoginThrottle.from_request(
request,
general_settings={"max_failed_login_attempts_per_source_overrides": {"192.0.2.8": 40}},
redis_cache=None,
)
assert throttle.source_limit is None
assert throttle.user_limit == 20
def test_the_disable_flag_is_read_once_not_per_login_attempt(monkeypatch):
"""Regression: the kill switch was read through the secret manager on every unauthenticated request."""
from litellm.proxy.auth import login_throttle

View file

@ -531,7 +531,7 @@ def test_budget_is_shared_across_every_login_endpoint(client, monkeypatch, reset
"""
_install_real_auth(
monkeypatch,
max_failed_login_attempts_per_user=10,
max_failed_login_attempts_per_source=20,
control_plane_url="https://cp.example.com",
)
@ -544,7 +544,7 @@ def test_budget_is_shared_across_every_login_endpoint(client, monkeypatch, reset
def test_budget_is_shared_across_username_casing(client, monkeypatch, reset_login_throttle):
"""The database lookup is case-insensitive, so casing must not partition the counter."""
_install_real_auth(monkeypatch, max_failed_login_attempts_per_user=3)
_install_real_auth(monkeypatch, max_failed_login_attempts_per_source=6)
assert [_json_login(client, "/v2/login", username="admin@corp.com") for _ in range(2)] == [401] * 2
assert [_json_login(client, "/v2/login", username="ADMIN@corp.com") for _ in range(2)] == [401] * 2
@ -554,7 +554,7 @@ def test_budget_is_shared_across_username_casing(client, monkeypatch, reset_logi
def test_a_refused_attempt_carries_retry_after(client, monkeypatch, reset_login_throttle):
"""The 429 tells the caller how long the block has left."""
_install_real_auth(monkeypatch, max_failed_login_attempts_per_user=1, failed_login_block_seconds=77)
_install_real_auth(monkeypatch, max_failed_login_attempts_per_source=2, failed_login_block_seconds=77)
assert [_json_login(client, "/v2/login") for _ in range(2)] == [401, 401]
@ -565,7 +565,7 @@ def test_a_refused_attempt_carries_retry_after(client, monkeypatch, reset_login_
def test_the_form_returns_a_human_readable_lockout_page(client, monkeypatch, reset_login_throttle):
"""The no-JavaScript form must render a wait page when its POST is throttled."""
_install_real_auth(monkeypatch, max_failed_login_attempts_per_user=1, failed_login_block_seconds=77)
_install_real_auth(monkeypatch, max_failed_login_attempts_per_source=2, failed_login_block_seconds=77)
assert [_form_login(client) for _ in range(2)] == [401, 401]
@ -578,7 +578,7 @@ def test_the_form_returns_a_human_readable_lockout_page(client, monkeypatch, res
def test_a_second_username_from_the_same_source_still_gets_through(client, monkeypatch, reset_login_throttle):
"""The pair block is per username, so one account's block cannot take the office down with it."""
_install_real_auth(monkeypatch, max_failed_login_attempts_per_user=1)
_install_real_auth(monkeypatch, max_failed_login_attempts_per_source=2)
assert [_json_login(client, "/v2/login", username="admin") for _ in range(3)] == [401, 401, 429]
@ -633,7 +633,7 @@ def test_the_configured_admin_password_is_refused_while_blocked(client, monkeypa
limit. An operator who is blocked administers the proxy with the master key over the API meanwhile."""
from unittest.mock import AsyncMock, patch
_install_real_auth(monkeypatch, max_failed_login_attempts_per_user=1)
_install_real_auth(monkeypatch, max_failed_login_attempts_per_source=2)
monkeypatch.setenv("DATABASE_URL", "postgresql://stub")
assert [_json_login(client, "/v2/login") for _ in range(3)] == [401, 401, 429]
@ -655,7 +655,7 @@ def test_the_master_key_as_a_bearer_token_still_works_while_the_ui_password_is_b
client, monkeypatch, reset_login_throttle
):
"""Lockout recovery: the API path with the master key never enters the sign-in throttle."""
_install_real_auth(monkeypatch, max_failed_login_attempts_per_user=1)
_install_real_auth(monkeypatch, max_failed_login_attempts_per_source=2)
assert [_json_login(client, "/v2/login") for _ in range(3)] == [401, 401, 429]
@ -667,7 +667,7 @@ def test_the_master_key_as_a_bearer_token_still_works_while_the_ui_password_is_b
def test_a_database_users_correct_password_is_refused_while_blocked(client, monkeypatch, reset_login_throttle):
"""The block is hard: while it lasts, nothing from that source signs in as that user, right password or not,
and the block is not extended by the refused attempts."""
_install_real_auth(monkeypatch, max_failed_login_attempts_per_user=1, failed_login_block_seconds=64)
_install_real_auth(monkeypatch, max_failed_login_attempts_per_source=2, failed_login_block_seconds=64)
_db_user(monkeypatch, "user@corp.com")
assert [_json_login(client, "/v2/login", username="user@corp.com") for _ in range(3)] == [401, 401, 429]
@ -682,7 +682,7 @@ def test_a_database_users_correct_password_is_refused_while_blocked(client, monk
def test_sign_in_succeeds_again_once_the_block_is_cleared(client, monkeypatch, reset_login_throttle):
"""A cleared store lets the same username straight back to a plain credential check."""
_install_real_auth(monkeypatch, max_failed_login_attempts_per_user=1)
_install_real_auth(monkeypatch, max_failed_login_attempts_per_source=2)
assert [_json_login(client, "/v2/login") for _ in range(3)] == [401, 401, 429]

View file

@ -13519,13 +13519,11 @@ async def test_login_throttle_settings_are_not_hot_applied_from_the_database():
ps.general_settings.clear()
await ProxyConfig()._update_general_settings(
db_general_settings={
"max_failed_login_attempts_per_user": 999,
"max_failed_login_attempts_per_source": 999,
"failed_login_window_seconds": 1,
"failed_login_block_seconds": 1,
}
)
assert "max_failed_login_attempts_per_user" not in ps.general_settings
assert "max_failed_login_attempts_per_source" not in ps.general_settings
assert "failed_login_window_seconds" not in ps.general_settings
assert "failed_login_block_seconds" not in ps.general_settings

View file

@ -26566,21 +26566,16 @@ 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`. 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 this limit is off. 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 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
*/
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. 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 very large value opts the address out of both limits. Set under `general_settings` in config.yaml
*/
max_failed_login_attempts_per_source_overrides?: {
[key: string]: number;
} | null;
/**
* Max Failed Login Attempts Per User
* @description Failed Admin UI sign-in attempts allowed from one source address for one username within `failed_login_window_seconds`. One more blocks that address for that username for `failed_login_block_seconds`, and its further failures stop counting against `max_failed_login_attempts_per_source`, so a script stuck on one account does not block everyone behind the same address. Set under `general_settings` in config.yaml. Defaults to 5
*/
max_failed_login_attempts_per_user?: number | null;
/**
* Max File Size Mb
* @description max file size in MB for /v1/files uploads, for any purpose, if a file is larger than this size it will be rejected before being forwarded to the provider