mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
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:
parent
b6bb212248
commit
0da2f5b96c
4 changed files with 66 additions and 27 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue