feat(proxy): hard-block throttled Admin UI sign-ins with no credential bypass

A blocked source, or source and username pair, is now refused with 429 before the database lookup and password check, in place of the soft block that held wrong guesses for 30 seconds and let a correct password through. The env admin credentials and the master key typed into the login form are refused like any other credential while blocked; recovery is the master key as an API bearer token, which never goes through the sign-in path

trusted_proxy_ranges: [] now means clients connect directly, so the peer address is the source and the per-source limit stays on. Only an unset or malformed value leaves the topology unknown, warns at startup and turns the per-source limit off

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-17 16:10:23 +00:00
parent c05095373d
commit 9a365d2021
10 changed files with 273 additions and 279 deletions

View file

@ -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,

View file

@ -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))

View file

@ -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 <master_key>), 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 <master_key>), 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: (

View file

@ -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]:

View file

@ -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)

View file

@ -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)

View file

@ -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):

View file

@ -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):

View file

@ -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

View file

@ -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;
/**