diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 08380d8b537..80147863304 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2755,7 +2755,7 @@ 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, since otherwise every client behind an ingress shares one address. 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`. 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", ) max_failed_login_attempts_per_source_overrides: dict[str, int] | None = Field( None, @@ -2774,7 +2774,7 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): failed_login_block_seconds: int | None = Field( None, ge=1, - description="How long a blocked source address, or source address and username, stays blocked. The block is soft: a correct password still signs in, but each attempt from a blocked key takes one of 5 held slots per worker and a wrong password is held for 30 seconds before it is refused with 429. Set under `general_settings` in config.yaml. Defaults to 300", + description="How long a blocked source address, or source address and username, stays blocked. Every attempt from a blocked key, right or wrong, is refused with 429 before the password is checked; the block is not extended by refused attempts. Set under `general_settings` in config.yaml. Defaults to 300", ) allowed_routes: list | None = Field(None, description="Proxy API Endpoints you want users to be able to access") reject_clientside_metadata_tags: bool | None = Field( @@ -2885,7 +2885,7 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): ) trusted_proxy_ranges: list[str] | None = Field( None, - description="CIDR ranges of trusted reverse proxies allowed to provide identity headers for header-based auth paths such as enable_oauth2_proxy_auth and custom_ui_sso_sign_in_handler.", + description="CIDR ranges of trusted reverse proxies allowed to provide identity headers for header-based auth paths such as enable_oauth2_proxy_auth and custom_ui_sso_sign_in_handler, and whose X-Forwarded-For is used to attribute Admin UI sign-in attempts to a source address. Set it to an empty list when clients connect directly, so the peer address is the source. Left unset, the per-source sign-in limit is off.", ) store_model_in_db: bool | None = Field( None, diff --git a/litellm/proxy/auth/login_throttle.py b/litellm/proxy/auth/login_throttle.py index a8899b9c675..0106d58e426 100644 --- a/litellm/proxy/auth/login_throttle.py +++ b/litellm/proxy/auth/login_throttle.py @@ -1,10 +1,10 @@ """Failed-login accounting for the Admin UI sign-in path. Wrong passwords are counted over a short window per source address and per source-and-username -pair; too many in one window blocks that key for a fixed time. Blocks are soft: a correct password -still signs in, while a wrong one from a blocked key is held open before its 429 and only a few can -be held at once, which bounds how many guesses a blocked key gets checked. A blocked pair stops +pair; too many in one window blocks that key for a fixed time. While a key is blocked every attempt +from it, right or wrong, is refused with 429 before the password is checked. A blocked pair stops counting against its source, so one script stuck on one account does not block the whole office. +Recovery is the master key over the API, which never passes through here, or waiting out the block. """ from __future__ import annotations @@ -14,8 +14,7 @@ import hashlib import ipaddress import math import time -from collections.abc import AsyncGenerator, Mapping -from contextlib import asynccontextmanager +from collections.abc import Mapping from dataclasses import dataclass from functools import cache from types import MappingProxyType @@ -37,8 +36,6 @@ DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS_PER_USER: Final = 5 DEFAULT_FAILED_LOGIN_WINDOW_SECONDS: Final = 60 DEFAULT_FAILED_LOGIN_BLOCK_SECONDS: Final = 300 -BLOCKED_ATTEMPT_HOLD_SECONDS: Final = 30 -MAX_HELD_ATTEMPTS_PER_KEY: Final = 5 IPV6_SOURCE_PREFIX_LENGTH: Final = 64 SOURCE_LIMIT_KEY: Final = "max_failed_login_attempts_per_source" @@ -100,11 +97,6 @@ _COUNTERS: Final = InMemoryCache( max_size_in_memory=_MAX_TRACKED_COUNTERS, default_ttl=DEFAULT_FAILED_LOGIN_WINDOW_SECONDS ) _BLOCKS: Final = InMemoryCache(max_size_in_memory=_MAX_TRACKED_BLOCKS, default_ttl=DEFAULT_FAILED_LOGIN_BLOCK_SECONDS) -_HELD_ATTEMPTS: Final[dict[str, int]] = {} # mutable-ok: in-flight hold counts rise on entry and fall on exit - - -async def _sleep(seconds: float) -> None: - await asyncio.sleep(seconds) @cache @@ -127,12 +119,25 @@ def warn_login_counters_are_per_worker(num_workers: str) -> None: def warn_source_login_limit_is_off() -> None: verbose_proxy_logger.warning( "%s is not set, so failed Admin UI sign-in attempts are limited per source address and username " - "only. Set it to the address ranges of the proxies in front of LiteLLM to also limit each " - "source address across usernames.", + "only. Set it to the address ranges of the proxies in front of LiteLLM, or to an empty list when " + "clients connect directly, to also limit each source address across usernames.", TRUSTED_PROXY_RANGES_KEY, ) +def declared_proxy_ranges(settings: Mapping[str, object]) -> tuple[str, ...] | None: + """What the operator says fronts LiteLLM: the proxy ranges, an empty tuple for none, None when unsaid. + + Only a declared topology makes the source address trustworthy enough to limit across usernames. + An unset key, or a value that is not a list of ranges, leaves it unknown and the source scope off. + """ + raw_ranges: Final = settings.get(TRUSTED_PROXY_RANGES_KEY) + if isinstance(raw_ranges, (list, tuple, set)) and not raw_ranges: + return () + cidrs: Final = tuple(normalize_cidr_ranges(raw_ranges, setting_name=TRUSTED_PROXY_RANGES_KEY)) + return cidrs or None + + def _positive_int(raw: object, key: str, default: int) -> int: if raw is None: return default @@ -221,8 +226,11 @@ class Block: @dataclass(frozen=True, slots=True) class LoginThrottle: - """Failed-login limits for one request's source address; ``source_limit`` is None when the - source scope is off because ``trusted_proxy_ranges`` is unset and the peer address is the ingress.""" + """Failed-login limits for one request's source address. + + ``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. + """ client_ip: str source_limit: int | None @@ -242,15 +250,13 @@ class LoginThrottle: redis_cache: RedisCache | None, ) -> LoginThrottle: settings: Final = general_settings if general_settings is not None else _NO_SETTINGS - cidrs: Final = normalize_cidr_ranges( - settings.get(TRUSTED_PROXY_RANGES_KEY), setting_name=TRUSTED_PROXY_RANGES_KEY - ) + proxies: Final = declared_proxy_ranges(settings) resolved, _ = resolve_client_ip( - request, TrustedProxyConfig(use_forwarded_for=bool(cidrs), trusted_proxy_cidrs=cidrs) + request, TrustedProxyConfig(use_forwarded_for=bool(proxies), trusted_proxy_cidrs=proxies or ()) ) return cls( client_ip=resolved or _UNKNOWN_SOURCE, - source_limit=_source_limit(settings, resolved) if cidrs and resolved is not None else None, + 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), window_seconds=_int_setting(settings, WINDOW_KEY, DEFAULT_FAILED_LOGIN_WINDOW_SECONDS), block_seconds=_int_setting(settings, BLOCK_KEY, DEFAULT_FAILED_LOGIN_BLOCK_SECONDS), @@ -270,18 +276,21 @@ class LoginThrottle: source_block=f"{_CACHE_KEY_PREFIX}:{{{group}}}:block:source", ) - @asynccontextmanager - async def attempt(self, username: str, *, exempt: bool = False) -> AsyncGenerator[LoginAttempt]: - if not self.enabled or exempt: - yield LoginAttempt(throttle=self, username=username, block=None) - return - keys: Final = self._keys(username) - block: Final = await self._active_block(keys) + async def attempt(self, username: str) -> LoginAttempt: + """Refuses a blocked key before any credential is looked at; otherwise hands back the attempt to settle.""" + if not self.enabled: + return LoginAttempt(throttle=self, username=username) + block: Final = await self._active_block(self._keys(username)) if block is None: - yield LoginAttempt(throttle=self, username=username, block=None) - return - slot: Final = keys.pair_block if block.scope == "user" else keys.source_block - yield LoginAttempt(throttle=self, username=username, block=block, slot=slot) + return LoginAttempt(throttle=self, username=username) + verbose_proxy_logger.warning( + "Admin UI sign-in refused: the %s is blocked for %s more seconds; username=%r source=%s", + block.scope, + block.retry_after, + username, + self.client_ip, + ) + self.refuse(block.retry_after) async def _active_block(self, keys: _Keys) -> Block | None: local: Final = self._local_block_ttls(keys) @@ -372,8 +381,6 @@ class LoginThrottle: class LoginAttempt: throttle: LoginThrottle username: str - block: Block | None - slot: str | None = None async def succeeded(self) -> None: if not self.throttle.enabled: @@ -383,8 +390,6 @@ class LoginAttempt: async def failed(self) -> None: if not self.throttle.enabled: return - if self.block is not None and self.slot is not None: - await self._hold_then_refuse(self.block, self.slot) user_block, source_block = await self.throttle.record_failure(self.username) if user_block == 0 and source_block == 0: return @@ -395,26 +400,3 @@ class LoginAttempt: self.username, self.throttle.client_ip, ) - - async def _hold_then_refuse(self, block: Block, slot: str) -> NoReturn: - held: Final = _HELD_ATTEMPTS.get(slot, 0) - if held >= MAX_HELD_ATTEMPTS_PER_KEY: - verbose_proxy_logger.warning( - "Admin UI sign-in refused at once: %s wrong attempts already held for a blocked %s; " - "username=%r source=%s", - held, - block.scope, - self.username, - self.throttle.client_ip, - ) - self.throttle.refuse(block.retry_after) - _HELD_ATTEMPTS[slot] = held + 1 - try: - await _sleep(BLOCKED_ATTEMPT_HOLD_SECONDS) - finally: - remaining: Final = _HELD_ATTEMPTS.get(slot, 1) - 1 - if remaining > 0: - _HELD_ATTEMPTS[slot] = remaining - else: - _HELD_ATTEMPTS.pop(slot, None) - self.throttle.refuse(max(block.retry_after - BLOCKED_ATTEMPT_HOLD_SECONDS, 1)) diff --git a/litellm/proxy/auth/login_utils.py b/litellm/proxy/auth/login_utils.py index 6f52babb255..e0d599b0017 100644 --- a/litellm/proxy/auth/login_utils.py +++ b/litellm/proxy/auth/login_utils.py @@ -186,9 +186,11 @@ async def authenticate_user( or if username/password login is disabled while SSO is configured Recovery: an admin locked out of the UI by - `disable_password_login_when_sso_enabled` can still administer the proxy over - the API with the master key (Authorization: Bearer ), which never - goes through this function. To restore UI username/password login, unset the + `disable_password_login_when_sso_enabled`, or by the failed sign-in block in + `throttle`, can still administer the proxy over the API with the master key + (Authorization: Bearer ), which never goes through this function. + No credential, the env admin credentials and the master key included, is + exempt from the block. To restore UI username/password login, unset the setting in config.yaml (or the DB-persisted general_settings) and restart the proxy; this is a deliberate, auditable config change rather than a hidden bypass. @@ -217,12 +219,8 @@ async def authenticate_user( code=500, ) - admin_credentials_match: Final = _admin_credentials_match(username, password, master_key, general_settings) - - async with throttle.attempt(username, exempt=admin_credentials_match) as attempt: - return await _sign_in( - username, password, master_key, prisma_client, attempt, general_settings, admin_credentials_match - ) + attempt: Final = await throttle.attempt(username) + return await _sign_in(username, password, master_key, prisma_client, attempt, general_settings) async def _sign_in( @@ -232,8 +230,8 @@ async def _sign_in( prisma_client: PrismaClient | None, attempt: LoginAttempt, general_settings: Mapping[str, object], - admin_credentials_match: bool, ) -> LoginResult: + admin_credentials_match: Final = _admin_credentials_match(username, password, master_key, general_settings) # 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: ( diff --git a/litellm/proxy/auth/network.py b/litellm/proxy/auth/network.py index 8a20e207113..4e8ab7512a7 100644 --- a/litellm/proxy/auth/network.py +++ b/litellm/proxy/auth/network.py @@ -1,6 +1,7 @@ from __future__ import annotations import ipaddress +from collections.abc import Sequence from typing import Any, Final from fastapi import Request @@ -19,7 +20,7 @@ class NetworkContext(BaseModel): class TrustedProxyConfig(BaseModel): use_forwarded_for: bool = False - trusted_proxy_cidrs: list[str] = Field(default_factory=list) + trusted_proxy_cidrs: Sequence[str] = Field(default_factory=tuple) def normalize_cidr_ranges(configured_ranges: Any, *, setting_name: str = "trusted_proxy_cidrs") -> list[str]: diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index d00c695d968..4075ac4aff7 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -328,8 +328,8 @@ from litellm.proxy.auth.fallback_model_access import router_fallback_access_chec from litellm.proxy.auth.handle_jwt import JWTHandler from litellm.proxy.auth.litellm_license import AUTO_ROUTER_LICENSE_REMEDY, LicenseCheck from litellm.proxy.auth.login_throttle import ( - TRUSTED_PROXY_RANGES_KEY, LoginThrottle, + declared_proxy_ranges, warn_login_counters_are_per_worker, warn_source_login_limit_is_off, ) @@ -5819,7 +5819,7 @@ class ProxyConfig: if os.getenv("NUM_WORKERS", "1") != "1" and redis_usage_cache is None: warn_login_counters_are_per_worker(os.getenv("NUM_WORKERS", "1")) - if not general_settings.get(TRUSTED_PROXY_RANGES_KEY): + if declared_proxy_ranges(general_settings) is None: warn_source_login_limit_is_off() _bg_hc_model_groups: Final = parse_background_health_check_model_groups(general_settings) diff --git a/tests/test_litellm/proxy/auth/test_login_utils.py b/tests/test_litellm/proxy/auth/test_login_utils.py index d1fdb2d6a70..8670d7fef41 100644 --- a/tests/test_litellm/proxy/auth/test_login_utils.py +++ b/tests/test_litellm/proxy/auth/test_login_utils.py @@ -13,28 +13,6 @@ 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 hold on a blocked wrong password, 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._HELD_ATTEMPTS.clear() - yield recorded - login_throttle._HELD_ATTEMPTS.clear() - - def _unlimited_throttle(): """A throttle wired to real in-memory stores with limits no test can reach.""" from litellm.caching.in_memory_cache import InMemoryCache @@ -749,8 +727,8 @@ def _local_count(throttle, key: str) -> int: @pytest.mark.asyncio async def test_too_many_failures_for_one_username_block_that_pair_and_carry_retry_after(monkeypatch): - """One failure past the pair limit blocks the source for that username; the next wrong guess is held - and answered 429 with the block's remaining time, and the counter is not touched by blocked guesses.""" + """One failure past the pair limit blocks the source for that username; the next guess is answered 429 + with the block's remaining time, and the counter is not touched by blocked guesses.""" from litellm.proxy._types import ProxyException monkeypatch.setenv("UI_USERNAME", "admin") @@ -764,30 +742,17 @@ async def test_too_many_failures_for_one_username_block_that_pair_and_carry_retr with pytest.raises(ProxyException) as blocked: await _guess(throttle) assert blocked.value.code == "429" - assert blocked.value.headers.get("Retry-After") == "47", "the 30s hold is taken off the remaining block" + assert blocked.value.headers.get("Retry-After") == "77" assert _local_count(throttle, keys.pair_counter) == 3, "a blocked guess is not counted again" @pytest.mark.asyncio -async def test_a_wrong_password_from_a_blocked_key_is_held_before_it_is_refused(monkeypatch, login_delays): - """The hold is the rate cap: a blocked key gets one verified guess per held slot per 30 seconds.""" - from litellm.proxy.auth.login_throttle import BLOCKED_ATTEMPT_HOLD_SECONDS +async def test_a_blocked_key_is_refused_before_the_password_is_looked_at(monkeypatch): + """The block is the rate cap: once a key is blocked, nothing from it reaches the user lookup or the + password check, so a guessing script gets no verification work out of the proxy.""" + from litellm.proxy._types import ProxyException + from litellm.proxy.auth.login_utils import authenticate_user - monkeypatch.setenv("UI_USERNAME", "admin") - monkeypatch.setenv("UI_PASSWORD", "right") - throttle = _throttle(user_limit=1) - - assert [await _fail(throttle) for _ in range(2)] == ["401", "401"] - assert login_delays.seconds == [], "an unblocked wrong password is answered at once" - - assert await _fail(throttle) == "429" - assert login_delays.seconds == [BLOCKED_ATTEMPT_HOLD_SECONDS] - - -@pytest.mark.asyncio -async def test_a_correct_password_signs_in_while_its_pair_is_blocked(monkeypatch): - """The block is soft: the real user is still verified and gets in, so nobody can be locked out by - guessing at their account.""" monkeypatch.setenv("UI_USERNAME", "admin") monkeypatch.setenv("UI_PASSWORD", "right") monkeypatch.setenv("DATABASE_URL", "postgresql://stub") @@ -795,13 +760,50 @@ async def test_a_correct_password_signs_in_while_its_pair_is_blocked(monkeypatch assert [await _fail(throttle, username="user@corp.com") for _ in range(3)] == ["401", "401", "429"] - result = await _db_login(throttle, "user@corp.com", "right", correct=True) - assert result.key == "sk-ui" + lookup = _known_user("user@corp.com") + verify = MagicMock(return_value=True) + with ( + patch( # test-quality-ok: the user lookup is the database boundary; a blocked attempt must not reach it + "litellm.proxy.auth.login_utils.UserRepository", lookup + ), + patch( # test-quality-ok: the password check is the expensive step; a blocked attempt must not reach it + "litellm.proxy.auth.login_utils.verify_password", verify + ), + pytest.raises(ProxyException) as refused, + ): + await authenticate_user( + username="user@corp.com", + password="right", + master_key="sk-master", + prisma_client=MagicMock(), + throttle=throttle, + ) + + assert refused.value.code == "429" + assert lookup.return_value.table.find_first.await_count == 0 + assert verify.call_count == 0 @pytest.mark.asyncio -async def test_a_correct_password_signs_in_while_its_source_is_blocked(monkeypatch): - """Same for the source-wide block: it slows guessing from that address, it does not refuse a user.""" +async def test_a_correct_password_is_refused_while_its_pair_is_blocked(monkeypatch): + """Letting the right password through would give a guesser unlimited tries, so the block is hard: the + real user waits it out, or uses the master key over the API, which never passes through here.""" + monkeypatch.setenv("UI_USERNAME", "admin") + monkeypatch.setenv("UI_PASSWORD", "right") + monkeypatch.setenv("DATABASE_URL", "postgresql://stub") + throttle = _throttle(user_limit=1, block_seconds=90) + + assert [await _fail(throttle, username="user@corp.com") for _ in range(3)] == ["401", "401", "429"] + + with pytest.raises(ProxyException) as refused: + await _db_login(throttle, "user@corp.com", "right", correct=True) + assert refused.value.code == "429" + assert refused.value.headers.get("Retry-After") == "90" + + +@pytest.mark.asyncio +async def test_a_correct_password_is_refused_while_its_source_is_blocked(monkeypatch): + """Same for the source-wide block: every username from that address is refused until it lapses.""" monkeypatch.setenv("UI_USERNAME", "admin") monkeypatch.setenv("UI_PASSWORD", "right") monkeypatch.setenv("DATABASE_URL", "postgresql://stub") @@ -811,8 +813,9 @@ async def test_a_correct_password_signs_in_while_its_source_is_blocked(monkeypat assert await _fail(throttle, username=f"other-{i}@corp.com") == "401" assert await _fail(throttle, username="other-9@corp.com") == "429", "the source is blocked for everyone" - result = await _db_login(throttle, "user@corp.com", "right", correct=True) - assert result.key == "sk-ui" + with pytest.raises(ProxyException) as refused: + await _db_login(throttle, "user@corp.com", "right", correct=True) + assert refused.value.code == "429" @pytest.mark.asyncio @@ -903,6 +906,63 @@ async def test_without_trusted_proxy_ranges_the_source_scope_is_off(monkeypatch) assert [await _fail(throttle, username=f"user-{i}@corp.com") for i in range(6)] == ["401"] * 6 +@pytest.mark.asyncio +async def test_an_empty_trusted_proxy_ranges_means_the_peer_is_the_client_and_the_source_scope_is_on(monkeypatch): + """An explicit empty list says there are no proxies: the peer address is the client, the forwarded header + is ignored, and the source-wide limit applies. Only an unset key means the topology is unknown.""" + from litellm.proxy.auth.login_throttle import LoginThrottle + + monkeypatch.setenv("UI_USERNAME", "admin") + monkeypatch.setenv("UI_PASSWORD", "right") + request = MagicMock() + request.headers = {"x-forwarded-for": "203.0.113.9"} + request.client = MagicMock() + request.client.host = "198.51.100.7" + throttle = LoginThrottle.from_request( + request, + general_settings={"trusted_proxy_ranges": [], "max_failed_login_attempts_per_source": 3}, + redis_cache=None, + ) + + assert throttle.client_ip == "198.51.100.7" + assert throttle.source_limit == 3 + assert [await _fail(throttle, username=f"user-{i}@corp.com") for i in range(4)] == ["401"] * 4 + assert await _fail(throttle, username="user-99@corp.com") == "429", "the spray is stopped by the source limit" + + +@pytest.mark.parametrize("configured", [None, 5, {"10.0.0.0/8": True}, ["", " "]]) +def test_a_trusted_proxy_ranges_value_that_names_no_ranges_leaves_the_topology_unknown(configured): + """Only a real list of ranges or an explicit empty list counts as a declaration; anything else is the same + as unset, so a typo cannot switch the source-wide block on behind a shared ingress.""" + from litellm.proxy.auth.login_throttle import LoginThrottle, declared_proxy_ranges + + settings = {"trusted_proxy_ranges": configured} if configured is not None else {} + assert declared_proxy_ranges(settings) is None + + request = MagicMock() + request.headers = {} + request.client = MagicMock() + request.client.host = "198.51.100.7" + throttle = LoginThrottle.from_request(request, general_settings=settings, redis_cache=None) + assert throttle.source_limit is None + assert throttle.client_ip == "198.51.100.7" + + +def test_declared_proxy_ranges_distinguishes_none_from_empty_from_configured(): + from litellm.proxy.auth.login_throttle import declared_proxy_ranges + + assert declared_proxy_ranges({}) is None + assert declared_proxy_ranges({"trusted_proxy_ranges": []}) == () + assert declared_proxy_ranges({"trusted_proxy_ranges": ["10.0.0.0/8", " 192.168.1.1 "]}) == ( + "10.0.0.0/8", + "192.168.1.1", + ) + assert declared_proxy_ranges({"trusted_proxy_ranges": "10.0.0.0/8,172.16.0.0/12"}) == ( + "10.0.0.0/8", + "172.16.0.0/12", + ) + + @pytest.mark.asyncio async def test_with_trusted_proxy_ranges_the_source_is_the_forwarded_client(monkeypatch): """The header is walked right to left past the trusted hops, so a forged left-most entry cannot pick the bucket.""" @@ -1056,9 +1116,10 @@ async def test_the_block_time_is_fixed_and_not_refreshed_by_blocked_guesses(monk @pytest.mark.asyncio -async def test_the_configured_admin_credentials_bypass_the_throttle(monkeypatch): - """The only account that can fix a misconfiguration is exempt: no hold, no slot, even while blocked.""" - from litellm.proxy.auth import login_throttle as lt +async def test_the_configured_admin_credentials_are_not_exempt_from_the_block(monkeypatch): + """Exempting the env credentials would make them the one password worth guessing without limit, so the + right UI_PASSWORD is refused while its pair is blocked, and signs in normally once the block lapses.""" + from litellm.proxy._types import ProxyException monkeypatch.setenv("UI_USERNAME", "admin") monkeypatch.setenv("UI_PASSWORD", "right") @@ -1075,9 +1136,31 @@ async def test_the_configured_admin_credentials_bypass_the_throttle(monkeypatch) "litellm.proxy.auth.login_utils.generate_key_helper_fn", new=AsyncMock(return_value={"token": "sk-ui"}) ), ): + with pytest.raises(ProxyException) as refused: + await _guess(throttle, password="right") + assert refused.value.code == "429" + + throttle.blocks.delete_cache(throttle._keys("admin").pair_block) result = await _guess(throttle, password="right") assert result.key == "sk-ui" - assert lt._HELD_ATTEMPTS == {} + + +@pytest.mark.asyncio +async def test_the_master_key_used_as_the_ui_password_is_not_exempt_from_the_block(monkeypatch): + """Without UI_PASSWORD the master key doubles as the admin password; it gets no special treatment here + either. Lockout recovery is the master key as a bearer token over the API, which never enters this path.""" + from litellm.proxy._types import ProxyException + + monkeypatch.setenv("UI_USERNAME", "admin") + monkeypatch.delenv("UI_PASSWORD", raising=False) + monkeypatch.setenv("DATABASE_URL", "postgresql://stub") + throttle = _throttle(user_limit=1) + + assert [await _fail(throttle) for _ in range(3)] == ["401", "401", "429"] + + with pytest.raises(ProxyException) as refused: + await _guess(throttle, password="sk-master") + assert refused.value.code == "429" @pytest.mark.asyncio @@ -1180,138 +1263,29 @@ async def test_a_wrong_password_for_a_known_user_also_counts(monkeypatch): @pytest.mark.asyncio -async def test_held_attempts_from_one_blocked_key_are_capped(monkeypatch): - """Holding a wrong guess open must not let one blocked key park unlimited sockets in password checks.""" - import asyncio - +async def test_a_source_block_outranks_a_pair_block_in_the_retry_after(monkeypatch): + """When both scopes are blocked, the answer carries the source block's time, which is the one that + still applies to every other username from that address.""" from litellm.proxy._types import ProxyException - from litellm.proxy.auth import login_throttle as lt - from litellm.proxy.auth.login_throttle import MAX_HELD_ATTEMPTS_PER_KEY 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(user_limit=1, client_ip="203.0.113.44") - slot = throttle._keys("admin").pair_block - assert [await _fail(throttle) for _ in range(2)] == ["401", "401"] - - held = [asyncio.create_task(_guess(throttle)) for _ in range(MAX_HELD_ATTEMPTS_PER_KEY)] - for _ in range(1000): - if lt._HELD_ATTEMPTS.get(slot) == MAX_HELD_ATTEMPTS_PER_KEY: - break - await asyncio.sleep(0) - assert lt._HELD_ATTEMPTS.get(slot) == MAX_HELD_ATTEMPTS_PER_KEY - - 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") == "300", "refused at once, for the whole block" - assert await _fail(throttle, username="someone-else@corp.com") == "401", "other keys are not affected" - finally: - release.set() - for task in held: - with pytest.raises(ProxyException): - await task - - assert lt._HELD_ATTEMPTS.get(slot) is None, "the slots are released once the held attempts answer" - - -@pytest.mark.asyncio -async def test_a_full_hold_pool_still_lets_the_right_password_in(monkeypatch): - """Five parked wrong guesses from the office must not turn the soft block into a lockout for the real user.""" - import asyncio - - from litellm.proxy.auth import login_throttle as lt - from litellm.proxy.auth.login_throttle import MAX_HELD_ATTEMPTS_PER_KEY - - monkeypatch.setenv("UI_USERNAME", "admin") - monkeypatch.setenv("UI_PASSWORD", "right") - monkeypatch.setenv("DATABASE_URL", "postgresql://stub") - release = asyncio.Event() - - async def _park(_seconds: float) -> None: - await release.wait() - - monkeypatch.setattr(lt, "_sleep", _park) - throttle = _throttle(user_limit=1, source_limit=3, client_ip="203.0.113.46") - assert [await _fail(throttle, username=f"spray-{i}@corp.com") for i in range(4)] == ["401"] * 4 - source_slot = throttle._keys("known@example.com").source_block - assert throttle._local_block_ttl(source_slot) > 0, "the source is blocked" - - held = [ - asyncio.create_task(_guess(throttle, username="known@example.com")) for _ in range(MAX_HELD_ATTEMPTS_PER_KEY) - ] - for _ in range(1000): - if lt._HELD_ATTEMPTS.get(source_slot) == MAX_HELD_ATTEMPTS_PER_KEY: - break - await asyncio.sleep(0) - assert lt._HELD_ATTEMPTS == {source_slot: MAX_HELD_ATTEMPTS_PER_KEY} - - try: - signed_in = await _db_login(throttle, "known@example.com", "right", correct=True) - assert signed_in.user_id == "u-1" - finally: - release.set() - for task in held: - with pytest.raises(ProxyException): - await task - - -@pytest.mark.asyncio -async def test_a_blocked_source_shares_one_held_slot_pool_across_its_blocked_usernames(monkeypatch): - """Once the source is blocked, a pair block for a username must not hand that username its own five slots.""" - 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_HELD_ATTEMPTS_PER_KEY - - 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(user_limit=1, source_limit=3, client_ip="203.0.113.45") + throttle = _throttle(user_limit=1, source_limit=3, block_seconds=120, client_ip="203.0.113.45") assert [await _fail(throttle) for _ in range(2)] == ["401", "401"], "the admin pair is now blocked" + throttle.blocks.set_cache(throttle._keys("admin").pair_block, 1, ttl=30) assert [await _fail(throttle, username=f"spray-{i}@corp.com") for i in range(3)] == ["401"] * 3 - source_slot = throttle._keys("admin").source_block - assert throttle._local_block_ttl(source_slot) > 0, "the source is now blocked as well" + assert throttle._local_block_ttl(throttle._keys("admin").source_block) == 120, "the source is now blocked too" - usernames = ["admin", *(f"fresh-{i}@corp.com" for i in range(MAX_HELD_ATTEMPTS_PER_KEY - 1))] - held = [asyncio.create_task(_guess(throttle, username=name)) for name in usernames] - for _ in range(1000): - if lt._HELD_ATTEMPTS.get(source_slot) == MAX_HELD_ATTEMPTS_PER_KEY: - break - await asyncio.sleep(0) - assert lt._HELD_ATTEMPTS == {source_slot: MAX_HELD_ATTEMPTS_PER_KEY} - - try: - for name in ("admin", "fresh-0@corp.com", "never-seen@corp.com"): - with pytest.raises(ProxyException) as over_cap: - await _guess(throttle, username=name) - assert over_cap.value.code == "429" - assert over_cap.value.headers.get("Retry-After") == "300" - finally: - release.set() - for task in held: - with pytest.raises(ProxyException): - await task - - assert lt._HELD_ATTEMPTS == {} + for name in ("admin", "spray-0@corp.com", "never-seen@corp.com"): + with pytest.raises(ProxyException) as refused: + await _guess(throttle, username=name) + assert refused.value.code == "429" + assert refused.value.headers.get("Retry-After") == "120", name @pytest.mark.asyncio -async def test_disabling_the_control_removes_the_hold_as_well(monkeypatch, login_delays): - """The escape hatch has to turn off the whole control, not only the refusal.""" +async def test_disabling_the_control_lets_every_attempt_through(monkeypatch): + """The escape hatch has to turn off the whole control: no counting and no refusal.""" import dataclasses monkeypatch.setenv("UI_USERNAME", "admin") @@ -1319,7 +1293,7 @@ async def test_disabling_the_control_removes_the_hold_as_well(monkeypatch, login throttle = dataclasses.replace(_throttle(user_limit=1), enabled=False) assert [await _fail(throttle) for _ in range(6)] == ["401"] * 6 - assert login_delays.seconds == [] + assert _local_count(throttle, throttle._keys("admin").pair_counter) == 0 class _FakeRedis: @@ -1404,12 +1378,15 @@ async def test_redis_is_the_only_counter_while_it_answers(monkeypatch): assert await _fail(second_worker, username="user@corp.com") == "429", "the second worker sees the block" + block_keys = [k for k in redis.values if ":block:user:" in k] + assert block_keys, "the block lives in Redis, where every worker reads it" + for key in block_keys: + await redis.async_delete_cache(key) await _db_login(second_worker, "user@corp.com", "right", correct=True) assert not [k for k in redis.values if ":user:" in k and ":block:" not in k], ( "success clears the shared pair counter" ) - assert [k for k in redis.values if ":block:user:" in k], "an active block is not lifted by one success" @pytest.mark.asyncio @@ -1436,7 +1413,7 @@ async def test_a_redis_outage_falls_back_to_this_workers_own_counter(monkeypatch verbose_proxy_logger.removeHandler(handler) assert blocked.value.code == "429" - assert blocked.value.headers.get("Retry-After") == "270" + assert blocked.value.headers.get("Retry-After") == "300" assert any("Redis failed while counting Admin UI sign-in attempts" in r.getMessage() for r in records) diff --git a/tests/test_litellm/proxy/proxy_server/conftest.py b/tests/test_litellm/proxy/proxy_server/conftest.py index a349d378985..d1adf2a5c02 100644 --- a/tests/test_litellm/proxy/proxy_server/conftest.py +++ b/tests/test_litellm/proxy/proxy_server/conftest.py @@ -522,16 +522,9 @@ def reset_login_throttle(monkeypatch): 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 import login_throttle from litellm.proxy.auth.login_throttle import _BLOCKS, _CACHE_KEY_PREFIX, _COUNTERS - async def _no_delay(_seconds: float) -> None: - """The hold on a rejected sign-in from a blocked key, replaced so the route tests stay fast.""" - - monkeypatch.setattr(login_throttle, "_sleep", _no_delay) - def _drop_throttle_keys() -> None: - login_throttle._HELD_ATTEMPTS.clear() for store in (_COUNTERS, _BLOCKS): for key in tuple(store.cache_dict) + tuple(store.ttl_dict): if key.startswith(_CACHE_KEY_PREFIX): 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 82e86086518..a82f078fcb9 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 @@ -553,14 +553,14 @@ 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, after the 30 seconds it was already held.""" + """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) assert [_json_login(client, "/v2/login") for _ in range(2)] == [401, 401] refused = client.post("/v2/login", json={"username": "admin", "password": "wrong"}) assert refused.status_code == 429 - assert refused.headers.get("retry-after") == "47" + assert refused.headers.get("retry-after") == "77" def test_the_form_returns_a_human_readable_lockout_page(client, monkeypatch, reset_login_throttle): @@ -572,8 +572,8 @@ def test_the_form_returns_a_human_readable_lockout_page(client, monkeypatch, res 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 47 seconds" in refused.text - assert refused.headers.get("retry-after") == "47" + 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): @@ -608,8 +608,29 @@ def test_a_spray_across_usernames_is_not_blocked_without_trusted_proxy_ranges( assert sprayed == [401] * 8 -def test_the_configured_admin_password_still_signs_in_while_blocked(client, monkeypatch, reset_login_throttle): - """The operator must never be locked out of the console by traffic aimed at it.""" +def test_a_spray_across_usernames_is_blocked_on_the_source_with_an_empty_trusted_proxy_ranges( + client, monkeypatch, reset_login_throttle +): + """An explicit empty list says nothing fronts the proxy, so the peer address is the client and the + source scope is on. A forwarded header from an untrusted peer is ignored rather than trusted.""" + _install_real_auth(monkeypatch, trusted_proxy_ranges=[], max_failed_login_attempts_per_source=4) + + sprayed = [ + client.post( + "/v2/login", + json={"username": f"sprayed-{i}@corp.com", "password": "wrong"}, + headers={"x-forwarded-for": f"203.0.113.{i}"}, + ).status_code + for i in range(5) + ] + assert sprayed == [401] * 5 + + assert _json_login(client, "/v2/login", username="sprayed-6@corp.com") == 429 + + +def test_the_configured_admin_password_is_refused_while_blocked(client, monkeypatch, reset_login_throttle): + """The env credentials get no bypass: a bypass would make them the one password worth guessing without + 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) @@ -625,18 +646,38 @@ def test_the_configured_admin_password_still_signs_in_while_blocked(client, monk "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") == 429 + reset_login_throttle() assert _json_login(client, "/v2/login", password="right-password") == 200 -def test_a_database_users_correct_password_signs_in_while_blocked(client, monkeypatch, reset_login_throttle): - """The block is soft: guessing at an account slows the guesser down, it does not lock the owner out.""" +def test_the_master_key_as_a_bearer_token_still_works_while_the_ui_password_is_blocked( + 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) + + assert [_json_login(client, "/v2/login") for _ in range(3)] == [401, 401, 429] + + assert client.get("/models", headers={"Authorization": "Bearer sk-not-the-master"}).status_code >= 400 + assert client.get("/models", headers={"Authorization": "Bearer sk-test-master"}).status_code == 200 + assert _json_login(client, "/v2/login", password="right-password") == 429, "the UI block is unaffected" + + +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) _db_user(monkeypatch, "user@corp.com") assert [_json_login(client, "/v2/login", username="user@corp.com") for _ in range(3)] == [401, 401, 429] + refused = client.post("/v2/login", json={"username": "user@corp.com", "password": "right-db-password"}) + assert refused.status_code == 429 + assert refused.headers.get("retry-after") == "64" + + reset_login_throttle() assert _json_login(client, "/v2/login", username="user@corp.com", password="right-db-password") == 200 - assert _json_login(client, "/v2/login", username="user@corp.com") == 429, "the block itself is still in force" def test_sign_in_succeeds_again_once_the_block_is_cleared(client, monkeypatch, reset_login_throttle): diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 80b1d3b1841..4c8cacf120f 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -3450,7 +3450,8 @@ async def test_load_config_warns_that_the_source_login_limit_is_off_without_trus tmp_path, monkeypatch, caplog ): """The per-source failed-login limit is skipped when the source cannot be attributed, and the - operator must be told so at startup; a configured range silences it.""" + operator must be told so at startup. Both a configured range and an explicit empty list (no + proxies, the peer is the source) silence it, since both keep the limit on.""" import logging from litellm.proxy.auth.login_throttle import warn_source_login_limit_is_off @@ -3465,12 +3466,13 @@ async def test_load_config_warns_that_the_source_login_limit_is_off_without_trus await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file)) assert "trusted_proxy_ranges is not set" in caplog.text - caplog.clear() - warn_source_login_limit_is_off.cache_clear() - config_file.write_text("model_list: []\ngeneral_settings:\n trusted_proxy_ranges: ['10.0.0.0/8']\n") - with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"): - await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file)) - assert "trusted_proxy_ranges is not set" not in caplog.text + for configured in ("['10.0.0.0/8']", "[]"): + caplog.clear() + warn_source_login_limit_is_off.cache_clear() + config_file.write_text(f"model_list: []\ngeneral_settings:\n trusted_proxy_ranges: {configured}\n") + with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"): + await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file)) + assert "trusted_proxy_ranges is not set" not in caplog.text, configured @pytest.mark.asyncio diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index ee6824ca02c..871cfea2d29 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -26512,7 +26512,7 @@ export interface components { enforce_fallback_model_access?: boolean | null; /** * Failed Login Block Seconds - * @description How long a blocked source address, or source address and username, stays blocked. The block is soft: a correct password still signs in, but each attempt from a blocked key takes one of 5 held slots per worker and a wrong password is held for 30 seconds before it is refused with 429. Set under `general_settings` in config.yaml. Defaults to 300 + * @description How long a blocked source address, or source address and username, stays blocked. Every attempt from a blocked key, right or wrong, is refused with 429 before the password is checked; the block is not extended by refused attempts. Set under `general_settings` in config.yaml. Defaults to 300 */ failed_login_block_seconds?: number | null; /** @@ -26566,7 +26566,7 @@ 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, since otherwise every client behind an ingress shares one address. 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`. 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 */ max_failed_login_attempts_per_source?: number | null; /** @@ -26756,7 +26756,7 @@ export interface components { supported_db_objects?: components["schemas"]["SupportedDBObjectType"][] | null; /** * Trusted Proxy Ranges - * @description CIDR ranges of trusted reverse proxies allowed to provide identity headers for header-based auth paths such as enable_oauth2_proxy_auth and custom_ui_sso_sign_in_handler. + * @description CIDR ranges of trusted reverse proxies allowed to provide identity headers for header-based auth paths such as enable_oauth2_proxy_auth and custom_ui_sso_sign_in_handler, and whose X-Forwarded-For is used to attribute Admin UI sign-in attempts to a source address. Set it to an empty list when clients connect directly, so the peer address is the source. Left unset, the per-source sign-in limit is off. */ trusted_proxy_ranges?: string[] | null; /**