mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-22 00:31:44 +00:00
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:
parent
b227a8c4c9
commit
fade26b969
6 changed files with 76 additions and 31 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
9
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
9
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue