diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 1fd2781bd95..0071ad4603d 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2528,7 +2528,12 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): max_failed_login_attempts: int | None = Field( None, ge=1, - description="Number of failed Admin UI sign-in attempts allowed for one username from one source address within `failed_login_window_seconds`, before further attempts are refused with 429. Configurable from config.yaml only. Defaults to 10", + description="Number of failed Admin UI sign-in attempts allowed for one username, from any source address, within `failed_login_window_seconds`, before further attempts for that username are refused with 429. Attempts are answered with a doubling delay well before this ceiling. Configurable from config.yaml only. Defaults to 50", + ) + max_failed_login_attempts_per_source: int | None = Field( + None, + ge=1, + description="Number of failed Admin UI sign-in attempts allowed from one source address, across every username, within `failed_login_window_seconds`, before further attempts from that address are refused with 429. Counted independently of `max_failed_login_attempts`. Configurable from config.yaml only. Defaults to 250", ) 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 e2befd15a35..ed53d9e7bc7 100644 --- a/litellm/proxy/auth/login_throttle.py +++ b/litellm/proxy/auth/login_throttle.py @@ -1,16 +1,20 @@ """Failed-login accounting for the Admin UI sign-in path. -Counts failed credential checks per (username, source address) over a fixed window and -denies further attempts with 429 once the count reaches the limit. Built per request by -``LoginThrottle.from_request`` because it carries that request's resolved source address, -and because the coordination cache is assigned at startup and can be reassigned later. +Counts failed credential checks over a fixed window against two independent keys, the +username on its own and the source address on its own, so that one username attacked from +many sources and one source spraying many usernames are both counted. Repeated failures +are answered slowly, doubling from one second, and refused with 429 once either counter +reaches its limit. Built per request by ``LoginThrottle.from_request`` because it carries +that request's resolved source address, and because the coordination cache is assigned at +startup and can be reassigned later. """ +import asyncio import hashlib from collections.abc import Awaitable from dataclasses import dataclass from types import MappingProxyType -from typing import Final +from typing import Final, NamedTuple, NoReturn from fastapi import Request @@ -23,21 +27,53 @@ from litellm.proxy.auth.network import TrustedProxyConfig, normalize_cidr_ranges from litellm.proxy.auth.trusted_proxy_utils import TRUSTED_PROXY_RANGES_KEY from litellm.secret_managers.main import get_secret_bool -DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS: Final = 10 +DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS: Final = 50 +DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS_PER_SOURCE: Final = 250 DEFAULT_FAILED_LOGIN_WINDOW_SECONDS: Final = 900 +USERNAME_DELAY_ONSET: Final = 3 +SOURCE_DELAY_ONSET: Final = 25 +FIRST_DELAY_SECONDS: Final = 1.0 +MAX_DELAY_SECONDS: Final = 30.0 +MAX_CONCURRENT_DELAYS_PER_SOURCE: Final = 5 + +_MAX_DELAY_DOUBLINGS: Final = 16 + _CACHE_KEY_PREFIX: Final = "login_fail" _UNKNOWN_SOURCE: Final = "unknown" _MAX_LOGGED_USERNAME_CHARS: Final = 128 +_MAX_TRACKED_LOGIN_USERNAMES: Final = 10_000 _MAX_TRACKED_LOGIN_SOURCES: Final = 10_000 -_FAILED_LOGIN_CACHE: Final = DualCache( - in_memory_cache=InMemoryCache(max_size_in_memory=_MAX_TRACKED_LOGIN_SOURCES), - default_in_memory_ttl=DEFAULT_FAILED_LOGIN_WINDOW_SECONDS, -) + +def _bounded_store(max_entries: int) -> DualCache: + return DualCache( + in_memory_cache=InMemoryCache(max_size_in_memory=max_entries), + default_in_memory_ttl=DEFAULT_FAILED_LOGIN_WINDOW_SECONDS, + ) + + +# Separate stores: eviction is earliest-expiring-first, so in one shared store a spray of +# fresh usernames would evict the source counter that is meant to stop that same spray. +_FAILED_LOGIN_USERNAME_CACHE: Final = _bounded_store(_MAX_TRACKED_LOGIN_USERNAMES) +_FAILED_LOGIN_SOURCE_CACHE: Final = _bounded_store(_MAX_TRACKED_LOGIN_SOURCES) _NO_SETTINGS: Final = MappingProxyType({}) +_DELAYS_IN_FLIGHT: Final[dict[str, int]] = {} + + +async def _sleep(seconds: float) -> None: + """The wait a rejected sign-in is held for. Replaced in tests so the suite pays no wall clock.""" + await asyncio.sleep(seconds) + + +class FailureCounts(NamedTuple): + """Failures recorded so far in this window against each of the two keys.""" + + username: int + source: int + def _int_setting(name: str, value: object, default: int, minimum: int) -> int: if value is None: @@ -56,12 +92,14 @@ def _as_count(cached: object) -> int: @dataclass(frozen=True, slots=True) class LoginThrottle: - """Fixed-window failed-login accounting for one request's source address.""" + """Fixed-window failed-login accounting for one request's username and source address.""" client_ip: str max_attempts: int + max_attempts_per_source: int window_seconds: int - cache: DualCache + username_cache: DualCache + source_cache: DualCache redis_cache: RedisCache | None = None enabled: bool = True @@ -85,13 +123,20 @@ class LoginThrottle: DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS, 1, ), + max_attempts_per_source=_int_setting( + "max_failed_login_attempts_per_source", + settings.get("max_failed_login_attempts_per_source"), + DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS_PER_SOURCE, + 1, + ), window_seconds=_int_setting( "failed_login_window_seconds", settings.get("failed_login_window_seconds"), DEFAULT_FAILED_LOGIN_WINDOW_SECONDS, 1, ), - cache=_FAILED_LOGIN_CACHE, + username_cache=_FAILED_LOGIN_USERNAME_CACHE, + source_cache=_FAILED_LOGIN_SOURCE_CACHE, redis_cache=redis_usage_cache, enabled=not get_secret_bool("LITELLM_DISABLE_LOGIN_RATE_LIMIT"), ) @@ -101,9 +146,13 @@ class LoginThrottle: """The username with anything that could forge a log line removed.""" return "".join(c for c in username if c.isprintable())[:_MAX_LOGGED_USERNAME_CHARS] - def _key(self, username: str) -> str: + @staticmethod + def _username_key(username: str) -> str: identity: Final = hashlib.sha256(username.casefold().encode("utf-8")).hexdigest() - return f"{_CACHE_KEY_PREFIX}:{identity}:{self.client_ip}" + return f"{_CACHE_KEY_PREFIX}:user:{identity}" + + def _source_key(self) -> str: + return f"{_CACHE_KEY_PREFIX}:source:{self.client_ip}" async def _outcome(self, work: Awaitable[object]) -> object: try: @@ -112,66 +161,147 @@ class LoginThrottle: verbose_proxy_logger.warning("login attempt accounting unavailable: %s", exc) return None - async def _failures(self, key: str) -> int: - store: Final = self.cache if self.redis_cache is None else self.redis_cache - return _as_count(await self._outcome(store.async_get_cache(key=key))) + async def _failures(self, store: DualCache, key: str) -> int: + """The larger of the shared and the process-local count, so a Redis outage degrades + to per-worker accounting instead of switching the control off.""" + local: Final = _as_count(await self._outcome(store.async_get_cache(key=key))) + redis_cache: Final = self.redis_cache + if redis_cache is None: + return local + return max(local, _as_count(await self._outcome(redis_cache.async_get_cache(key)))) - async def _ensure_expiry(self, key: str) -> None: - """Give the counter an expiry if it somehow has none. + async def _remaining_window(self, key: str) -> int: + """Seconds until this counter expires, repairing a counter left without an expiry. Redis commits the increment before setting the TTL, so a failure in between can leave a counter that never expires. Nothing increments the key again once the - limit is reached, so without this the pair would stay refused indefinitely. + limit is reached, so without the repair the key would stay refused indefinitely. """ redis_cache: Final = self.redis_cache if redis_cache is None: - return - if isinstance(await self._outcome(redis_cache.async_get_ttl(key)), int): - return + return self.window_seconds + ttl: Final = await self._outcome(redis_cache.async_get_ttl(key)) + if isinstance(ttl, int) and ttl > 0: + return min(ttl, self.window_seconds) await self._outcome(redis_cache.async_increment(key, 0, ttl=self.window_seconds)) + return self.window_seconds - async def raise_if_blocked(self, username: str) -> None: - """Deny before the database lookup and before the password comparison.""" - if not self.enabled: - return - key: Final = self._key(username) - if await self._failures(key) < self.max_attempts: - return - await self._ensure_expiry(key) - verbose_proxy_logger.warning( - "Admin UI sign-in attempts exhausted for username=%s source=%s; %s attempts in %ss, retry after %ss", - self._loggable(username), - self.client_ip, - self.max_attempts, - self.window_seconds, - self.window_seconds, - ) - raise ProxyException( + def _refused(self, retry_after: int, param: str) -> ProxyException: + return ProxyException( message="Too many failed sign-in attempts. Try again later.", type=ProxyErrorTypes.auth_error, - param="max_failed_login_attempts", + param=param, code=429, - headers={"Retry-After": str(self.window_seconds)}, # mutable-ok: ProxyException coerces header values + headers={"Retry-After": str(retry_after)}, # mutable-ok: ProxyException coerces header values ) - async def record_failure(self, username: str) -> None: - """Count one rejected credential guess against this username and source.""" + async def _refuse(self, key: str, scope: str, param: str, username: str, failures: int, limit: int) -> NoReturn: + retry_after: Final = await self._remaining_window(key) + verbose_proxy_logger.warning( + "Admin UI sign-in attempts exhausted for %s; username=%s source=%s failures=%s limit=%s window=%ss", + scope, + self._loggable(username), + self.client_ip, + failures, + limit, + self.window_seconds, + ) + raise self._refused(retry_after, param) + + async def raise_if_blocked(self, username: str) -> None: + """Refuse before the database lookup and before the invite-link password hash.""" if not self.enabled: return - key: Final = self._key(username) + username_key: Final = self._username_key(username) + source_key: Final = self._source_key() + username_failures: Final = await self._failures(self.username_cache, username_key) + if username_failures >= self.max_attempts: + await self._refuse( + username_key, "username", "max_failed_login_attempts", username, username_failures, self.max_attempts + ) + source_failures: Final = await self._failures(self.source_cache, source_key) + if source_failures >= self.max_attempts_per_source: + await self._refuse( + source_key, + "source address", + "max_failed_login_attempts_per_source", + username, + source_failures, + self.max_attempts_per_source, + ) + + async def _bump(self, store: DualCache, key: str) -> int: + local: Final = _as_count( + await self._outcome(store.async_increment_cache(key=key, value=1, ttl=self.window_seconds)) + ) redis_cache: Final = self.redis_cache - if redis_cache is not None: - await self._outcome(redis_cache.async_increment(key, 1, ttl=self.window_seconds)) - await self._ensure_expiry(key) + if redis_cache is None: + return local + shared: Final = _as_count(await self._outcome(redis_cache.async_increment(key, 1, ttl=self.window_seconds))) + await self._remaining_window(key) + return max(local, shared) + + async def record_failure(self, username: str) -> FailureCounts: + """Count one rejected credential guess against this username and against this source.""" + if not self.enabled: + return FailureCounts(username=0, source=0) + return FailureCounts( + username=await self._bump(self.username_cache, self._username_key(username)), + source=await self._bump(self.source_cache, self._source_key()), + ) + + @staticmethod + def delay_seconds(counts: FailureCounts) -> float: + """Seconds to hold a rejected attempt for, doubling per failure past whichever onset is further along.""" + steps: Final = min( + max(counts.username - USERNAME_DELAY_ONSET, counts.source - SOURCE_DELAY_ONSET), + _MAX_DELAY_DOUBLINGS, + ) + if steps < 0: + return 0.0 + return min(FIRST_DELAY_SECONDS * float(2**steps), MAX_DELAY_SECONDS) + + async def delay_for(self, username: str, counts: FailureCounts) -> None: + """Hold this rejected attempt open before answering it, so guessing costs wall-clock time. + + Only ever reached once the credentials are known to be wrong, so a valid password is + never delayed. Sources are capped at ``MAX_CONCURRENT_DELAYS_PER_SOURCE`` held + connections; over that, the attempt is refused immediately instead of parking a socket. + """ + if not self.enabled: return - await self._outcome(self.cache.async_increment_cache(key=key, value=1, ttl=self.window_seconds)) + delay: Final = self.delay_seconds(counts) + if delay <= 0: + return + in_flight: Final = _DELAYS_IN_FLIGHT.get(self.client_ip, 0) + if in_flight >= MAX_CONCURRENT_DELAYS_PER_SOURCE: + verbose_proxy_logger.warning( + "Admin UI sign-in attempts held concurrently exhausted; username=%s source=%s in_flight=%s", + self._loggable(username), + self.client_ip, + in_flight, + ) + raise self._refused(int(MAX_DELAY_SECONDS), "concurrent_failed_logins") + _DELAYS_IN_FLIGHT[self.client_ip] = in_flight + 1 + try: + await _sleep(delay) + finally: + remaining: Final = _DELAYS_IN_FLIGHT.get(self.client_ip, 1) - 1 + if remaining > 0: + _DELAYS_IN_FLIGHT[self.client_ip] = remaining + else: + _DELAYS_IN_FLIGHT.pop(self.client_ip, None) async def clear(self, username: str) -> None: - """Drop the bucket after a successful sign-in.""" + """Drop the username counter after a successful sign-in. + + The source counter is left alone. It is shared by every account behind that address, + so one success there says nothing about the other attempts it is counting. + """ if not self.enabled: return - key: Final = self._key(username) + key: Final = self._username_key(username) redis_cache: Final = self.redis_cache if redis_cache is not None: await self._outcome(redis_cache.async_delete_cache(key)) - await self._outcome(self.cache.async_delete_cache(key=key)) + await self._outcome(self.username_cache.async_delete_cache(key=key)) diff --git a/litellm/proxy/auth/login_utils.py b/litellm/proxy/auth/login_utils.py index 98780a4692a..8835227cd1c 100644 --- a/litellm/proxy/auth/login_utils.py +++ b/litellm/proxy/auth/login_utils.py @@ -145,10 +145,15 @@ async def authenticate_user( code=500, ) - await throttle.raise_if_blocked(username) - ui_username, ui_password = get_ui_credentials(master_key) + admin_credentials_match: Final = secrets.compare_digest( + username.encode("utf-8"), ui_username.encode("utf-8") + ) and secrets.compare_digest(password.encode("utf-8"), ui_password.encode("utf-8")) + + if not admin_credentials_match: + await throttle.raise_if_blocked(username) + # Check if we can find the `username` in the db. On the UI, users can enter username=their email _user_row: LiteLLM_UserTable | None = None user_role: ( @@ -174,9 +179,7 @@ async def authenticate_user( - Login with UI_USERNAME and UI_PASSWORD - Login with Invite Link `user_email` and `password` combination """ - if secrets.compare_digest(username.encode("utf-8"), ui_username.encode("utf-8")) and secrets.compare_digest( - password.encode("utf-8"), ui_password.encode("utf-8") - ): + if admin_credentials_match: # Non SSO -> If user is using UI_USERNAME and UI_PASSWORD they are Proxy admin user_role = LitellmUserRoles.PROXY_ADMIN user_id = LITELLM_PROXY_ADMIN_NAME @@ -314,7 +317,7 @@ async def authenticate_user( login_method="username_password", ) else: - await throttle.record_failure(username) + await throttle.delay_for(username, await throttle.record_failure(username)) raise ProxyException( message=INVALID_UI_CREDENTIALS_MESSAGE, type=ProxyErrorTypes.auth_error, @@ -322,7 +325,7 @@ async def authenticate_user( code=401, ) else: - await throttle.record_failure(username) + await throttle.delay_for(username, await throttle.record_failure(username)) raise ProxyException( message=INVALID_UI_CREDENTIALS_MESSAGE, type=ProxyErrorTypes.auth_error, diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index 0449802abae..a2e3a649586 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -1358,6 +1358,10 @@ def run_server( # DO NOT DELETE - enables global variables to work across files from litellm.proxy.proxy_server import app + # Write the resolved --num_workers back to its env var so worker processes can read + # the fleet size at startup (the failed-login accounting warning keys off it) + os.environ["NUM_WORKERS"] = str(num_workers) + # Auto-create PROMETHEUS_MULTIPROC_DIR for multi-worker setups ProxyInitializationHelpers._maybe_setup_prometheus_multiproc_dir( num_workers=num_workers, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 39a419df73f..c6a1dc4cdb7 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2264,6 +2264,7 @@ user_custom_key_generate = None # Tests that need to reset it can patch 'litellm.proxy.proxy_server._pkce_no_redis_warning_emitted'. _pkce_no_redis_warning_emitted: bool = False _cp_no_redis_warning_emitted: bool = False +_login_throttle_no_redis_warning_emitted: bool = False user_custom_key_update = None user_custom_sso = None user_custom_ui_sso_sign_in_handler = None @@ -5317,6 +5318,21 @@ class ProxyConfig: "or ensure sticky sessions for single-instance deployments." ) + ### FAILED-LOGIN ACCOUNTING MULTI-INSTANCE PREREQUISITE CHECK ### + # Failed Admin UI sign-in counters live in redis_usage_cache when available so a + # brute-force run is counted once across workers instead of once per worker. + if os.getenv("NUM_WORKERS", "1") != "1" and redis_usage_cache is None: + global _login_throttle_no_redis_warning_emitted + if not _login_throttle_no_redis_warning_emitted: + _login_throttle_no_redis_warning_emitted = True + verbose_proxy_logger.warning( + "Running %s workers but Redis is not configured for LiteLLM caching. " + "Failed Admin UI sign-in attempts are counted per worker, so an attacker " + "gets max_failed_login_attempts guesses per worker instead of overall. " + "Configure Redis via the 'cache' section in your proxy config.", + os.getenv("NUM_WORKERS", "1"), + ) + ### STORE MODEL IN DB ### feature flag for `/model/new` store_model_in_db = general_settings.get("store_model_in_db", False) if store_model_in_db is None: @@ -14756,13 +14772,26 @@ async def login(request: Request): password: Final = str(form.get("password")) # Authenticate user and get login result - login_result: Final = await authenticate_user( - username=username, - password=password, - master_key=master_key, - prisma_client=prisma_client, - throttle=LoginThrottle.from_request(request), - ) + try: + login_result: Final = await authenticate_user( + username=username, + password=password, + master_key=master_key, + prisma_client=prisma_client, + throttle=LoginThrottle.from_request(request), + ) + except ProxyException as exc: + if int(exc.code) != status.HTTP_429_TOO_MANY_REQUESTS: + raise + retry_after: Final = exc.headers.get("Retry-After", "30") + return HTMLResponse( + content=( + "

Too many sign-in attempts

" + f"

Try again in about {retry_after} seconds

" + ), + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + headers=exc.headers, + ) # Create UI token object returned_ui_token_object: Final = create_ui_token_object( diff --git a/tests/test_litellm/proxy/auth/test_login_utils.py b/tests/test_litellm/proxy/auth/test_login_utils.py index 1e3fcaf5d78..1b85c9f4046 100644 --- a/tests/test_litellm/proxy/auth/test_login_utils.py +++ b/tests/test_litellm/proxy/auth/test_login_utils.py @@ -6,17 +6,48 @@ to login_utils.py for better reusability. """ import os +from typing import Final from unittest.mock import AsyncMock, MagicMock, patch import pytest +class _RecordedSleeps: + """A sleep that records what it was asked to wait for instead of waiting.""" + + def __init__(self): + self.seconds: list[float] = [] + + async def __call__(self, seconds: float) -> None: + self.seconds.append(seconds) + + +@pytest.fixture(autouse=True) +def login_delays(monkeypatch): + """Replace the failed-login wait, so the suite pays no wall clock and can read it back.""" + from litellm.proxy.auth import login_throttle + + recorded = _RecordedSleeps() + monkeypatch.setattr(login_throttle, "_sleep", recorded) + login_throttle._DELAYS_IN_FLIGHT.clear() + yield recorded + login_throttle._DELAYS_IN_FLIGHT.clear() + + def _unlimited_throttle(): """A throttle wired to a real in-memory store with a limit no test can reach.""" from litellm.caching.dual_cache import DualCache from litellm.proxy.auth.login_throttle import LoginThrottle - return LoginThrottle(client_ip="1.2.3.4", max_attempts=10_000, window_seconds=900, cache=DualCache()) + store: Final = DualCache() + return LoginThrottle( + client_ip="1.2.3.4", + max_attempts=10_000, + max_attempts_per_source=10_000, + window_seconds=900, + username_cache=store, + source_cache=store, + ) @@ -628,16 +659,26 @@ class TestEncodeUiSessionJwt: # --------------------------------------------------------------------------- -def _throttle(max_attempts: int = 3, window_seconds: int = 900, client_ip: str = "1.2.3.4", cache=None, redis_cache=None): +def _throttle( + max_attempts: int = 3, + window_seconds: int = 900, + client_ip: str = "1.2.3.4", + cache=None, + redis_cache=None, + max_attempts_per_source: int = 10_000, +): """A throttle over a real in-memory store, so the tests exercise the true counters.""" from litellm.caching.dual_cache import DualCache from litellm.proxy.auth.login_throttle import LoginThrottle + store: Final = cache if cache is not None else DualCache() return LoginThrottle( client_ip=client_ip, max_attempts=max_attempts, + max_attempts_per_source=max_attempts_per_source, window_seconds=window_seconds, - cache=cache if cache is not None else DualCache(), + username_cache=store, + source_cache=store, redis_cache=redis_cache, ) @@ -675,21 +716,32 @@ async def test_attempts_are_refused_once_the_limit_is_reached(monkeypatch): @pytest.mark.asyncio -async def test_a_correct_password_is_refused_while_blocked(monkeypatch): - """The check precedes the credential comparison, so being over the limit wins.""" +async def test_a_correct_admin_password_is_accepted_while_blocked(monkeypatch): + """The configured admin credentials are compared before the gate, so the operator gets in. + + A throttle that refuses a valid password hands anyone who can reach the login form a + denial of service against the one account that can fix it. + """ from litellm.proxy._types import ProxyException monkeypatch.setenv("UI_USERNAME", "admin") monkeypatch.setenv("UI_PASSWORD", "right") + monkeypatch.setenv("DATABASE_URL", "postgresql://stub") throttle = _throttle(max_attempts=2) for _ in range(2): with pytest.raises(ProxyException): await _guess(throttle) - with pytest.raises(ProxyException) as blocked: - await _guess(throttle, password="right") - assert blocked.value.code == "429" + with pytest.raises(ProxyException) as still_blocked: + await _guess(throttle) + assert still_blocked.value.code == "429", "a wrong password is still refused" + + with patch("litellm.proxy.auth.login_utils.user_update", new=AsyncMock()), patch( + "litellm.proxy.auth.login_utils.generate_key_helper_fn", new=AsyncMock(return_value={"token": "sk-ui"}) + ): + result = await _guess(throttle, password="right") + assert result.key == "sk-ui" @pytest.mark.asyncio @@ -700,18 +752,18 @@ async def test_a_blocked_attempt_does_not_extend_the_window(monkeypatch): monkeypatch.setenv("UI_USERNAME", "admin") monkeypatch.setenv("UI_PASSWORD", "right") throttle = _throttle(max_attempts=2) - key = throttle._key("admin") + key = throttle._username_key("admin") for _ in range(2): with pytest.raises(ProxyException): await _guess(throttle) - counted_at_limit = await throttle._failures(key) + counted_at_limit = await throttle._failures(throttle.username_cache, key) for _ in range(5): with pytest.raises(ProxyException): await _guess(throttle) - assert await throttle._failures(key) == counted_at_limit == 2 + assert await throttle._failures(throttle.username_cache, key) == counted_at_limit == 2 @pytest.mark.asyncio @@ -733,7 +785,7 @@ async def test_a_successful_sign_in_clears_the_bucket(monkeypatch): ): await _guess(throttle, password="right") - assert await throttle._failures(throttle._key("admin")) == 0 + assert await throttle._failures(throttle.username_cache, throttle._username_key("admin")) == 0 @pytest.mark.asyncio @@ -749,7 +801,7 @@ async def test_a_configuration_error_never_counts(monkeypatch): ) assert exc.value.code == "500" - assert await throttle._failures(throttle._key("admin")) == 0 + assert await throttle._failures(throttle.username_cache, throttle._username_key("admin")) == 0 @pytest.mark.asyncio @@ -773,7 +825,7 @@ async def test_the_username_is_case_folded_into_one_bucket(monkeypatch): @pytest.mark.asyncio async def test_a_different_username_from_the_same_source_is_unaffected(monkeypatch): - """The bucket is the pair, so one username's failures do not block another.""" + """The counters are independent, so one username's failures do not exhaust another's.""" from litellm.proxy._types import ProxyException monkeypatch.setenv("UI_USERNAME", "admin") @@ -853,7 +905,7 @@ async def test_a_user_with_no_password_set_does_not_consume_the_budget(monkeypat ) assert exc.value.code == "401" - assert await throttle._failures(throttle._key("nopass@example.com")) == 0 + assert await throttle._failures(throttle.username_cache, throttle._username_key("nopass@example.com")) == 0 @pytest.mark.asyncio @@ -896,11 +948,40 @@ async def test_a_wrong_password_for_a_known_user_also_counts(monkeypatch): @pytest.mark.asyncio -async def test_two_source_addresses_do_not_share_a_bucket(monkeypatch): - """The key is the pair, so one address exhausting its budget must not block another. +async def test_one_source_exhausting_its_own_budget_does_not_refuse_another_source(monkeypatch): + """The source counter is per address, so a noisy office does not take its neighbour down.""" + from litellm.caching.dual_cache import DualCache + from litellm.proxy._types import ProxyException - Dropping the address from the key would turn this into the username-only counter the - design rejects, where anyone can lock a named admin out from anywhere. + monkeypatch.setenv("UI_USERNAME", "admin") + monkeypatch.setenv("UI_PASSWORD", "right") + shared_store = DualCache() + attacker = _throttle( + max_attempts=10_000, max_attempts_per_source=2, client_ip="203.0.113.9", cache=shared_store + ) + operator = _throttle( + max_attempts=10_000, max_attempts_per_source=2, client_ip="198.51.100.7", cache=shared_store + ) + + for i in range(2): + with pytest.raises(ProxyException): + await _guess(attacker, username=f"target-{i}@corp.com") + + with pytest.raises(ProxyException) as blocked: + await _guess(attacker, username="target-2@corp.com") + assert blocked.value.code == "429" + + with pytest.raises(ProxyException) as unaffected: + await _guess(operator, username="target-3@corp.com") + assert unaffected.value.code == "401", "the other address must still reach the credential check" + + +@pytest.mark.asyncio +async def test_a_username_exhausted_from_one_source_is_refused_from_another(monkeypatch): + """The username counter carries no address, so spreading the guesses buys nothing. + + The pair key this replaced reset the budget for every new address, which is exactly the + shape of a credential-stuffing run from a proxy pool. """ from litellm.caching.dual_cache import DualCache from litellm.proxy._types import ProxyException @@ -908,20 +989,154 @@ async def test_two_source_addresses_do_not_share_a_bucket(monkeypatch): monkeypatch.setenv("UI_USERNAME", "admin") monkeypatch.setenv("UI_PASSWORD", "right") shared_store = DualCache() - attacker = _throttle(max_attempts=2, client_ip="203.0.113.9", cache=shared_store) - operator = _throttle(max_attempts=2, client_ip="198.51.100.7", cache=shared_store) + first_hop = _throttle(max_attempts=2, client_ip="203.0.113.9", cache=shared_store) + second_hop = _throttle(max_attempts=2, client_ip="198.51.100.7", cache=shared_store) - for _ in range(3): + for _ in range(2): with pytest.raises(ProxyException): - await _guess(attacker, username="admin") + await _guess(first_hop, username="victim@corp.com") + + with pytest.raises(ProxyException) as rotated: + await _guess(second_hop, username="victim@corp.com") + assert rotated.value.code == "429" + + +@pytest.mark.asyncio +async def test_a_source_wide_spray_is_counted_even_though_each_username_is_fresh(monkeypatch): + """One guess against each of many usernames never trips a username counter, only the source one.""" + from litellm.proxy._types import ProxyException + + monkeypatch.setenv("UI_USERNAME", "admin") + monkeypatch.setenv("UI_PASSWORD", "right") + throttle = _throttle(max_attempts=10_000, max_attempts_per_source=6, client_ip="203.0.113.11") + + for i in range(6): + with pytest.raises(ProxyException) as rejected: + await _guess(throttle, username=f"sprayed-{i}@corp.com") + assert rejected.value.code == "401" with pytest.raises(ProxyException) as blocked: - await _guess(attacker, username="admin") + await _guess(throttle, username="sprayed-7@corp.com") assert blocked.value.code == "429" + assert await throttle._failures(throttle.username_cache, throttle._username_key("sprayed-7@corp.com")) == 0 - with pytest.raises(ProxyException) as unaffected: - await _guess(operator, username="admin") - assert unaffected.value.code == "401", "the real operator must still reach the credential check" + +@pytest.mark.asyncio +async def test_a_successful_sign_in_leaves_the_source_counter_alone(monkeypatch): + """One account's success says nothing about the other attempts the address is making.""" + from litellm.proxy._types import ProxyException + + monkeypatch.setenv("UI_USERNAME", "admin") + monkeypatch.setenv("UI_PASSWORD", "right") + monkeypatch.setenv("DATABASE_URL", "postgresql://stub") + throttle = _throttle(max_attempts=10) + + for _ in range(2): + with pytest.raises(ProxyException): + await _guess(throttle) + + with patch("litellm.proxy.auth.login_utils.user_update", new=AsyncMock()), patch( + "litellm.proxy.auth.login_utils.generate_key_helper_fn", new=AsyncMock(return_value={"token": "sk-ui"}) + ): + await _guess(throttle, password="right") + + assert await throttle._failures(throttle.username_cache, throttle._username_key("admin")) == 0 + assert await throttle._failures(throttle.source_cache, throttle._source_key()) == 2 + + +@pytest.mark.asyncio +async def test_the_delay_doubles_from_one_second_and_is_capped(monkeypatch, login_delays): + """Guessing has to cost wall clock, and the cost has to stop short of an unbounded hang.""" + from litellm.proxy._types import ProxyException + from litellm.proxy.auth.login_throttle import MAX_DELAY_SECONDS + + monkeypatch.setenv("UI_USERNAME", "admin") + monkeypatch.setenv("UI_PASSWORD", "right") + throttle = _throttle(max_attempts=10_000) + + for _ in range(9): + with pytest.raises(ProxyException): + await _guess(throttle) + + assert login_delays.seconds == [1.0, 2.0, 4.0, 8.0, 16.0, 30.0, 30.0], ( + "the first two failures answer immediately, then the wait doubles up to the cap" + ) + assert max(login_delays.seconds) == MAX_DELAY_SECONDS + + +@pytest.mark.asyncio +async def test_the_delay_tracks_whichever_counter_is_further_past_its_onset(monkeypatch): + """A source deep into a spray must not be answered instantly just because the username is fresh.""" + from litellm.proxy.auth.login_throttle import FailureCounts, LoginThrottle + + assert LoginThrottle.delay_seconds(FailureCounts(username=1, source=1)) == 0.0 + assert LoginThrottle.delay_seconds(FailureCounts(username=2, source=24)) == 0.0 + assert LoginThrottle.delay_seconds(FailureCounts(username=3, source=1)) == 1.0 + assert LoginThrottle.delay_seconds(FailureCounts(username=1, source=25)) == 1.0 + assert LoginThrottle.delay_seconds(FailureCounts(username=4, source=28)) == 8.0 + + +@pytest.mark.asyncio +async def test_held_attempts_from_one_source_are_capped(monkeypatch): + """Holding a rejected attempt open must not let one address park unlimited sockets.""" + import asyncio + + from litellm.proxy._types import ProxyException + from litellm.proxy.auth import login_throttle as lt + from litellm.proxy.auth.login_throttle import MAX_CONCURRENT_DELAYS_PER_SOURCE + + monkeypatch.setenv("UI_USERNAME", "admin") + monkeypatch.setenv("UI_PASSWORD", "right") + release = asyncio.Event() + + async def _park(_seconds: float) -> None: + await release.wait() + + monkeypatch.setattr(lt, "_sleep", _park) + throttle = _throttle(max_attempts=10_000, client_ip="203.0.113.44") + await throttle.record_failure("admin") + await throttle.record_failure("admin") + + held = [asyncio.create_task(_guess(throttle)) for _ in range(MAX_CONCURRENT_DELAYS_PER_SOURCE)] + for _ in range(1000): + if lt._DELAYS_IN_FLIGHT.get("203.0.113.44") == MAX_CONCURRENT_DELAYS_PER_SOURCE: + break + await asyncio.sleep(0) + assert lt._DELAYS_IN_FLIGHT.get("203.0.113.44") == MAX_CONCURRENT_DELAYS_PER_SOURCE + + try: + with pytest.raises(ProxyException) as over_cap: + await _guess(throttle) + assert over_cap.value.code == "429" + assert over_cap.value.headers.get("Retry-After") == "30" + finally: + release.set() + for task in held: + with pytest.raises(ProxyException): + await task + + with pytest.raises(ProxyException) as after_drain: + await _guess(throttle) + assert after_drain.value.code == "401", "the cap must release once the held attempts answer" + + +@pytest.mark.asyncio +async def test_disabling_the_control_removes_the_delay_as_well(monkeypatch, login_delays): + """The escape hatch has to turn off the whole control, not only the refusal.""" + import dataclasses + + from litellm.proxy._types import ProxyException + + monkeypatch.setenv("UI_USERNAME", "admin") + monkeypatch.setenv("UI_PASSWORD", "right") + throttle = dataclasses.replace(_throttle(max_attempts=2), enabled=False) + + for _ in range(6): + with pytest.raises(ProxyException) as rejected: + await _guess(throttle) + assert rejected.value.code == "401" + + assert login_delays.seconds == [] class _NoExpiryRedis: @@ -1003,7 +1218,7 @@ async def test_counters_do_not_share_the_key_authentication_cache(monkeypatch): throttle = LoginThrottle.from_request(request) for i in range(25): - with pytest.raises(Exception): + with pytest.raises(ProxyException, match="Invalid credentials"): await _guess(throttle, username=f"made-up-{i}@example.com") added = set(ps.user_api_key_cache.in_memory_cache.cache_dict) - auth_cache_keys_before @@ -1055,14 +1270,29 @@ async def test_a_username_spray_cannot_evict_an_existing_counter(monkeypatch): back a fresh allowance against the real account. """ from litellm.proxy._types import ProxyException - from litellm.proxy.auth.login_throttle import _FAILED_LOGIN_CACHE, _MAX_TRACKED_LOGIN_SOURCES + from litellm.proxy.auth.login_throttle import ( + LoginThrottle, + _FAILED_LOGIN_SOURCE_CACHE, + _FAILED_LOGIN_USERNAME_CACHE, + _MAX_TRACKED_LOGIN_SOURCES, + _MAX_TRACKED_LOGIN_USERNAMES, + ) monkeypatch.setenv("UI_USERNAME", "admin") monkeypatch.setenv("UI_PASSWORD", "right") assert _MAX_TRACKED_LOGIN_SOURCES >= 10_000 - assert _FAILED_LOGIN_CACHE.in_memory_cache.max_size_in_memory == _MAX_TRACKED_LOGIN_SOURCES + assert _MAX_TRACKED_LOGIN_USERNAMES >= 10_000 + assert _FAILED_LOGIN_SOURCE_CACHE.in_memory_cache.max_size_in_memory == _MAX_TRACKED_LOGIN_SOURCES + assert _FAILED_LOGIN_USERNAME_CACHE.in_memory_cache.max_size_in_memory == _MAX_TRACKED_LOGIN_USERNAMES - throttle = _throttle(max_attempts=3, cache=_FAILED_LOGIN_CACHE, client_ip="10.9.9.9") + throttle = LoginThrottle( + client_ip="10.9.9.9", + max_attempts=3, + max_attempts_per_source=10_000, + window_seconds=900, + username_cache=_FAILED_LOGIN_USERNAME_CACHE, + source_cache=_FAILED_LOGIN_SOURCE_CACHE, + ) victim = "spray-victim@corp.com" for _ in range(3): with pytest.raises(ProxyException): @@ -1071,7 +1301,7 @@ async def test_a_username_spray_cannot_evict_an_existing_counter(monkeypatch): for i in range(500): await throttle.record_failure(f"spray-filler-{i}@corp.com") - assert await throttle._failures(throttle._key(victim)) == 3, "the counter must survive a spray" + assert await throttle._failures(throttle.username_cache, throttle._username_key(victim)) == 3, "the counter must survive a spray" with pytest.raises(ProxyException) as blocked: await _guess(throttle, username=victim) assert blocked.value.code == "429" diff --git a/tests/test_litellm/proxy/proxy_server/conftest.py b/tests/test_litellm/proxy/proxy_server/conftest.py index 22f2e5b3feb..56aa87f1e50 100644 --- a/tests/test_litellm/proxy/proxy_server/conftest.py +++ b/tests/test_litellm/proxy/proxy_server/conftest.py @@ -517,27 +517,38 @@ def make_key( def reset_login_throttle(monkeypatch): """Clear the Admin UI failed-login counters between tests. - `client` is session scoped and the counters live in the shared `user_api_key_cache` - with a 900s window, so without this any test that fails a sign-in enough times would - start returning 429 from unrelated tests later in the same process. Only the throttle's - own keys are removed, so nothing else in that cache is disturbed. + `client` is session scoped and the counters live in shared module stores with a 900s + window, so without this a failed sign-in test could return 429 in unrelated tests later. + Only the throttle's own keys are removed, so other cache entries remain untouched. """ from litellm.proxy import proxy_server as ps - from litellm.proxy.auth.login_throttle import _FAILED_LOGIN_CACHE, _CACHE_KEY_PREFIX + from litellm.proxy.auth import login_throttle + from litellm.proxy.auth.login_throttle import ( + _CACHE_KEY_PREFIX, + _FAILED_LOGIN_SOURCE_CACHE, + _FAILED_LOGIN_USERNAME_CACHE, + ) + + async def _no_delay(_seconds: float) -> None: + """The escalating wait on a rejected sign-in, replaced so the route tests stay fast.""" + + monkeypatch.setattr(login_throttle, "_sleep", _no_delay) def _drop_throttle_keys() -> None: - in_memory = getattr(_FAILED_LOGIN_CACHE, "in_memory_cache", None) - if in_memory is None: - return - tracked = tuple( - key - for store in (getattr(in_memory, "cache_dict", None), getattr(in_memory, "ttl_dict", None)) - if isinstance(store, dict) - for key in tuple(store) - if str(key).startswith(_CACHE_KEY_PREFIX) - ) - for key in tracked: - in_memory.delete_cache(key) + login_throttle._DELAYS_IN_FLIGHT.clear() + for cache in (_FAILED_LOGIN_USERNAME_CACHE, _FAILED_LOGIN_SOURCE_CACHE): + in_memory = getattr(cache, "in_memory_cache", None) + if in_memory is None: + continue + tracked = tuple( + key + for store in (getattr(in_memory, "cache_dict", None), getattr(in_memory, "ttl_dict", None)) + if isinstance(store, dict) + for key in tuple(store) + if str(key).startswith(_CACHE_KEY_PREFIX) + ) + for key in tracked: + in_memory.delete_cache(key) monkeypatch.setattr(ps, "redis_usage_cache", None) _drop_throttle_keys() diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_login_sso.py b/tests/test_litellm/proxy/proxy_server/test_routes_login_sso.py index faeb7750e03..c8185cdb886 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_login_sso.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_login_sso.py @@ -531,8 +531,21 @@ def test_a_refused_attempt_carries_retry_after(client, monkeypatch, reset_login_ assert refused.headers.get("retry-after") == "77" +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=2, failed_login_window_seconds=77) + + assert [_form_login(client) for _ in range(2)] == [401, 401] + + refused = client.post("/login", data={"username": "admin", "password": "wrong"}) + assert refused.status_code == 429 + assert refused.headers.get("content-type", "").startswith("text/html") + assert "Try again in about 77 seconds" in refused.text + assert refused.headers.get("retry-after") == "77" + + def test_a_second_username_from_the_same_source_still_gets_through(client, monkeypatch, reset_login_throttle): - """The bucket is the username and source pair, so one account cannot block another.""" + """The username counter carries no address, so one account exhausting it cannot block another.""" _install_real_auth(monkeypatch, max_failed_login_attempts=2) for _ in range(3): @@ -541,6 +554,32 @@ def test_a_second_username_from_the_same_source_still_gets_through(client, monke assert _json_login(client, "/v2/login", username="someone-else@example.com") == 401 +def test_a_spray_across_usernames_is_refused_on_the_source_counter(client, monkeypatch, reset_login_throttle): + """A fresh username per guess keeps every username counter at one, so the address is what stops it.""" + _install_real_auth(monkeypatch, max_failed_login_attempts=100, max_failed_login_attempts_per_source=4) + + sprayed = [_json_login(client, "/v2/login", username=f"sprayed-{i}@corp.com") for i in range(4)] + assert sprayed == [401] * 4 + + assert _json_login(client, "/v2/login", username="sprayed-5@corp.com") == 429 + + +def test_the_configured_admin_password_still_signs_in_while_refused(client, monkeypatch, reset_login_throttle): + """The operator must never be locked out of the console by traffic aimed at it.""" + from unittest.mock import AsyncMock, patch + + _install_real_auth(monkeypatch, max_failed_login_attempts=2) + monkeypatch.setenv("DATABASE_URL", "postgresql://stub") + + assert [_json_login(client, "/v2/login") for _ in range(2)] == [401, 401] + assert _json_login(client, "/v2/login") == 429 + + with patch("litellm.proxy.auth.login_utils.user_update", new=AsyncMock()), patch( + "litellm.proxy.auth.login_utils.generate_key_helper_fn", new=AsyncMock(return_value={"token": "sk-ui"}) + ): + assert _json_login(client, "/v2/login", password="right-password") == 200 + + def test_sign_in_succeeds_again_once_the_budget_is_restored(client, monkeypatch, reset_login_throttle): """A cleared bucket lets the same username straight back in.""" _install_real_auth(monkeypatch, max_failed_login_attempts=2) diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index ac2e84b8474..17563f60a4f 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -11442,9 +11442,14 @@ async def test_login_throttle_settings_are_not_overridable_from_the_database(): try: ps.general_settings.clear() await ProxyConfig()._update_general_settings( - db_general_settings={"max_failed_login_attempts": 999, "failed_login_window_seconds": 1} + db_general_settings={ + "max_failed_login_attempts": 999, + "max_failed_login_attempts_per_source": 999, + "failed_login_window_seconds": 1, + } ) assert "max_failed_login_attempts" 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 finally: ps.general_settings.clear() diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index bb2841b6388..9d66320041f 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -24201,9 +24201,14 @@ export interface components { max_batch_file_size_mb?: number | null; /** * Max Failed Login Attempts - * @description Number of failed Admin UI sign-in attempts allowed for one username from one source address within `failed_login_window_seconds`, before further attempts are refused with 429. Configurable from config.yaml only. Defaults to 10 + * @description Number of failed Admin UI sign-in attempts allowed for one username, from any source address, within `failed_login_window_seconds`, before further attempts for that username are refused with 429. Attempts are answered with a doubling delay well before this ceiling. Configurable from config.yaml only. Defaults to 50 */ max_failed_login_attempts?: number | null; + /** + * Max Failed Login Attempts Per Source + * @description Number of failed Admin UI sign-in attempts allowed from one source address, across every username, within `failed_login_window_seconds`, before further attempts from that address are refused with 429. Counted independently of `max_failed_login_attempts`. Configurable from config.yaml only. Defaults to 250 + */ + max_failed_login_attempts_per_source?: number | null; /** * Max Parallel Requests * @description maximum parallel requests for each api key